Skip to content

Stop the API losing track of live agent runs, and stop the evidence gate rejecting its own source ids - #92

Merged
braedonsaunders merged 2 commits into
mainfrom
fix/agent-liveness-and-evidence-gate
Sep 10, 2026
Merged

Stop the API losing track of live agent runs, and stop the evidence gate rejecting its own source ids#92
braedonsaunders merged 2 commits into
mainfrom
fix/agent-liveness-and-evidence-gate

Conversation

@braedonsaunders

Copy link
Copy Markdown
Owner

Two production failures found on the Birla electrical quotes (CB-260910-0001 and -0002).

1. A stale reap timer blinded the API to a live run

A finished session schedules sessions.delete(projectId) five minutes later. A follow-up run starting inside that window takes the slot, and the previous run's timer then evicted the new, running session.

On CB-260910-0001: run 1 completed 16:48:59, the follow-up started 16:52:31, and at 16:53:59 run 1's timer wiped run 2's live entry — 3 seconds before it asked its scope question. From there:

  • resumeSession's "already running" guard read the empty map, passed silently, and let a resume collide with the live agent. Codex refused the second writer (thread-store conflict: thread <id> already has an active writer); the resumed run died in ~300ms.
  • /answer concluded nobody was polling the askUser prompt and auto-resumed on top of the running agent — one second after the answer had already been delivered.
  • Each stillborn resume became the newest AiRun and therefore latestRun, so status reported the project as failed while the estimate was still building. Every Resume click appended another and re-applied the mask (8 in total).

The run never stopped. It finished normally at 17:28:27 with the full 4-job quote.

Fixes: identity-checked reapSession; probeLiveAgent decides liveness by probing (registry, then .bidwright/session.json + kill(pid, 0), with a new ownerPid so a pid from another API process or a restored snapshot is never trusted); selectLatestRun skips a stillborn tail while a real run is still open.

2. The evidence gate rejected the source ids it asks for

60 of 65 createWorksheetItem calls failed on CB-260910-0002.

  • looksLikeStructuredSourceRef accepted only -/: after the prefix, but SourceDocument mints doc_<uuid> and LineItemSearchDocument mints lis_<hex>. Both scored zero structured refs, so a row citing a real document was rejected for "needs structured cite" regardless of accuracy — and those are exactly the ids available for a material row. The message compounded it by naming doc-, a prefix nothing produces.
  • sourceRefs was z.array(z.string()), so object refs returned a bare MCP error -32602 with no hint at the intended shape. New sourceRefArray() accepts either and normalizes.
  • 39 calls arrived with only worksheetId + entityName (the two params without defaults) because the tool call was cut off mid-serialization. The server blamed evidence, sending the agent rewriting evidenceBasis 39 times. Now detected and reported as truncation.

Testing

17 new tests across four files, replaying recorded prod payloads. Verified they catch the regressions: reverting the reap guard fails the resume test, reverting the regex fails the minted-id test.

Also replaces the inert assertions in resume-session-guard.test.ts, which asserted that resumeSession contained the string session.status === "running" — true for three weeks while the guard did nothing.

Full focused suite: 325 pass, 0 fail. Both packages typecheck.

🤖 Generated with Claude Code

braedonsaunders and others added 2 commits September 10, 2026 14:15
A session that finishes schedules `sessions.delete(projectId)` five minutes
later. If a follow-up run starts inside that window it takes the project's
slot, and the previous run's timer then evicted the *new*, running session.

Everything downstream reads that registry, so the API went blind to its own
agent while the child kept working:

  - `resumeSession`'s "already running" guard consulted the empty map, so it
    silently passed and let a resume collide with the live agent. Codex refused
    the second writer -- "thread-store conflict: thread <id> already has an
    active writer" -- and the resumed run died in ~300ms.
  - `/answer` concluded nobody was polling the askUser prompt and auto-resumed
    on top of the running agent, producing that same collision one second after
    the answer had already been delivered.
  - Each stillborn resume became the newest AiRun and therefore `latestRun`, so
    the status endpoint reported the whole project as "failed" while the
    estimate was still being built. Every Resume click appended another and
    re-applied the mask.

