diff --git a/migrations/015_target_facts.sql b/migrations/015_target_facts.sql new file mode 100644 index 0000000..9cd36dc --- /dev/null +++ b/migrations/015_target_facts.sql @@ -0,0 +1,50 @@ +-- 015: Established facts get their own append-only table. +-- +-- WHY. `targets.state` is a single JSONB blob that submitResult REPLACED +-- wholesale (`UPDATE targets SET state = $2`). Three consequences, all of which +-- showed up on firstproof-c4: +-- +-- 1. An agent adding one fact had to reconstruct the entire working set or +-- clobber it, so what was already established could be lost by any submit. +-- 2. Debris became sticky. A note about one attempt's formatting failure, and +-- an auto-written salvage from a timed-out run, were copied forward by +-- every later agent — because dropping a key it did not understand was +-- indistinguishable from destroying a real result. +-- 3. The 64 KB cap truncated by keeping the TAIL, so the first thing dropped +-- as state grew was the accumulated head: the established facts. +-- +-- Facts are the part that must never be lost or rewritten, so they move out of +-- the blob and into rows: append-only, provenance-carrying, and unbounded by +-- the blob cap. `state` keeps its job of holding the *mutable* working set +-- (cursors, current phase, scratch), which is now merged per key rather than +-- replaced (see mergeStateUpdate). +-- +-- Append-only, with retraction rather than deletion: a fact recorded in error +-- is superseded on the record, never erased, so the history of what was +-- believed stays auditable. Same principle as the ledger. + +CREATE TABLE target_facts ( + id BIGSERIAL PRIMARY KEY, + target_id UUID NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + -- The claim, in the agent's own words. Prose on purpose: what counts as a + -- fact about an open conjecture is not enumerable in advance. + claim TEXT NOT NULL, + -- The contribution that established it. Nullable because an admin may seed a + -- fact when curating a target, before any volunteer has worked it. + established_by BIGINT REFERENCES contributions(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Superseded, not deleted. Set when a later contribution shows the claim was + -- wrong; the row stays and stops counting as current. + retracted_at TIMESTAMPTZ, + retracted_reason TEXT, + CHECK (length(claim) > 0) +); + +-- The feed reads one target's facts oldest-first; the id tiebreak keeps that +-- order total (created_at alone ties when several land in one transaction). +CREATE INDEX idx_target_facts_target ON target_facts (target_id, id); + +-- Idempotence. A replayed submit, or two agents independently establishing the +-- same thing, must not stack duplicate rows. md5 rather than the raw claim so +-- the index stays small regardless of how long a claim runs. +CREATE UNIQUE INDEX uq_target_facts_claim ON target_facts (target_id, md5(claim)); diff --git a/package.json b/package.json index b0777ba..34e2d13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "givework", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "description": "Volunteer your AI agent's spare capacity to open mathematics", "license": "Apache-2.0", diff --git a/src/executor.ts b/src/executor.ts index 12fb2fe..5cfa3fd 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -684,7 +684,13 @@ export const RESULT_JSON_SCHEMA = { }, state_update: { type: 'object', - description: "Replacement for the target's compacted working set", + description: + "Updates to the target's working set, MERGED per key — send only the keys you " + + 'changed, never a copy of the whole thing. Two reserved keys: `facts` is an array ' + + 'of short claims this run established (appended permanently, never overwritten by ' + + 'a later run — put durable results here, not in ordinary keys), and `$retract` is ' + + 'an array of key names to delete when scaffolding from an earlier attempt is no ' + + 'longer true. Omitting a key leaves it untouched; only $retract removes one.', }, artifact_uri: { type: 'string' }, code_contribution: { @@ -1466,11 +1472,13 @@ const SALVAGE_ARTIFACT_CHARS = 16_000; * assistant events when any arrived, else a chars/4 token estimate over the * prompt we sent plus whatever text came back (the prompt was certainly * processed, so the floor is 1 cent — "free" would be a lie). - * - The STATE is merged, not replaced. state_update overwrites the target's - * compacted working set, and a timeout's fragments must never clobber the - * accumulated frontier — so the salvage rides in under a `timeout_salvage` - * key beside the existing state. If there is no partial text, or the current - * state isn't a mergeable object, no state_update is sent at all. + * - The STATE update carries only `timeout_salvage`. Merging is the server's + * job now (mergeStateUpdate), so a timeout's fragments cannot clobber the + * accumulated frontier no matter what else is on the target. This used to + * spread the prior state back over itself to protect it, which worked but + * re-published every key the run never touched — that is how a stale note + * outlives the attempt that wrote it. If there is no partial text, no + * state_update is sent at all. * - Whatever text was captured is preserved in full (bounded) as the * contribution's inline result/artifact, so the next agent can continue * rather than restart. If truly nothing was captured, the contribution still @@ -1512,21 +1520,20 @@ function salvageTimedOutRun( ? 'Partial output was captured and is attached for the next agent to continue from.' : 'No partial output could be captured before the kill; a full window produced nothing visible — consider a smaller chunk or a decomposition.'); - // Merge-don't-clobber: only extend a plain-object state, never replace it. - const prior = task.target_state; - const mergeable = prior == null || (typeof prior === 'object' && !Array.isArray(prior)); - const state_update = - salvage && mergeable - ? { - ...(prior as Record | null | undefined), - timeout_salvage: { - task_id: task.task_id, - elapsed_ms: err.elapsedMs, - source, - partial: salvage.slice(0, SALVAGE_STATE_CHARS), - }, - } - : undefined; + // Send ONLY our own key. The server merges per key (mergeStateUpdate), so + // re-sending the prior state to avoid clobbering it — which is what this used + // to do — is both unnecessary and actively harmful: echoing back keys this + // agent never looked at is how stale scaffolding got copied forward forever. + const state_update = salvage + ? { + timeout_salvage: { + task_id: task.task_id, + elapsed_ms: err.elapsedMs, + source, + partial: salvage.slice(0, SALVAGE_STATE_CHARS), + }, + } + : undefined; return { result: { diff --git a/src/operations.ts b/src/operations.ts index 89f55b7..de3d651 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -932,6 +932,81 @@ function boundedProposal(proposed: unknown): unknown { }; } +/** Reserved keys in a state_update — routed, never stored in the blob. */ +const STATE_FACTS_KEY = 'facts'; +const STATE_RETRACT_KEY = '$retract'; + +export interface ParsedStateUpdate { + /** The merged working set to store on targets.state. */ + state: Record; + /** Claims to append to target_facts. */ + facts: string[]; + /** Working-set keys the agent explicitly dropped. */ + retracted: string[]; +} + +/** + * Merge a state_update into the existing working set instead of replacing it. + * + * The old behaviour was `UPDATE targets SET state = $2` — last writer wins over + * the whole blob. That forced every agent to reconstruct the entire working set + * from scratch just to add one key, so any submit could silently destroy what + * earlier work had established, and any key an agent did not recognise got + * copied forward forever because dropping it looked exactly like vandalism. + * + * Now: + * - unknown keys are merged per key, so adding one thing cannot drop another; + * - `facts: [...]` is routed to the append-only target_facts table, out of + * reach of both the blob cap and the next writer; + * - `$retract: ["key", ...]` is the ONLY way to remove a working-set key, so + * dropping stale scaffolding is a deliberate, attributable act rather than + * a side effect of omission. This is what lets an agent clean up after a + * dead end without an admin editing the field by hand. + * + * A non-object update (scalar, array, null) replaces wholesale, as before — + * targets seeded with a bare cursor keep working untouched. + */ +export function mergeStateUpdate(existing: unknown, update: unknown): ParsedStateUpdate { + const isPlain = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + if (!isPlain(update)) { + return { state: update as Record, facts: [], retracted: [] }; + } + + // Facts may be given as plain strings or as {claim} objects — an agent + // writing structured output shouldn't have to guess which we take. + const rawFacts = update[STATE_FACTS_KEY]; + const facts: string[] = []; + if (Array.isArray(rawFacts)) { + for (const f of rawFacts) { + const claim = typeof f === 'string' ? f : isPlain(f) ? String(f.claim ?? '') : ''; + const trimmed = claim.trim(); + if (trimmed) facts.push(trimmed); + } + } + + const rawRetract = update[STATE_RETRACT_KEY]; + const retracted = Array.isArray(rawRetract) + ? rawRetract.filter((k): k is string => typeof k === 'string' && k.length > 0) + : []; + + // A truncation marker is a tombstone the platform wrote, not a working set an + // agent built, so the next real update REPLACES it rather than merging into + // it. Merging would pin `truncated`/`note`/`tail` in place permanently — a + // fresh species of exactly the sticky debris this function exists to end. + const isTombstone = isPlain(existing) && existing.truncated === true && 'tail' in existing; + const base = isPlain(existing) && !isTombstone ? { ...existing } : {}; + for (const [k, v] of Object.entries(update)) { + if (k === STATE_FACTS_KEY || k === STATE_RETRACT_KEY) continue; + base[k] = v; + } + for (const k of retracted) delete base[k]; + // Never let a retired reserved key survive in the stored blob. + delete base[STATE_FACTS_KEY]; + delete base[STATE_RETRACT_KEY]; + return { state: base, facts, retracted }; +} + /** * Bound a state_update to MAX_STATE_BYTES by truncating instead of rejecting. * By the time a submit carries an oversized state the volunteer's tokens are @@ -1426,13 +1501,10 @@ export async function submitResult( const summary = typeof opts.summary === 'string' ? opts.summary.slice(0, MAX_SUMMARY_CHARS) : ''; // An oversized state is truncated, never rejected: by submit time the spend // is real, and failing the submit would lose both the work and the booking. - let stateUpdate = opts.stateUpdate; - let stateTruncated = false; - if (stateUpdate !== undefined) { - const bounded = boundedStateUpdate(stateUpdate); - stateUpdate = bounded.state; - stateTruncated = bounded.truncated; - } + // NOT bounded here any more: the update is merged into the existing working + // set inside the transaction (which needs the current row), so the size cap + // has to apply to the merged result, not to the fragment on its own. + const stateUpdate = opts.stateUpdate; // A non-terminal contribution returns the task to the pool, so `result` has // nowhere to live on the task row. Preserve it as the contribution's inline // artifact (unless the agent already supplied one) rather than dropping the @@ -1719,11 +1791,33 @@ export async function submitResult( // 6. Refresh the target's compacted working set, if the agent supplied one // (bounded above — an oversized update was truncated, not rejected). - if (stateUpdate !== undefined) { + // Merged per key, not replaced: see mergeStateUpdate. The targets row is + // locked FOR UPDATE first so two submits landing on the same conjecture + // can't read-modify-write over each other — without it, merging would + // reintroduce the very lost-update it exists to prevent. + let stateTruncated = false; + if (stateUpdate !== undefined && targetId) { + const cur = await client.query<{ state: unknown }>( + `SELECT state FROM targets WHERE id = $1 FOR UPDATE`, + [targetId], + ); + const parsed = mergeStateUpdate(cur.rows[0]?.state, stateUpdate); + const bounded = boundedStateUpdate(parsed.state); + stateTruncated = bounded.truncated; await client.query(`UPDATE targets SET state = $2 WHERE id = $1`, [ targetId, - JSON.stringify(stateUpdate), + JSON.stringify(bounded.state), ]); + // Facts are append-only and deduped by claim, so a replayed submit adds + // nothing and two agents establishing the same thing collapse to one row. + for (const claim of parsed.facts) { + await client.query( + `INSERT INTO target_facts (target_id, claim, established_by) + VALUES ($1, $2, $3) + ON CONFLICT (target_id, md5(claim)) DO NOTHING`, + [targetId, claim, contrib.rows[0].id], + ); + } } // 7. Funnel: on THIS connection, under a savepoint — see checkoutTask. The @@ -2446,6 +2540,43 @@ export interface TargetProgress { metrics: TargetProgressMetrics; /** The newest page of the feed; page the rest via listTargetContributions. */ recent_contributions: TargetContribution[]; + /** + * What this conjecture has actually established, oldest first. Append-only + * and out of `state`, so no later submit can overwrite it. + */ + facts: TargetFact[]; + /** + * What is left to do, DERIVED from the task graph on every read — never + * stored, so it cannot go stale. This is what a hand-maintained "NEXT: …" + * field in `state` was standing in for. + */ + next_steps: TargetNextSteps; +} + +export interface TargetFact { + id: number; + claim: string; + /** The contribution that established it, or null for an admin-seeded fact. */ + established_by: number | null; + created_at: string; + /** Superseded rather than deleted; retracted facts stay on the record. */ + retracted_at: string | null; + retracted_reason: string | null; +} + +export interface TargetNextSteps { + /** Open tasks a volunteer could claim right now. */ + claimable: { id: string; title: string; kind: string; max_cost_cents: number }[]; + /** Submitted, waiting on verification or review — work in flight, not lost. */ + awaiting_verification: number; + /** + * Tasks whose lock expired: claimed, never finished, back in the pool. A + * large number here is the signal that something is systematically failing + * (the C4 timeout pile), which no prose field would ever have surfaced. + */ + expired: number; + /** Nothing claimable and nothing in flight — the target needs decomposition. */ + stalled: boolean; } /** One row of a conjecture's public contribution feed. */ @@ -2529,6 +2660,7 @@ export async function getTargetProgress(slug: string): Promise { + const { rows } = await query<{ + id: string; + claim: string; + established_by: string | null; + created_at: string | Date; + retracted_at: string | Date | null; + retracted_reason: string | null; + }>( + `SELECT id, claim, established_by, created_at, retracted_at, retracted_reason + FROM target_facts WHERE target_id = $1 ORDER BY id`, + [targetId], + ); + return rows.map((r) => ({ + id: Number(r.id), + claim: r.claim, + established_by: r.established_by === null ? null : Number(r.established_by), + created_at: new Date(r.created_at).toISOString(), + retracted_at: r.retracted_at ? new Date(r.retracted_at).toISOString() : null, + retracted_reason: r.retracted_reason, + })); +} + +/** + * What is left to do, computed from the task graph rather than stored. + * + * The whole reason a target ever carried a hand-written "NEXT: propose a + * decomposition of …" was that nothing derived it. A stored directive is stale + * the moment the work it describes lands, and then someone has to go and edit + * the field — forever, once per conjecture per turn of work. This cannot go + * stale, because there is nothing to keep up to date. + */ +async function deriveNextSteps(targetId: string): Promise { + const { rows } = await query<{ + id: string; + title: string; + kind: string; + max_cost_cents: string; + status: string; + }>( + `SELECT id, title, kind::text AS kind, max_cost_cents, status::text AS status + FROM tasks + WHERE target_id = $1 AND status IN ('open', 'submitted', 'locked', 'expired') + ORDER BY max_cost_cents, created_at`, + [targetId], + ); + const claimable = rows + .filter((r) => r.status === 'open') + .map((r) => ({ + id: r.id, + title: r.title, + kind: r.kind, + max_cost_cents: Number(r.max_cost_cents), + })); + const awaiting = rows.filter((r) => r.status === 'submitted').length; + const inFlight = rows.filter((r) => r.status === 'locked').length; + return { + claimable, + awaiting_verification: awaiting, + expired: rows.filter((r) => r.status === 'expired').length, + stalled: claimable.length === 0 && awaiting === 0 && inFlight === 0, }; } diff --git a/test/executor.test.ts b/test/executor.test.ts index cc1d372..94a8119 100644 --- a/test/executor.test.ts +++ b/test/executor.test.ts @@ -612,9 +612,13 @@ describe('ClaudeCliExecutor — timeout salvage', () => { expect(r.summary).toContain('timed out after 4 minute(s)'); // partial findings preserved for the next agent… expect((r.result as any).partial_output).toContain('no counterexample so far'); - // …and merged BESIDE the existing state, never clobbering it - expect((r.state_update as any).frontier).toBe('n < 10^5 done'); + // …carried in a state_update that names ONLY this run's own key. Merging is + // the server's job (mergeStateUpdate), so the existing frontier is safe + // without the executor echoing it back — and echoing it back is precisely + // how a stale key outlives the attempt that wrote it. expect((r.state_update as any).timeout_salvage.partial).toContain('10^6'); + expect(Object.keys(r.state_update as any)).toEqual(['timeout_salvage']); + expect((r.state_update as any).frontier).toBeUndefined(); // cost metered from the streamed usage, flagged as an estimate expect(r.actual_cost_cents).toBe( usageToCents('claude-sonnet-4-6', { diff --git a/test/helpers.ts b/test/helpers.ts index 79ad9a3..ff72e7b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -33,7 +33,8 @@ if (url && !looksLocal && process.env.TEST_DB_ALLOW_REMOTE !== '1') { export async function resetDb(): Promise { await pool.query( `TRUNCATE ledger, verifications, contributions, funnel_events, tasks, intake_attachments, - intake_requests, dev_budgets, target_budgets, target_identifiers, targets, devs + intake_requests, dev_budgets, target_budgets, target_identifiers, target_facts, + targets, devs RESTART IDENTITY CASCADE`, ); } diff --git a/test/state-governor.test.ts b/test/state-governor.test.ts new file mode 100644 index 0000000..31da172 --- /dev/null +++ b/test/state-governor.test.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { closePool } from '../src/db.js'; +import { + checkoutTask, + getTargetProgress, + mergeStateUpdate, + submitResult, +} from '../src/operations.js'; +import { app } from '../src/server.js'; +import { createDev, createTask, mintAdminToken, resetDb, setBudget } from './helpers.js'; + +// The working set stopped being a blob that every submit overwrote. +// +// Established facts are append-only rows; the mutable working set merges per +// key; removing a key is an explicit, attributable act; and "what's next" is +// derived from the task graph instead of being written down and going stale. + +afterAll(closePool); + +let adminTok: string; +beforeEach(async () => { + await resetDb(); + adminTok = await mintAdminToken(); +}); + +const req = (path: string, init?: RequestInit) => + app.fetch(new Request(`http://test${path}`, init)); + +async function conjecture(slug: string) { + const res = await req('/admin/targets', { + method: 'POST', + headers: { authorization: `Bearer ${adminTok}`, 'content-type': 'application/json' }, + body: JSON.stringify({ name: slug, slug, kind: 'conjecture' }), + }); + return (await res.json()) as any; +} + +async function work(targetId: string, dev: string, stateUpdate: unknown, summary = 'did a thing') { + const task = await createTask(targetId, { max: 500 }); + await checkoutTask(dev, task); + return submitResult(dev, task, { ok: true }, 5, null, { + outcome: 'progress', + summary, + stateUpdate, + }); +} + +describe('mergeStateUpdate (pure)', () => { + it('merges per key instead of replacing the whole blob', () => { + const out = mergeStateUpdate({ cursor: 100, best: 'a' }, { cursor: 200 }); + // The old behaviour dropped `best` entirely — any submit could destroy what + // it did not happen to re-state. + expect(out.state).toEqual({ cursor: 200, best: 'a' }); + expect(out.facts).toEqual([]); + }); + + it('routes facts out of the blob', () => { + const out = mergeStateUpdate({ cursor: 1 }, { cursor: 2, facts: ['girth 6 closed'] }); + expect(out.facts).toEqual(['girth 6 closed']); + expect(out.state).toEqual({ cursor: 2 }); // never stored in the blob + }); + + it('accepts facts as strings or {claim} objects, ignoring blanks', () => { + const out = mergeStateUpdate({}, { facts: ['a', { claim: 'b' }, '', ' ', { claim: '' }, 7] }); + expect(out.facts).toEqual(['a', 'b']); + }); + + it('removes a key only when explicitly retracted', () => { + const existing = { cursor: 1, attempt7_note: 'debris', timeout_salvage: { partial: 'x' } }; + // Omitting a key no longer deletes it... + expect(mergeStateUpdate(existing, { cursor: 2 }).state).toHaveProperty('attempt7_note'); + // ...naming it does. This is what lets an agent clean up after a dead end + // without an admin editing the field by hand. + const cleaned = mergeStateUpdate(existing, { + cursor: 2, + $retract: ['attempt7_note', 'timeout_salvage'], + }); + expect(cleaned.state).toEqual({ cursor: 2 }); + expect(cleaned.retracted).toEqual(['attempt7_note', 'timeout_salvage']); + }); + + it('never leaves the reserved keys in the stored blob', () => { + const out = mergeStateUpdate({ facts: ['stale'], $retract: ['x'] }, { a: 1 }); + expect(out.state).toEqual({ a: 1 }); + }); + + it('replaces wholesale for a non-object update, as before', () => { + expect(mergeStateUpdate({ a: 1 }, 42).state).toBe(42 as never); + expect(mergeStateUpdate({ a: 1 }, [1, 2]).state).toEqual([1, 2] as never); + expect(mergeStateUpdate({ a: 1 }, null).state).toBeNull(); + }); + + it('tolerates a non-object existing state', () => { + expect(mergeStateUpdate(null, { a: 1 }).state).toEqual({ a: 1 }); + expect(mergeStateUpdate('legacy', { a: 1 }).state).toEqual({ a: 1 }); + }); + + it('replaces a truncation tombstone rather than merging into it', () => { + // The platform writes {truncated, note, tail} when a state blows the cap. + // Merging over it would pin those three keys forever — the same sticky + // debris this whole change exists to stop, reintroduced by the fix. + const tomb = { truncated: true, note: 'exceeded 65536 bytes', tail: '…' }; + expect(mergeStateUpdate(tomb, { frontier: 'small and tidy' }).state).toEqual({ + frontier: 'small and tidy', + }); + // An agent's own `truncated` key (no tail) is ordinary content and merges. + expect(mergeStateUpdate({ truncated: true, cursor: 5 }, { a: 1 }).state).toEqual({ + truncated: true, + cursor: 5, + a: 1, + }); + }); +}); + +describe('facts survive submits that used to clobber them', () => { + it('accumulates across agents and is never overwritten by a later working set', async () => { + const conj = await conjecture('facts'); + const a = await createDev('agent-a'); + const b = await createDev('agent-b'); + await setBudget(a, 100_000); + await setBudget(b, 100_000); + + await work(conj.id, a, { cursor: 100, facts: ['searched to 100'] }); + // Agent B knows nothing about A's state and writes only its own key — the + // exact submit that used to destroy everything before it. + await work(conj.id, b, { note: 'unrelated', facts: ['girth 6 closed'] }); + + const p = (await getTargetProgress('facts'))!; + expect(p.facts.map((f) => f.claim)).toEqual(['searched to 100', 'girth 6 closed']); + expect(p.state).toEqual({ cursor: 100, note: 'unrelated' }); // merged, not replaced + // Each fact is attributable to the contribution that established it. + expect(p.facts.every((f) => typeof f.established_by === 'number')).toBe(true); + }); + + it('is idempotent — a repeated claim does not stack duplicate rows', async () => { + const conj = await conjecture('dedupe'); + const dev = await createDev('repeater'); + await setBudget(dev, 100_000); + await work(conj.id, dev, { facts: ['n verified to 10^6'] }); + await work(conj.id, dev, { facts: ['n verified to 10^6'] }); + const p = (await getTargetProgress('dedupe'))!; + expect(p.facts).toHaveLength(1); + }); + + it('is not truncated away when the working set grows past the blob cap', async () => { + const conj = await conjecture('bigstate'); + const dev = await createDev('bloater'); + await setBudget(dev, 100_000); + await work(conj.id, dev, { facts: ['this must survive'] }); + // A working set well past MAX_STATE_BYTES (64 KB). + await work(conj.id, dev, { blob: 'x'.repeat(80 * 1024) }); + const p = (await getTargetProgress('bigstate'))!; + // The blob got truncated; the fact did not, because it is not in the blob. + expect((p.state as any).truncated).toBe(true); + expect(p.facts.map((f) => f.claim)).toEqual(['this must survive']); + }); +}); + +describe('next steps are derived, so they cannot go stale', () => { + it('lists claimable work and counts what is in flight or expired', async () => { + const conj = await conjecture('derived'); + const dev = await createDev('derive-dev'); + await setBudget(dev, 100_000); + await createTask(conj.id, { max: 40 }); + await createTask(conj.id, { max: 15 }); + + const p = (await getTargetProgress('derived'))!; + expect(p.next_steps.claimable).toHaveLength(2); + // Cheapest first: the smallest next step is the easiest to pick up. + expect(p.next_steps.claimable[0].max_cost_cents).toBe(15); + expect(p.next_steps.stalled).toBe(false); + expect(p.next_steps.awaiting_verification).toBe(0); + }); + + it('reports stalled when there is no claimable work and nothing in flight', async () => { + await conjecture('stalled'); + const p = (await getTargetProgress('stalled'))!; + expect(p.next_steps.claimable).toEqual([]); + expect(p.next_steps.stalled).toBe(true); + }); + + it('drops a task from claimable the moment it is claimed — no field to update', async () => { + const conj = await conjecture('live'); + const dev = await createDev('live-dev'); + await setBudget(dev, 100_000); + const task = await createTask(conj.id, { max: 40 }); + expect((await getTargetProgress('live'))!.next_steps.claimable).toHaveLength(1); + await checkoutTask(dev, task); + const after = (await getTargetProgress('live'))!.next_steps; + expect(after.claimable).toEqual([]); + expect(after.stalled).toBe(false); // in flight, not stalled + }); +});