fix(agent-runtime,thread-view): stop timeline 500s from session-scoped ACP fs-write ids - #1224
Conversation
agustif
left a comment
There was a problem hiding this comment.
Note: earlier empty/test APPROVE was accidental from a scout dry-run of the gh CLI APIs — please ignore. Not a real review of this PR.
|
🚨 SLOP COP 🚨 · I am SlopCop. I am reviewing this pull request for security, code quality, performance, tests, and architecture. I will post the combined findings after the parallel checks finish. |
| // restart their synthetic id counters). Merge only rows from a compatible | ||
| // scope and leave foreign-scope rows untouched, so each scope keeps its own | ||
| // file-edit message instead of failing the whole projection. | ||
| const compatibleRows: EventProjectionFileEditMessage[] = []; |
There was a problem hiding this comment.
🚨 slopcop/review — High: scope whole-item windowing by turn
This fallback handles reused IDs only after the server selects a timeline window. The server still groups item spans and lifecycle rows by raw itemId. A legacy ID reused across turns becomes one thread-wide item.
With 39 events and a budget of 10, the latest page backfilled both turns. The next older page returned neither file row. Larger histories can exceed the 1,500-event budget and restore the expensive projection path.
Please use (scopeKind, turnId, itemId) through whole-item closure and its database queries. Add a server test with the reused ID on opposite sides of the event cut.
| id: `acp-fs-write-${state.fsWriteCounter}`, | ||
| // Include the turn id: resumed sessions restart the counter, so a | ||
| // bare counter would reuse ids already persisted in earlier turns. | ||
| id: `acp-fs-write-${turnId}-${state.fsWriteCounter}`, |
There was a problem hiding this comment.
🚨 slopcop/review — Medium: update the daemon protocol for this wire change
This changes an event item ID that the host daemon sends to the server. HOST_DAEMON_PROTOCOL_VERSION remains 86, so enrolled daemons will not update. They will continue to emit the legacy IDs that this PR tries to stop.
Please increment the protocol version and update its contract expectation.
| } | ||
| const foreignMessageIds = new Set(foreignRows.map((row) => row.id)); | ||
| const stdoutBuffer = | ||
| state.fileEditStdoutBuffersByCallId.get(partial.callId) ?? |
There was a problem hiding this comment.
🚨 slopcop/review — Medium: scope the file-edit output buffer too
The new row logic separates reused call IDs by turn, but this buffer still uses only callId. Two pending file-edit output streams with the same ID therefore share text across turns. The flush path then writes that combined text into every row for the ID.
A focused reproduction made both rows contain one-outputtwo-output. Please key buffers by the scoped call identity. Add an output-delta regression test that keeps each turn’s output separate.
SawyerHood
left a comment
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
ELI5: The app gave two file changes the same name. This patch separates them. Some shelves still use the old name, so files can mix or disappear.
I found three actionable problems.
- High: whole-item timeline closure ignores the turn scope. Legacy IDs can exceed the event budget and make older rows disappear.
- Medium: the host-daemon wire behavior changed without a protocol update. Enrolled daemons will continue to emit legacy IDs.
- Medium: file-edit output buffers still use only the call ID. Reused IDs can combine output from different turns.
The security review found no issue. The architecture review recommends one scoped item identity for closure queries, projection maps, and output buffers.
The focused suite passed all 123 tests. Both affected package type checks passed. The complete thread-view suite passed all 349 tests.
The agent-runtime suite passed 838 of 839 tests. The unrelated stderr-tail test failed twice, while GitHub package checks passed.
A browser test was not practical for this historical ACP session path. An in-memory server test used the real database and timeline service.
The GPT-5.6 review gate confirmed all three findings. I posted this review as comment-only, as required.
…ndows Address the three SlopCop findings on get-bb#1224. Whole-item window closure keyed items by raw item_id, so a reused id looked like one item spanning every turn between its two uses. The newest page then backfilled the oldest turn's lifecycle rows, and every older page disowned the item, so the earlier file changes vanished. The closure and its three queries now key on (scope_kind, turn_id, item_id). The file-edit stdout buffer was also keyed by call id alone, so two pending output streams sharing a reused id merged their text into every row for that id. Buffers are now keyed by scoped call identity, and the flush path resolves each row's buffer from that row's own scope. Bump HOST_DAEMON_PROTOCOL_VERSION to 87: the ACP adapter now sends turn-qualified fileChange item ids, and an enrolled daemon on an older build keeps emitting the colliding session-scoped counters. Tests: a server timeline test with one file-change item id reused across turns on both sides of an event-budget cut, and a thread-view test that keeps each turn's file-change output separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndows Address the three SlopCop findings on get-bb#1224. Whole-item window closure keyed items by raw item_id, so a reused id looked like one item spanning every turn between its two uses. The newest page then backfilled the oldest turn's lifecycle rows, and every older page disowned the item, so the earlier file changes vanished. The closure and its three queries now key on (scope_kind, turn_id, item_id). The file-edit stdout buffer was also keyed by call id alone, so two pending output streams sharing a reused id merged their text into every row for that id. Buffers are now keyed by scoped call identity, and the flush path resolves each row's buffer from that row's own scope. Bump HOST_DAEMON_PROTOCOL_VERSION to 88: the ACP adapter now sends turn-qualified fileChange item ids, and an enrolled daemon on an older build keeps emitting the colliding session-scoped counters. Version 87 already shipped on main for the moved-thread session handoff, so this needs its own bump to force those daemons to update. Tests: a server timeline test with one file-change item id reused across turns on both sides of an event-budget cut, and a thread-view test that keeps each turn's file-change output separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5e1dcd1 to
b96fbdf
Compare
…d ACP fs-write ids The ACP adapter minted fileChange item ids from a per-session counter (acp-fs-write-N). Resumed ACP sessions (e.g. acp-kimi after a restart) restart the counter, so a later turn reuses an item id already persisted in an earlier turn. The timeline projection merges file-edit rows by call id and threw 'Cannot merge file-edit messages with different scopes', failing the whole timeline request and taking down the thread's chat with a 500. Two layers: - Adapter: mint acp-fs-write-<turnId>-<counter>. Turn ids carry a per-adapter-instance random prefix, so ids are unique across sessions. - Projection: upsertFileEdit partitions existing rows by scope, merges only compatible-scope rows, preserves foreign-scope rows as their own messages, and scope-qualifies message keys on collision. Any reused call id (including rows already persisted by older builds) now degrades to separate file cards instead of failing the projection. Tests: cross-session fs-write id uniqueness in the ACP adapter, and a timeline regression test covering two turns that reuse the same fileChange item id.
…ndows Address the three SlopCop findings on get-bb#1224. Whole-item window closure keyed items by raw item_id, so a reused id looked like one item spanning every turn between its two uses. The newest page then backfilled the oldest turn's lifecycle rows, and every older page disowned the item, so the earlier file changes vanished. The closure and its three queries now key on (scope_kind, turn_id, item_id). The file-edit stdout buffer was also keyed by call id alone, so two pending output streams sharing a reused id merged their text into every row for that id. Buffers are now keyed by scoped call identity, and the flush path resolves each row's buffer from that row's own scope. Bump HOST_DAEMON_PROTOCOL_VERSION to 89: the ACP adapter now sends turn-qualified fileChange item ids, and an enrolled daemon on an older build keeps emitting the colliding session-scoped counters. Tests: a server timeline test with one file-change item id reused across turns on both sides of an event-budget cut, and a thread-view test that keeps each turn's file-change output separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b96fbdf to
087df27
Compare
|
Sorry about that @vburojevic I just asked the review bot to fix and test my b! |
…thread (#1321) # Problem Every thread on the host freezes at "waiting" and only a full app restart clears it. This has now happened **four times on bb-app 0.36.0**, each time triggered by a single event the server could never store. The daemon holds **one in-memory event queue for the whole host** and reposts it as a single batch. When the head of that queue is an event the server deterministically refuses, the batch can never succeed — so every other thread's `turn/started`, `item/*` and `turn/completed` events pile up behind it and never reach the database. The UI reads the database, so every thread looks stuck. ### From the logs `~/.bb/logs/server.3.log` — the first rejection, then the same one repeating verbatim: ```json {"level":40,"time":1786360836499,"eventType":"provider/unhandled","scopeKind":"turn", "threadId":"thr_fpx3vkax5h","turnId":"auto-compact-1", "errorMessage":"Cannot append provider/unhandled for turn auto-compact-1 before turn/started is stored", "errorName":"MissingStoredTurnStartedError","msg":"Rejected daemon event before turn/started"} {"level":40,"time":1786360836616,"eventType":"provider/unhandled","scopeKind":"turn", "threadId":"thr_fpx3vkax5h","turnId":"auto-compact-1", ... } ``` Every occurrence, grouped by the turn that poisoned the queue: | Thread | Turn | Rejections | Window | |---|---|---:|---| | `thr_dwmzmanhn5` | `auto-compact-2` | 1911 | 08-07 14:57:19 → 15:27:06 (29.8 min) | | `thr_fpx3vkax5h` | `auto-compact-1` | 505 | 08-10 13:20:36 → 13:25:57 (5.3 min) | | `thr_sdc5dy277m` | `auto-compact-3` | 171 | 08-10 13:48:47 → 13:52:43 (3.9 min) | | `thr_qifimqh4a6` | `auto-compact-1` | 260 | 08-10 15:08:46 → 15:14:00 (5.2 min) | Every window ends at a restart, never at a recovery. During the 13:20 window the server logged **no thread activity whatsoever** — only the rejections: ``` 1 [plugin:connect] rpc listAccountServers failed: not_paired 5 Skipping malformed prompt history row 1 [plugin:agent-limits] disposed <- the restart ``` The 15:08 occurrence is visible directly in the database. Rows inserted per minute across all threads, spanning that window: ``` 15:03 | 90 events | 4 threads 15:04 | 91 | 1 15:05 | 66 | 1 15:06 | 22 | 1 15:07 | 32 | 1 15:08 | 35 | 2 <- poison event lands at 15:08:46 15:09 | 0 | 0 15:10 | 0 | 0 15:11 | 1 | 1 15:12 | 0 | 0 15:13 | 4 | 2 15:14 | 29 | 4 <- restart at 15:14:00 15:15 | 48 | 3 ``` Five minutes in which the whole machine persisted essentially nothing, then instant recovery on restart. Those events are gone: the queue is in-memory, so the restart that clears the wedge also discards everything held behind it, leaving a hole in each affected thread's transcript. # Root cause 1. **A provider-minted turn id is trusted.** `createUnhandledProviderEvent` falls back to reading `turnId` out of the raw provider event when the caller does not supply one: ```ts const turnId = args.turnId ?? getTurnIdFromRawEvent(args.rawEvent); ``` Codex labels its automatic-compaction traffic `auto-compact-N`. The string `auto-compact` appears nowhere in bb's source — it is entirely provider-minted, and every `provider/unhandled` event on all four affected threads carries `providerId: "codex"`. bb never opened that turn, so it never emitted a `turn/started` for it. Critically, every caller supplies `turnId` from bb's own turn registry and omits it *only when bb has no active turn* — precisely the case where a scraped id is guaranteed wrong. 2. **The server hard-rejects the orphan.** `resolveDaemonTurnStartDisposition` finds no stored `turn/started`; the escape hatch `ORPHAN_DROPPABLE_TURN_EVENT_TYPES` held only the two usage-snapshot types, so it throws `MissingStoredTurnStartedError`. 3. **The whole batch dies with it.** `/session/events` appends every event in one `immediate` transaction, so the throw rolls all of them back and returns `409 invalid_request`. 4. **The daemon reposts it forever.** The drain loop takes the entire queue as one batch and splices only on success: ```ts try { response = await options.postEvents(batch) } catch (error) { logger.error(..., "Failed to post daemon events; will retry on the next flush"); return; // queue untouched } queue.splice(0, batch.length); // only reached on success ``` The daemon already knows this class of error is permanent — `defaultRetryableForStatus(409)` is `false`, and `ServerResponseError.retryable` carries that verdict — but nothing consults it. # Fix **1. `apps/host-daemon/src/event-sink.ts` — never repost a batch the server permanently refused.** On a non-retryable `invalid_request`, the sink bisects the batch, drops the events that are undeliverable by construction, and delivers the rest. Since the server appends in one transaction and rolls back entirely on refusal, nothing was committed and re-posting the halves cannot duplicate. Isolating k bad events costs O(k log n) posts. The `invalid_request` code check is what keeps this narrow: `/session/events` also fails non-retryably with `401 unauthorized` and `401 inactive_session`, and those say nothing about the events themselves. Those must stay queued for the session the daemon is about to reopen, not be discarded one at a time — there is a regression test for exactly this. **2. `packages/agent-runtime/src/shared/provider-unhandled-event.ts` — stop trusting provider turn ids.** Only a turn id the caller vouched for scopes the event; the raw-event fallback is gone. **3. `packages/db/src/data/events.ts` — `provider/unhandled` becomes orphan-droppable.** A backstop, in the spirit of the existing comment about fork usage snapshots. An unhandled passthrough event is diagnostic only: losing one is a non-event, failing the batch it rode in with is not. Turn-content events still require a stored `turn/started`, so genuine ordering bugs are still caught. **4. The queue-backup tripwire logs at `warn`, not `debug`.** It never once fired in any of the four incidents, so there was no signal short of noticing the UI had stopped moving. Fix 1 is the load-bearing one. Fixes 2 and 3 close this particular trigger; only fix 1 stops the *next* unknown orphan event from wedging the host. ## Note on ordering of fixes 1 and 3 Fix 3 alone repairs already-enrolled daemons: an old daemon talking to a new server stops receiving 409s, so the wedge cannot recur even before it updates. Fix 1 is what makes the daemon resilient to the next unknown case. # Protocol version Bumped `HOST_DAEMON_PROTOCOL_VERSION` 99 → 100, matching the convention used by #1224, #1208, #1232, #1314 and #1236 for daemon-behaviour changes. Nothing in the wire *schema* changed, and both directions are compatible (old daemon + new server is in fact the repair path above) — the bump is here to push fix 1 out to enrolled machines rather than leave them on a build that wedges. Happy to drop it if you would rather not force an update cycle for this. # Tests Written as reproductions first, and confirmed failing against the base commit before the fix: | Test | Package | Reproduces | |---|---|---| | `ignores a provider-supplied turn id the caller did not vouch for` | `@bb/agent-runtime` | root cause — `auto-compact-1` scraped from raw params | | `drops orphan provider/unhandled events instead of failing the batch` | `@bb/db` | the batch-wide rollback | | `drops a permanently rejected event instead of retrying it forever` | `@bb/host-daemon` | the infinite repost | | `delivers events queued behind a permanently rejected event` | `@bb/host-daemon` | **the wedge itself** — healthy traffic from other threads gets through | | `accepts a batch carrying a provider/unhandled event for a turn bb never started` | `@bb/server` | end-to-end at the `/internal/session/events` route that produced the 409 | Plus guards against over-correcting: - `keeps events queued when the session, not the batch, is rejected` — a 401 must not bisect the queue away. - `keeps retrying a batch that fails for a retryable reason` — 5xx behaviour unchanged. One existing expectation changed: `codex/adapter.test.ts > translateEvent unknown codex notifications fall back to provider/unhandled` now expects thread scope. That path handles notifications which *failed* schema parsing, so nothing there vouches for the turn id; Codex notifications bb does parse still carry turn scope. Comment in the test explains it. ## Verification Rebased onto `d07c1ce28` and re-verified there. `pnpm exec turbo run test` on `@bb/db`, `@bb/agent-runtime`, `@bb/host-daemon`, `@bb/host-daemon-contract`, `@bb/server`, `@bb/integration-tests`: ``` @bb/host-daemon-contract 49 passed (49) @bb/host-daemon 526 passed (526) @bb/db 378 passed (378) @bb/server 1405 passed (1405) @bb/integration-tests 55 passed (55) @bb/agent-runtime 894 passed | 1 failed (895) ``` `typecheck` and `lint` clean across all of them. The single `@bb/agent-runtime` failure — `runtime.process-lifecycle.test.ts > bounds provider stderr while data arrives without a newline` — **fails identically on unmodified `origin/main`** and is unrelated to this change. --- Fixes #1320 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d ACP fs-write ids (get-bb#1224) ## Problem Threads on ACP providers (reproduced with acp-kimi) intermittently fail `GET /api/v1/threads/:id/timeline` with a 500, taking down the whole chat view (`Cannot merge file-edit messages with different scopes`). Root cause: the ACP adapter minted `fileChange` item ids from a per-session counter (`acp-fs-write-N`, `packages/agent-runtime/src/acp/adapter.ts`). Resumed ACP sessions restart the counter, so a later turn reuses an item id already persisted in an earlier turn. The timeline projection merges file-edit rows by call id (`upsertFileEdit` in `packages/thread-view/src/operation-projection.ts`) and threw on the cross-turn scope mismatch, 500ing every timeline request for the thread from then on. Every resumed session that wrote files created a fresh collision, so affected threads kept re-breaking. ## Fix (two layers) - **Adapter:** mint `acp-fs-write-<turnId>-<counter>`. Turn ids carry a per-adapter-instance random prefix, so ids are unique across sessions for good. - **Projection:** `upsertFileEdit` now partitions existing rows by scope — it merges only compatible-scope rows, preserves foreign-scope rows as their own messages, and scope-qualifies message keys on collision. Any reused call id (including rows already persisted by older builds) degrades to separate file cards instead of failing the projection. The strict throw in `updateFileEditMessage` stays as an invariant guard, now unreachable from `upsertFileEdit`. ## Tests - `acp/adapter.test.ts`: two adapter instances (simulating a resumed session) mint distinct fs-write ids. - `build-thread-timeline.test.ts`: two turns reusing the same `fileChange` item id produce two distinct file-change rows, each keeping its own diff. Verified: full `@bb/agent-runtime` and `@bb/thread-view` vitest suites green, `tsc --noEmit` clean in both packages. Also repaired the affected production threads in a live `bb.db` with this recipe (kept earliest turn, suffixed later-turn duplicates in `item_id` and `data->'$.item.id'`); all previously-500ing threads load again. ## Release note After merge, cut 0.36.1 per the usual flow: `node scripts/bump-version.mjs --patch` → "Prepare bb-app 0.36.1" PR → dispatch `publish-bb-app.yml` with `npm_tag=latest`, `dry_run=false`. --------- Co-authored-by: Sawyer Hood <sawyerjhood@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replays one scripted claude session — start, a turn with a delta-first assistant message, a Bash tool_use/tool_result pair and a thinking block, a mid-turn steer, a second turn, a resume, a post-resume turn, release stop — through the same bridge module twice, once per dialect, and diffs the normalized ThreadEvent streams. One scripted SDK query drives both legs, so provider output is byte-identical. Result: full parity. The only stream differences are the legacy thread/identity (canonical returns identity in the thread/start response) and four synthesized item/started events for the delta-first assistant and reasoning items. The tool pair, finalized reasoning content, token usage and every turn settlement match byte for byte, including across the resume, where the canonical session mints a fresh id prefix (#1224). turn/input/accepted is compared as a set rather than in-stream: it is emitted by a different actor on each path (runtime vs bridge), so its interleaving with provider output is not a comparable protocol property. The canonical bridge acks all four accepted inputs; the legacy path acked only the steer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replays one scripted claude session — start, a turn with a delta-first assistant message, a Bash tool_use/tool_result pair and a thinking block, a mid-turn steer, a second turn, a resume, a post-resume turn, release stop — through the same bridge module twice, once per dialect, and diffs the normalized ThreadEvent streams. One scripted SDK query drives both legs, so provider output is byte-identical. Result: full parity. The only stream differences are the legacy thread/identity (canonical returns identity in the thread/start response) and four synthesized item/started events for the delta-first assistant and reasoning items. The tool pair, finalized reasoning content, token usage and every turn settlement match byte for byte, including across the resume, where the canonical session mints a fresh id prefix (#1224). turn/input/accepted is compared as a set rather than in-stream: it is emitted by a different actor on each path (runtime vs bridge), so its interleaving with provider output is not a comparable protocol property. The canonical bridge acks all four accepted inputs; the legacy path acked only the steer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/scripted-echo-provider is the echo example bridge plus the scripted directives the suites drive (delay, approvals, user questions, tool calls) and the session/process behaviour they script (archived sessions, failing commands, crashes, slow starts) through providerOptions.scripted or the SCRIPTED_ECHO_OPTIONS env JSON; SCRIPTED_ECHO_RECORD_PATH records every request it handles. It passes the canonical conformance suite. The integration harness no longer has an adapterFactory seam: the fake providers are declarations backed by the scripted echo artifact, built exactly as the plugin runtime builds a real provider plugin, and the daemon runs it through the bridge-protocol adapter and delta assembler. The runtime-config smoke test reads the bridge's request record instead of intercepting adapter commands in-process; it and the fan-out tests create threads on the fake ids, since a first-party id now launches that provider's real bridge. The continuity test asserts distinct bb-minted message ids across a daemon restart (#1224) instead of the legacy fake's reused msg-1. The fake ProviderAdapter and its script are unused by the integration suites from here; the runtime unit suites move off them in the deletion layer. Co-Authored-By: Claude <noreply@anthropic.com>
tests/scripted-echo-provider is the echo example bridge plus the scripted directives the suites drive (delay, approvals, user questions, tool calls) and the session/process behaviour they script (archived sessions, failing commands, crashes, slow starts) through providerOptions.scripted or the SCRIPTED_ECHO_OPTIONS env JSON; SCRIPTED_ECHO_RECORD_PATH records every request it handles. It passes the canonical conformance suite. The integration harness no longer has an adapterFactory seam: the fake providers are declarations backed by the scripted echo artifact, built exactly as the plugin runtime builds a real provider plugin, and the daemon runs it through the bridge-protocol adapter and delta assembler. The runtime-config smoke test reads the bridge's request record instead of intercepting adapter commands in-process; it and the fan-out tests create threads on the fake ids, since a first-party id now launches that provider's real bridge. The continuity test asserts distinct bb-minted message ids across a daemon restart (#1224) instead of the legacy fake's reused msg-1. The fake ProviderAdapter and its script are unused by the integration suites from here; the runtime unit suites move off them in the deletion layer. Co-Authored-By: Claude <noreply@anthropic.com>
…2136) Stacked on #2124 (`bb/provider-contract`). WS1a of the provider-plugin migration: the generic assembler, one streaming + one usage dialect, extension ingest validation, the published testing kit, the scripted echo bridge as the harness default, and — last commit — the deletion of the legacy `ProviderAdapter` path and the v2 delta dialects. **Do not merge.** Coordinator reviews, Sawyer merges the stack. ## What was wrong The contract PR landed the grammar v3 vocabulary but left the assembler stubbed (`UnsupportedDeltaShapeError` in every v3 shape and in `extension.state`), kept two streaming and two usage dialects, validated no extension payload, published no testing kit, and the runtime still carried a legacy `ProviderAdapter` interface whose only non-bridge implementation was a 674-line fake adapter driven by a legacy-dialect script (the integration harness's default provider). ## What changed (one commit per layer; the last deletes) 1. **Assembler builds the v3 core kinds** (`92ae9cd`) — `fileRead`, `search`, `planSteps` open pending and settle from the terminal shape like `command`; a foreground `delegation` settles through `item/completed`, a `background: true` delegation is thread-attached like a background task (`item/delegation/progress|completed`, no turn needed, survives turn settlement and `session.ended`). `ASSEMBLER_GRAMMAR_VERSIONS` → `[2, 3]`. 2. Presentation persistence shipped upstream in #2124 (`fc88906`); nothing to do here. 3. **Extension kinds + ingest validation** (`f174c5d`) — `extension` items and the new `thread/extensionState/updated` event assemble. The server validates every extension payload against the owning plugin's declared Standard Schema at ingest (`apps/server/src/internal/extension-payloads.ts`; registrations carry the validators, the registry resolves `"<pluginId>/<name>"` through the plugin-id prefix; 64 KiB cap). An undeclared kind, a schema miss, a validator error, or an oversized payload is persisted as `provider/unhandled` in the same batch slot — G11-visible, never dropped, never stored unvalidated. `extensionKindSchema` parses to the `ExtensionKind` type. 4. **One streaming dialect, one usage dialect** (`cce9415`) — every text stream is an item keyed like any other: `item.textDelta`/`item.textClose`, anonymous streams keyed by `key.channel` (+ `parentRef`). `usage { total, last, modelContextWindow }` is forwarded verbatim; bridges that report per turn (claude, pi) accumulate with the bridge kit's `addTokenUsage` and reset at `session.reset`; codex sends `contextWindow` (now with `providerTurnId`) beside it. All four bridges + the echo example migrated. **Calibration goldens unchanged** for codex, claude, acp, pi. 5. **`provider/recovery`** (`e5f5a3b`) — decoded by the adapter, forwarded to the runtime's new `onProviderRecovery` hook (the daemon logs it; WS4 acts per kind). Grammar negotiation shipped upstream (`0816b4c`). 6. **Published testing kit** (`03815e3`) — `@get-bb/plugin-sdk/provider-bridge/testing`: conformance kit, the real assembler, delta→event collector, JSON-RPC harness, calibration normalizer. Framework-agnostic (`captureBridgeJsonRpcOutput` patches `process.stdout.write`, no `vi`). `experimental_` value names + `docs/api_to_audit.md` entry; G10 doc-sync test asserts the entry. The assembler moved into `@bb/provider-bridge-protocol` (`assembler` subpath) — the SDK cannot depend on the runtime (cycle). The echo example and every first-party bridge suite import only `@get-bb/plugin-sdk/provider-bridge` + the testing entry; the echo example's `@bb/*` devDependencies are gone. **Scripted echo bridge as the harness default** (`3d3cb9e`) — `tests/scripted-echo-provider` (the echo bridge + scripted directives: `delay:`, `approve:`, `ask_user`, `call_tool:`, `hold_turn`, `fail_turn:`, …; session/process behaviour via `providerOptions.scripted` / `SCRIPTED_ECHO_OPTIONS`; `SCRIPTED_ECHO_RECORD_PATH` records every request, `SCRIPTED_ECHO_PROCESS_LOG_PATH` every process step). Passes the conformance suite. The integration harness has no `adapterFactory` seam: the fake providers are declarations backed by the built scripted artifact, run by the daemon through the real adapter. 7. **Deletion** (`a9a3950`) — `ProviderAdapter` → concrete `BridgeProtocolAdapter`; `adapterFactory` / `createAgentRuntimeWithAdapters`; the fake adapter + script; `message.delta/close`, `usage.turn/exact` from schema + assembler; assembler `[3, 3]` and every bridge reports it (a bridge that predates `grammarVersions` reads as v2 and is refused at the handshake; the conformance handshake scenario checks the same). Runtime unit suites + the daemon's thread.stop race suite run the scripted echo bridge through the real bootstrap + adapter + assembler; the process manager gains a `createAdapter` seam for raw-script spawn/stderr/exit tests. `HOST_DAEMON_PROTOCOL_VERSION` → 148. ### Tests deleted (subject no longer exists) - command-contract: "rejects required adapter commands that return no-op plans", "rejects no-op steer commands", the noop half of "rejects no-op stop commands" (only the fake adapter's `buildCommandPlan` seam could plan a noop; handshake gating is pinned in `bridge-protocol-adapter.test.ts`). - lifecycle: "passes Codex-shaped thread/start ids to accepted command translation" (`translateAcceptedCommand` is a no-op for bridges), "preserves merged shell env when reconfiguring a thread" (the session-rebuild path is unreachable: bridges classify every settings change as `live`; env is covered by the start/resume tests), "drops a delta into an item nothing opened, with a visible warning" (the only seam that could feed a malformed event was the `translateEvent` override; the grammar gate is exercised by the new replayed-turn bridge test). - input-accepted: "suppresses provider-emitted user message echoes" (bridges never emit `userMessage`). - multi-thread: "maps thread/started before identity", "drops unscoped provider events" (legacy `thread/event` dialect routing; every `thread/delta` names its bb thread id). - process-lifecycle: "continues startup when an optional post-initialize read is unsupported" (the only post-initialize request is the handshake itself). Everything else is ported faithfully; literal-id assertions became assertions on the assembler-minted ids (#1224), `AdapterCommand` recordings became request-record assertions on the same wire facts. ## Regression oracle status - **Parity replay (A2)**: `bb/provider-recordings` does not exist on origin; not in the base. Coordinator requires it before merge. - **Corpus row snapshots (A4, #2121)**: not in the base. - **G1 ratchet (#2120)**: not in the base. This PR adds no provider-id literal to core (the scripted bridge's codex-shaped archived error is test-only). - **Conformance kit**: green for echo, scripted echo, codex, claude-code, acp, pi. - **Calibration goldens**: byte-identical for codex, claude, acp, pi through the dialect migration. ### Intended byte-level difference (allowlist) - **WS1a #2136, layer 4**: a provider-named text item that streamed before `session.ended` now settles with its accumulated text instead of its opened (empty) shape. Reason: one streaming dialect means the assembler owns the stream text for named items too; losing streamed text on interrupt was the v2 behaviour, not a feature. ### Wire discipline `HOST_DAEMON_PROTOCOL_VERSION` 147 → 148: the daemon emits a new event type (`thread/extensionState/updated`) and its bridges speak grammar v3 only, which a 147 daemon would refuse at the handshake. `PROVIDER_BRIDGE_PROTOCOL_VERSION` stays 2 (envelope and methods unchanged; the grammar range gates). ## Gates (all `--concurrency 4`) Typecheck — green, 27 tasks: `@bb/domain @bb/provider-bridge-protocol @bb/agent-runtime @bb/server @bb/host-daemon @bb/host-daemon-contract @get-bb/plugin-sdk @bb/thread-view @bb/db` and `provider-codex provider-claude-code provider-acp provider-pi echo-provider scripted-echo-provider @bb/integration-tests @bb/app @bb/mobile @bb/cli`. Tests: | package | result | |---|---| | @bb/domain | 148/148 | | @bb/provider-bridge-protocol (incl. the assembler suite) | 213/213 | | @bb/host-daemon-contract | 52/52 | | @get-bb/plugin-sdk | 127/127 | | @bb/thread-view | 379/379 | | @bb/db | 406/406 | | @bb/agent-runtime (incl. pi conformance) | 336/336 | | bb-plugin-provider-codex | 172/172 | | bb-plugin-provider-claude-code | 263/263 | | bb-plugin-provider-acp | 181/181 | | bb-plugin-echo-provider / scripted-echo-provider | 2/2, 1/1 | | @bb/host-daemon | 556/556 | | @bb/integration-tests (fake stack on the scripted bridge) | 55/55 | | @bb/server | 1821/1823 — two pre-existing, environmental locals: `internal-skill-trees` (umask 0664 vs 0644, passes in CI) and `plugin-update` "waits one full interval" (5 s timeout under load; test and subject untouched by this PR) | Perf (assembler micro-benchmark, 20 000 mixed turns = 1 340 000 deltas → 1 380 000 events, 3 runs each, same workload): contract head (v2 dialect) min 457 ms / 2.93 M deltas/s; this branch (v3 dialect) min 436 ms / 3.07 M deltas/s — ~4 % faster, heap delta no worse. Within the +10 % gate. > AGENT GENERATED: by Claude Opus 5 --------- Co-authored-by: Claude <noreply@anthropic.com>
) ## What was wrong A tool call can outlive the turn that spawned it. Its `item/completed` then arrives scoped to the next turn (the claude-code translator emits it as a generic `toolCall` once the turn boundary cleared its call map). The timeline projection merges that lifecycle by bare call id into the spawning turn's row (`upsertRunningExecCall`, #447), so the turn row spans the late completion and its inline children show the call completed with its output. The turn-summary details route re-projects the row's sequence window but `filterExactEventRowsForRequestedTurn` drops every turn-scoped row whose turn id differs from the requested turn (#164's overlapping-turn fix). The completion is gone, `ensureSequenceWindowWholeItemRows` cannot restore it (it keys items by scoped identity and only backfills below the window), and the call renders `pending` forever (or `interrupted` / "Tool execution interrupted" on an idle thread). The response is a 200, so nothing logs. Issue: #1714. Report: https://get-bb.github.io/reports/issues/1714.html ## What changed `apps/server/src/services/threads/timeline.ts`, `filterExactEventRowsForRequestedTurn`: the filter tracks tool calls the requested turn `item/started` (kinds the projection keys by bare call id: `commandExecution`, `toolCall`, `webSearch`, `webFetch`, `imageView`) that have not completed yet, and keeps another turn's `item/*` rows for those ids. The id leaves the set at its `item/completed`, so a later turn that reuses the id for a new item (a resumed ACP session restarting its counter, #1224/#1398) stays out of the spawning turn. File edits (scope-partitioned in the projection), buffered text (keyed per turn), and background tasks (own thread-scoped state rows) do not establish cross-turn ownership, matching the projection. The re-admitted completion is the last surviving row, so `resolveTurnSummaryDetailsSourceRange` still yields the requested range and the exact-bounds match holds; the issue's "missing-match 500" trap does not bite. Server-only. No wire change, no `HOST_DAEMON_PROTOCOL_VERSION` bump, no CLI or doc surface. Known sibling, out of scope: when the *later* turn has its own work and therefore its own summary row, that turn's details window contains the orphan completion and re-projects it as an extra completed "unknown" tool row that its inline children do not have. That is pre-existing and unchanged here; fixing it needs a bare-id lifecycle lookup before the window. ## How you verified Added to `apps/server/test/services/threads/timeline-in-turn-window.test.ts` (`describe("turn details for an item that finishes in a later turn")`): - "shows the spawning turn's item completed with its late output" asserts the turn-1 details rows deep-equal the same row's inline `children`. Fails on origin/main: ``` AssertionError: expected [ { …(19) } ] to deeply equal [ { …(19) } ] - "completedAt": 1787299091836, + "completedAt": null, - "output": "dev server exited with code 0", + "output": "", - "sourceSeqEnd": 6, + "sourceSeqEnd": 2, - "status": "completed", + "status": "pending", ``` Passes with the fix. - "keeps a later turn's reuse of the call id out of the spawning turn" guards the ownership release: turn 1 completes `call-1`, turn 2 starts and completes its own `call-1`; both turns' details equal their inline children (passes before and after). Commands (from the committed tree, `git status --porcelain` empty): - `pnpm exec turbo run typecheck --filter=@bb/server` — `Tasks: 4 successful, 4 total` - `pnpm exec turbo run test --filter=@bb/server` — `Tests 1 failed | 1824 passed (1825)`; the one failure is `test/internal/internal-skill-trees.test.ts` expecting file mode 0644 and getting 0664 on this machine's umask 0002. It fails identically on clean main here and passes in CI; unrelated to this change. Manual: seeded the report's eight-event shape into my own dev instance, set the thread idle, and hit both routes. `GET /timeline?includeNestedRows=true` and `GET /timeline/turn-summary-details?turnId=turn-1&sourceSeqStart=1&sourceSeqEnd=6` now return the same row: `status: "completed"`, `output: "dev server exited with code 0"`, `sourceSeqEnd: 6`. In the app, expanding "Worked for" shows "Ran npm run dev" with the real output instead of "interrupted" / "Tool execution interrupted". Fixes #1714 > AGENT GENERATED: by Claude Opus 5 ## Independent verification Checked out `726d071fc` (this branch) on top of `origin/main` (`git merge-base --is-ancestor origin/main HEAD` holds; `mergeable: MERGEABLE`). Diff: `apps/server/src/services/threads/timeline.ts` (+44/-2) and `apps/server/test/services/threads/timeline-in-turn-window.test.ts` (+171). Server-only; no wire, CLI, plugin-API, or UI surface touched, so no `HOST_DAEMON_PROTOCOL_VERSION` bump is needed. Root cause confirmed independently against `origin/main` before reading the PR description: `filterExactEventRowsForRequestedTurn` drops every other-turn turn-scoped row, while `upsertRunningExecCall` / `onExecEnd` in `packages/thread-view` merge exec lifecycles by bare call id into the spawning turn, and `ensureSequenceWindowWholeItemRows` keys by scoped identity and only backfills below the window. The PR changes the filter, which is the right layer. I also confirmed the projection does not clear `runningCallsById` at `turn/completed` (only at `item/completed` or final interruption), so the PR's "release at `item/completed`" rule matches the projection more closely than the report's prototype (which released on a later turn's `item/started`). Fail-before / pass-after: - `git checkout origin/main -- apps/server/src/services/threads/timeline.ts`, then `pnpm exec vitest run --root apps/server test/services/threads/timeline-in-turn-window.test.ts -t "finishes in a later turn"` -> `1 failed | 1 passed`: ``` AssertionError: expected [ { …(19) } ] to deeply equal [ { …(19) } ] - "completedAt": 1787299535570, + "completedAt": null, - "output": "dev server exited with code 0", + "output": "", - "sourceSeqEnd": 6, + "sourceSeqEnd": 2, - "status": "completed", + "status": "pending", ``` - `git checkout HEAD -- apps/server/src/services/threads/timeline.ts`, then the whole file -> `Tests 24 passed (24)`. Extra edge cases (ad-hoc test over in-memory SQLite, not committed): (1) turn 1 starts `call-1` and never completes it, turn 2 starts and completes its own `call-1`; (2) turn 2 carries an `item/commandExecution/outputDelta` plus the completion for turn 1's call; (3) turn 1 starts a `webFetch`, turn 2 carries the degraded `toolCall` completion for its id. All three: details rows deep-equal the inline children on this branch; (1) and (2) diverge on `origin/main`. Package checks from the committed tree (`git status --porcelain` empty): `pnpm exec turbo run typecheck --filter=@bb/server` -> `Tasks: 4 successful, 4 total`. `pnpm exec turbo run test --filter=@bb/server --force` -> `Tests 1 failed | 1824 passed (1825)`; the one failure is `test/internal/internal-skill-trees.test.ts` (mode 420 vs 436), the known umask-0002 local-only failure that also fails on clean main here and passes in CI. Repro on the fixed branch: started my own dev instance (`scripts/bb-dev-app current`, server :21529), seeded the report's eight-event shape into its `bb.db` via `@bb/db`, set the thread `idle`, then compared `GET /api/v1/threads/<id>/timeline?includeNestedRows=true` (turn-1 row `[1,6]`, child `completed`, output `dev server exited with code 0`, `sourceSeqEnd: 6`) with `GET /api/v1/threads/<id>/timeline/turn-summary-details?turnId=turn-1&sourceSeqStart=1&sourceSeqEnd=6` -> HTTP 200, same row id, `status: "completed"`, `output: "dev server exited with code 0"`, `completedAt` set, `sourceSeqEnd: 6`. The report's wrong answer (`interrupted` / `Tool execution interrupted`, `sourceSeqEnd: 2`) no longer reproduces. CI: all required checks green (Checks, Tests server/packages/integration/app-1..3, Package Smoke ubuntu+macos, version check). Residual risks: the `CROSS_TURN_TOOL_ITEM_KINDS` doc comment says the projection tracks web activity and image views by bare id across turns; in fact `mergeWebActivityMessage` throws on a cross-scope merge (pre-existing, untouched here), so those kinds only matter when the late completion arrives as a degraded `toolCall`, which the allowlist still handles correctly. The later turn's own details (when it has its own summary row) still re-project the orphan completion as an extra "unknown" tool row, as the PR body notes; pre-existing and unchanged. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude <noreply@anthropic.com>
Problem
Threads on ACP providers (reproduced with acp-kimi) intermittently fail
GET /api/v1/threads/:id/timelinewith a 500, taking down the whole chat view (Cannot merge file-edit messages with different scopes).Root cause: the ACP adapter minted
fileChangeitem ids from a per-session counter (acp-fs-write-N,packages/agent-runtime/src/acp/adapter.ts). Resumed ACP sessions restart the counter, so a later turn reuses an item id already persisted in an earlier turn. The timeline projection merges file-edit rows by call id (upsertFileEditinpackages/thread-view/src/operation-projection.ts) and threw on the cross-turn scope mismatch, 500ing every timeline request for the thread from then on. Every resumed session that wrote files created a fresh collision, so affected threads kept re-breaking.Fix (two layers)
acp-fs-write-<turnId>-<counter>. Turn ids carry a per-adapter-instance random prefix, so ids are unique across sessions for good.upsertFileEditnow partitions existing rows by scope — it merges only compatible-scope rows, preserves foreign-scope rows as their own messages, and scope-qualifies message keys on collision. Any reused call id (including rows already persisted by older builds) degrades to separate file cards instead of failing the projection. The strict throw inupdateFileEditMessagestays as an invariant guard, now unreachable fromupsertFileEdit.Tests
acp/adapter.test.ts: two adapter instances (simulating a resumed session) mint distinct fs-write ids.build-thread-timeline.test.ts: two turns reusing the samefileChangeitem id produce two distinct file-change rows, each keeping its own diff.Verified: full
@bb/agent-runtimeand@bb/thread-viewvitest suites green,tsc --noEmitclean in both packages. Also repaired the affected production threads in a livebb.dbwith this recipe (kept earliest turn, suffixed later-turn duplicates initem_idanddata->'$.item.id'); all previously-500ing threads load again.Release note
After merge, cut 0.36.1 per the usual flow:
node scripts/bump-version.mjs --patch→ "Prepare bb-app 0.36.1" PR → dispatchpublish-bb-app.ymlwithnpm_tag=latest,dry_run=false.