feat(inspector): project a per-session causal trace over both ledgers - #1956
Conversation
599264c to
77afd2c
Compare
|
Two runs of identical content failed different tests. Run 1: This diff is 891 added lines and touches no runtime-host code: a new contract file in Why it lands on this PR rather than on So a core-touching PR runs the full matrix under parallel load, while a UI-only push on The counter-evidence, for completeness: #1877 also touched So: happy to wait for the de-racing to settle and rebase, or to look into either failure properly as its own change if that's useful. What I'd rather not do is keep pushing this branch to reroll the dice. |
First slice of apache#1625, and the one that was blocked: the RFC's headline — per-step latency and cost — was not derivable from `RuntimeEvent`, which is why this issue paused for the accounting work (apache#1687, apache#1755, apache#1877). That work landed, so `ModelCallAttempt` now carries one record per physical provider request with `step`, `attempt`, latency, time-to-first-token, usage basis, and a cost frozen at call time. The projection this issue always wanted is now writable honestly. Pure and synchronous: both ledgers are handed in already read, so the caller owns the I/O and `@maka/storage` stays out of `@maka/runtime`. Contract in `packages/core`, builder in `packages/runtime`, per the split agreed on the issue. Three properties are deliberate: - **Retries are nested, not flattened.** Attempts of one logical call share a `logicalCallId` by contract, so "this call was retried twice" is a grouping rather than something a reader reconstructs from four steps that happen to share an id. - **An absent price stays absent.** A step whose attempts were never priced has no `costUsd`, and a session of only such calls totals to no price rather than to zero — the distinction the canonical record exists to keep. - **Coverage is stated, not implied.** A backend that emits no canonical records produces a trace that says so. The pi backend is exactly this case: it emits `token_usage` and zero `ModelCallAttempt`, and an empty timeline would be indistinguishable from a session that did nothing. Aggregate usage with no record behind it is the signal, reported per session as `absent` or `partial` with the turns named. Failure attribution points at what failed *first*, not at the terminal error: a turn that ends in an error usually ends there because of an earlier tool failure, and naming the last event names the symptom. No UI, no search API, no cost authority of its own — a per-session total sums the same `ModelCallAttempt` records Settings → Usage aggregates, read at a different scope. `@maka/core` 703/703, `@maka/runtime` 2721/2733 (3 suites = documented local `rg` noise). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77afd2c to
d44c2f6
Compare
|
Green after rebasing onto The rebase picked up #1964, which split |
Astro-Han
left a comment
There was a problem hiding this comment.
The overall shape looks good to me: the contract/builder split follows the existing package boundary, the projection stays pure, retries retain their logical grouping, and unpriced spend is not collapsed into a false $0. Approving this direction.
I found six correctness follow-ups that are worth addressing. I don't think they rise to P1 for this currently unconsumed read model, but they should be fixed before the Inspector UI relies on these fields:
-
Model-call coverage overstates what is known. A turn with
tokenUsage.runtimeSteps = 2and only one logical main-call attempt is currently reported ascomplete. The canonicalModelCallAttemptcontract explicitly says present settlements cannot prove completeness. Detectable shortfalls should bepartial, and the no-known-gap state should not imply a completeness proof. (packages/runtime/src/session-trace-projection.ts:55-94,packages/core/src/session-trace.ts:191-199) -
Attempts need canonical
attemptIddeduplication. A provisional abort and its later settlement can be appended under the same attempt id. WithoutdedupeModelCallAttempts, the trace invents a retry/unpriced attempt and can double-count repeated priced settlements, disagreeing with Settings → Usage. (packages/runtime/src/session-trace-projection.ts:132-163,300-312) -
Step-less turns produce non-finite bounds. Usage-only, text-only, and history-compaction text turns have no projected steps, so
Math.min/max(...steps)yieldsInfinity/-Infinity; JSON serialization turns those intonull. Turn bounds should come from the input ledger facts, not only visible steps. (packages/runtime/src/session-trace-projection.ts:97-123) -
Handled tool failures currently mark a completed turn as failed. A failed tool result followed by model recovery and terminal
status: 'completed'still getsfailure: { code: 'tool_failed' }. Terminal RuntimeEvent status should decide whether the turn failed; failed steps should only locate the cause after that fact is established. (packages/runtime/src/session-trace-projection.ts:271-293) -
The public compaction step is never emitted.
TraceCompactionStepexists, but real history-compaction checkpoints are system text RuntimeEvents andprojectEventStepsignores them. Ahistory_compactmodel-call attempt represents the summarizer request, not the written compaction boundary. (packages/core/src/session-trace.ts:28,111-119,packages/runtime/src/session-trace-projection.ts:195-261) -
recoveryModeis a policy, not evidence that recovery occurred. The dispatch field is present on normal first executions too, while actual recovery results live inactions.toolRecovery. The trace should distinguish the declared recovery policy from a durable recovery decision, correlated byoperationId. (packages/core/src/session-trace.ts:97-98,packages/runtime/src/session-trace-projection.ts:201-215)
The current tests are green, but regression cases for raw duplicate settlements, usage-only turns, successful recovery after a tool error, detectable runtimeSteps gaps, compaction boundaries, and recovery decisions would close these gaps cleanly.
Review on apache#1956. All six were right; four are cases the tests never drove and two were contract surface that promised more than the code delivered. **Coverage claimed a proof it cannot have.** `complete` said every call settled; the canonical contract says present settlements cannot prove completeness. The state is now `no_known_gap` — the absence of evidence of a gap. A detectable shortfall is also new: `tokenUsage.runtimeSteps` states how many tool-loop steps one aggregate stands for, and fewer main calls on record than that is a disagreement between the two ledgers, reported per turn as a floor on what is missing rather than a count of it. **Attempts are deduplicated by `attemptId`.** An aborted attempt and its later settlement are appended under one id; the ledger dedupes on write, a stream read does not. Without `dedupeModelCallAttempts` the trace invented a retry and double-counted a priced settlement, which would have put a session total out of step with Settings → Usage over the very same records. Grouping now goes through core's `groupModelCallAttempts`, which dedupes on the way in. **Step-less turns had non-finite bounds.** Usage-only and text-only turns project no steps, and folding an empty list gives ±Infinity — which JSON renders as `null`. Bounds now come from the ledger facts the turn is made of. **A handled tool failure no longer fails the turn.** Any failed step marked the whole turn failed, including a tool error the model recovered from before finishing normally. The ledger's terminal status decides whether the turn failed; the failed step only locates the cause once that is established. **The compaction step is emitted rather than merely declared.** `TraceCompactionStep` existed and nothing produced it. Written checkpoints are system text events, and they are a different fact from the `history_compact` model call: one is the boundary the next request replays from, the other is the spend. Both now appear. **`recoveryMode` was a policy wearing the name of an outcome.** Every dispatch declares one, including ordinary first executions. It is now `recoveryPolicy`, and an actual `recovered` decision is joined from `actions.toolRecovery` by `operationId` — correlated rather than positional, because the decision is appended by the recovery writer and not by the dispatch it settles. Seven regression tests, one per finding plus the no-known-gap state. `@maka/core` 703/703, `@maka/runtime` 2742/2757 — the 6 local failures are this machine's `rg`-as-a-shell-function and macOS path noise; CI ran the same base green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — all six were right, and two of them were the failure mode I keep having to relearn: contract surface that promised more than the code delivered. Fixed in 1. Coverage claimed a proof it cannot have. 2. 3. Non-finite bounds. 4. Handled tool failures. Fixed as you describe: the ledger's terminal status decides whether the turn failed, and a failed step only locates the cause once that is established. A tool error the model recovered from now leaves the step failed and the turn not. 5. The compaction step was declared and never emitted. Written checkpoints are now projected from the 6. Seven regression tests, one per finding plus the no-known-gap state: duplicate settlements,
|
Astro-Han
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head. The six previous correctness issues are fixed with targeted regression coverage, and the current CI is green. Approving the PR.
One non-blocking P2 follow-up: failure attribution currently sorts projected steps by startedAt and then selects the first failed step. Because tool calls within a model step execute concurrently, this can attribute the turn to the tool that started first rather than the one that failed first. For example, tool A may start before B but finish with an error after B; the trace will point to A even though B failed first.
Please consider comparing failure timestamps instead: tool endedAt, the terminal model attempt completedAt, and error startedAt. A regression test with two concurrent tools completing in reverse order would lock the intended points-at-what-failed-first contract.
Second layer of apache#1625, stacked on apache#1956: a read-only IPC that projects one session's trace, and a fifth `SessionWorkbar` tab that renders it. **The read boundary is the AgentRun stream, not the Usage read model.** Usage is range-queried and carries no session predicate; the stream is the authority and is already keyed by session and run, which is how `context-diagnostics.ts` reads it too. `inspector:trace` reads both ledgers and writes nothing. **A record that will not decode is counted, not dropped.** `coverage` gains `unreadableRecords`, because spend the trace cannot show is exactly the kind of gap that otherwise looks like an absence of activity — the same failure mode the pi-backend case already forced into the open. **The panel's judgements are pure and tested without a DOM.** `deriveInspectorPanelModel` decides what counts as a gap worth showing and whether a cost may be rendered at all; `SessionInspectorPanel` only lays the result out. An unpriced call renders "cost unavailable" rather than `$0`, which is the same distinction the ledger keeps. Failure highlighting follows the attributed step rather than every step after it. The tab lives on `SessionWorkbar` and not on `AgentGraphPanel`, which is gated on `orchestrationMode === 'graph'` and would have hidden the Inspector from ordinary sessions. It refreshes only while visible: a hidden panel re-projecting a long session is a cost with no reader. Tests: 3 for the IPC read (ledger join, undecodable record, non-canonical run events), 5 for the panel model, and the workbar persistence whitelist — which had no coverage at all, so a tab that renders but is not listed there would silently revert to Tasks after a restart. `@maka/core` 703/703, session-trace projection 16/16, desktop main targeted suites green; `check-a11y`, `check-console`, `check-copy`, `check-dead-css` and `format:check` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(inspector): surface the session trace as a workbar tab Second layer of #1625, stacked on #1956: a read-only IPC that projects one session's trace, and a fifth `SessionWorkbar` tab that renders it. **The read boundary is the AgentRun stream, not the Usage read model.** Usage is range-queried and carries no session predicate; the stream is the authority and is already keyed by session and run, which is how `context-diagnostics.ts` reads it too. `inspector:trace` reads both ledgers and writes nothing. **A record that will not decode is counted, not dropped.** `coverage` gains `unreadableRecords`, because spend the trace cannot show is exactly the kind of gap that otherwise looks like an absence of activity — the same failure mode the pi-backend case already forced into the open. **The panel's judgements are pure and tested without a DOM.** `deriveInspectorPanelModel` decides what counts as a gap worth showing and whether a cost may be rendered at all; `SessionInspectorPanel` only lays the result out. An unpriced call renders "cost unavailable" rather than `$0`, which is the same distinction the ledger keeps. Failure highlighting follows the attributed step rather than every step after it. The tab lives on `SessionWorkbar` and not on `AgentGraphPanel`, which is gated on `orchestrationMode === 'graph'` and would have hidden the Inspector from ordinary sessions. It refreshes only while visible: a hidden panel re-projecting a long session is a cost with no reader. Tests: 3 for the IPC read (ledger join, undecodable record, non-canonical run events), 5 for the panel model, and the workbar persistence whitelist — which had no coverage at all, so a tab that renders but is not listed there would silently revert to Tasks after a restart. `@maka/core` 703/703, session-trace projection 16/16, desktop main targeted suites green; `check-a11y`, `check-console`, `check-copy`, `check-dead-css` and `format:check` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inspector): read the Result payload by its actual field name `Result<T>` is `{ ok: true; data: T }`, not `{ value }`. The renderer typecheck caught it; I could not, because `@maka/ui` does not build in this worktree, so `tsconfig.renderer.json` never ran here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(inspector): make the panel actually follow a live session Review on #2018. The P2 was right, and it is the same failure I have now made four times in this track: the hook's comment described refreshing on the session's event stream and nothing subscribed to anything. A user watching a running turn would have seen a snapshot frozen at the moment they opened the tab. Implemented rather than documented away, because the seam already existed (`sessions.subscribeEvents`) and a trace panel that silently stops tracking is worse than no panel. The policy is its own pure module so it is testable without a DOM, which is why the claim can now be checked instead of asserted: - Only events that can change a ledger trigger a read. A streaming turn emits text and tool-output deltas continuously; re-projecting a whole session per delta would spend the session's own latency budget watching it. - A burst coalesces into one read. A finishing turn emits `tool_result`, `token_usage` and `complete` within milliseconds — each alone justifies a re-read, three do not. - The subscription and any scheduled read are dropped when the panel hides, so "refreshes only while visible" is enforced rather than described. Also from the review: the empty state no longer renders beside an error ("nothing to trace" is a claim about the session, and a failed read cannot make it); the panel model imports core's `emptyTraceTotals` instead of redefining it; the fixtures use `satisfies TraceStep` instead of `as never`, which is what let a malformed step through in the first place; and `registerInspectorIpc` has a behaviour test proving a failed read answers as a typed `Result` rather than a rejected invoke. Deferred with reasons, per the review's own framing: unbounded per-activation reads and timeline virtualization (a real limit, but it wants a paging contract rather than a patch), and story/e2e coverage of the panel's interactive states. 19 desktop inspector tests green; `check-a11y`, `check-dead-css`, `check-console` and `format:check` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
First layer of the Session Inspector: a pure per-session causal trace over the two ledgers that already record it. No UI, no I/O, no new store.
This is the PR that #1625 paused for. The RFC's headline — per-step latency and cost — was not derivable from
RuntimeEvent: one aggregate usage event per send, carrying aruntimeStepscount of how many steps it stood for. Rather than ship a view that was knowingly incomplete on exactly the turns worth inspecting, the metering source was fixed first (#1687, #1755, #1877).ModelCallAttemptnow records one canonical record per physical provider request —step,attempt,latencyMs,timeToFirstTokenMs, usage with ausageBasis, and a cost frozen at call time with the rates it used — so the projection can finally be written honestly.projectSessionTracejoins:RuntimeEventfor causal structure — tool dispatch paired with the result that answers it, permission decisions, errors.ModelCallAttemptfor metering — what each call cost and how long it took.Contract in
packages/core, builder inpackages/runtime, pure and synchronous with both ledgers handed in already read. The caller owns the I/O, which keeps@maka/storageout of@maka/runtimeand the projection testable against fixtures.Refs #1625. Builds on #1679.
Three properties that are deliberate
Retries are nested, not flattened. Attempts of one logical call share a
logicalCallIdby contract, so "this call was retried twice" is a grouping. A flat list of three steps that happen to share an id makes the reader reconstruct it.An absent price stays absent. A step whose attempts were never priced carries no
costUsd, and a session of only such calls totals to no price rather than to zero. That distinction is the one the canonical record exists to keep: unpriced and free are different facts.Coverage is stated, not implied. A backend outside canonical accounting produces a trace that says so. The pi backend is exactly this case —
recordModelCallAttempthas zero references inpi-agent-backend.ts, it emitstoken_usageonly — and an empty timeline is indistinguishable from a session that did nothing. Aggregate usage with no canonical record behind it is the signal, reported per session asabsentorpartialwith the turns named.Failure attribution points at what failed first, not at the terminal error. A turn that ends in an error usually ends there because of an earlier tool failure; naming the last event names the symptom.
One thing I'd like confirmed before it stays
TraceTotals.costUsdsums a session's attempts. @Astro-Han warned in the issue thread that a per-session cost would make the Inspector a second cost authority, and at the time that was right — Settings → Usage aggregated the telemetry index while the Inspector would have projected the RuntimeEvent ledger, two record paths over one pricing table.That inverted with #1755: Settings → Usage now aggregates these same
ModelCallAttemptrecords. Summing a session's attempts is reading one authority at a different scope, not creating a second — a disagreement between the two would be a bug in one of them rather than two defensible numbers. I've written it that way, but it's the claim I'm least willing to assert unilaterally. If you'd rather it wait, droppingcostUsdfromTraceTotalstouches one field and one test; everything else stands.Deliberately not here
No
searchTraceAPI — structured predicates are caller-sideArray.filteroverTraceStep[], per the issue discussion. No UI; the fifthSessionWorkbartab is PR 2. No pricing parameter; cost is read off the record as stored rather than recomputed from a current snapshot. Nothing touches Runtime Host ownership or the Desktop writer path.Two coverage gaps are reported rather than closed: the pi backend above, and
goal_evaluation, which still writes only the frozen table because it has no run or turn identity at the Host layer.Verification
@maka/core703/703,@maka/runtime2721/2733 — the 3 failing suites are this machine'srg-as-a-shell-function sandbox noise, identical on the parent commit.npm run format:checkandnode scripts/check-console.mjsclean.🤖 Generated with Claude Code