On CB-260910-0001 the run survived all of it and finished normally 32 minutes
later, having never stopped -- only the UI ever thought otherwise.

Three changes:

  - `reapSession` deletes only if the slot still holds that same session.
    Extracted as a pure function over an injected Map so the takeover case is
    testable without spawning a process.
  - `probeLiveAgent` decides liveness by probing: registry first, then
    `.bidwright/session.json` + `kill(pid, 0)`. `persistSessionState` now
    records `ownerPid`, and a recorded pid is trusted only when this API
    process spawned it -- a pid from an earlier process, or restored with a
    workspace snapshot from another host, is not ours and pids get reused.
    Both the resume guard and `/answer` use it.
  - `selectLatestRun` skips a tail of stillborn runs (failed, and never emitted
    `status: running`) while the last run that did start is still open. It
    walks the whole tail, since each retry added one. When nothing else is
    running the newest run still wins, so genuine start-up failures surface.

`selectLatestRun` lives in its own module because anything defined in
cli-routes.ts can only be tested by grepping its source text -- which is how
the resume guard stayed inert for three weeks while
`resume-session-guard.test.ts` asserted that it contained
`session.status === "running"`. That file now checks route wiring only; the
rule itself is covered behaviourally in cli-runtime-liveness.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the Birla electrical quote 60 of 65 createWorksheetItem calls were
rejected. Three separate causes:

1. `looksLikeStructuredSourceRef` accepted only `-` or `:` after the prefix,
   but SourceDocument mints `doc_<uuid>` and LineItemSearchDocument mints
   `lis_<hex>`. Both scored zero structured refs, so a row citing a real source
   document was rejected for "needs structured cite" no matter how accurately
   it cited it -- and those are precisely the two ids an agent has for a
   material row. Every other minted id (lu-, ds-, kb-, rsi-, ecost-, rci-)
   passed, which is why this only bit material and composite rows.

   The rejection text made it worse by naming `doc-` as the format to use, a
   prefix nothing in the system produces. Gate messages now interpolate
   STRUCTURED_SOURCE_REF_HINT, which lists real prefixes.

2. `sourceRefs` was `z.array(z.string())`, so when the agent read "structured
   sourceRefs" as "send a structure" it got back a bare
   `MCP error -32602: expected string, received object` with no hint at the
   intended shape. It then alternated between object and string forms, each
   rejected by a different layer for a different reason. The new
   `sourceRefArray()` accepts either and normalizes objects to the string form
   the gates read (`{documentId, page}` -> `doc_3b409f90 p.4`), dropping
   entries with nothing identifiable rather than storing "[object Object]".
   Applied to all ten sourceRefs schemas, which also fixes the
   saveEstimateScopeGraph scopeItems[].sourceRefs rejection.

3. 39 calls arrived carrying only `worksheetId` and `entityName` -- the two
   parameters without a default -- because the tool call was cut off while
   being serialized. The server answered "Line evidence basis is required",
   which is true and diagnostically useless: it sent the agent rewriting
   evidenceBasis 39 times over a payload that never arrived intact. It worked
   this out itself ("I'm repeatedly truncating my own tool call") and
   eventually delegated the rows to a sub-agent. That shape is now detected and
   reported as a truncated call, telling the agent not to retry the same way
   and pointing at createRateScheduleWorksheetItem, whose smaller payload
   succeeded 13 of 16 times in the same run against 5 of 65.

Tests replay the recorded prod payloads and are registered in CI, along with
the session-liveness tests from the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
bidwright-demo Ready Ready Preview Sep 10, 2026 6:17pm UTC

Request Review

@braedonsaunders
braedonsaunders merged commit 0a24c63 into main Sep 10, 2026
6 checks passed
@braedonsaunders
braedonsaunders deleted the fix/agent-liveness-and-evidence-gate branch September 10, 2026 19:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant