Skip to content

fix: a run record must include the turn's closing model call - #41

Merged
linhdmn merged 1 commit into
mainfrom
fix/closing-drain
Oct 5, 2026
Merged

linhdmn merged 1 commit into
mainfrom
fix/closing-drain

Conversation

@linhdmn

@linhdmn linhdmn commented Oct 5, 2026

Copy link
Copy Markdown
Member

What was wrong

The run history reported steps and costUSD one attempt short on every turn. Measured in the Desktop app against the session log on 2026-10-05: steps equalled the session's assistant/message count minus one in 8 of 8 runs.

recorded session log off by
1 2 1
2 3 1
2 3 1
1058 1059 1
322 323 1

Root cause — ordering, not a missing drain

spendSettledUsage already ran at two seams: agent/pre-step (before each step) and agent/request (per attempt). But a turn's last attempt is settled only as the agent/request handler returns — and turn/end fires before that return. So the record is built from a budget that has not yet been charged for its closing call.

The step it drops is the turn's most expensive one: the closing answer, on the longest context. So the undercount is not spread evenly — it is systematically the worst step.

The fix

turn/end now drains before it builds the record (src/plugin.ts). The existing gate check stays first inside the same block, because that is the phase-advance that must not be reordered behind a price read; the duplicated agentOfSession lookup is folded into the one call that now needs it.

Test

test/plugin-approval.test.ts gains a fixture that models the real order — a step boundary drains while the closing answer is still unmaterialised, then the answer lands, then turn/end arrives. On the old code it fails 0 !== 1; on the new one it passes. A log that already contained the answer at the first boundary would pass either way, which is why the fixture orders the events rather than merely declaring them.

Verification

  • make test — 689 pass, 0 fail
  • make typecheck — clean
  • .githooks/pre-commit --all — clean
  • docs/PRD.md §8 updated: the "largest correctness gap" entry described a missing price path; the real defect was cadence, and it is now closed.

Not verified here: a fresh Desktop run re-measured against the session log. This PR needs merging and a profile refresh before the app picks it up.

The run history reported `steps` and `costUSD` one attempt short on every
turn. Measured in the Desktop app on 2026-10-05: `steps` equalled the
session log's `assistant/message` count minus one in 8 of 8 runs.

The cause is ordering, not a missing drain. `spendSettledUsage` runs at
`agent/pre-step` and at `agent/request`, but a turn's LAST attempt is
settled only as the `agent/request` handler RETURNS — after the `turn/end`
listener has already snapshotted the budget and written the record. So the
record is built from a budget that has not yet been charged for its final
step, and the step it misses is the turn's most expensive one: the closing
answer, on the longest context.

`turn/end` now drains before it builds the record. The gate check that was
already there stays first in the same block — it is the phase-advance that
must not be reordered behind a price read — and the duplicated
`agentOfSession` lookup is folded into the one call that now needs it.

test/plugin-approval.test.ts gains a fixture that models the real ORDER:
a step boundary drains with the closing answer still unmaterialised, the
answer lands, then `turn/end` arrives. It fails `steps: 0 !== 1` on the old
code and passes on the new one. A log that already contained the answer at
the first boundary would pass either way, which is why the fixture orders
the events instead of just declaring them.

689 tests pass, typecheck clean, pre-commit scan clean.
@linhdmn
linhdmn merged commit 2884190 into main Oct 5, 2026
3 checks passed
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