Skip to content

fix(agent-runtime,thread-view): stop timeline 500s from session-scoped ACP fs-write ids - #1224

Merged
SawyerHood merged 2 commits into
get-bb:mainfrom
vburojevic:fix-acp-fs-write-ids
Aug 9, 2026
Merged

SawyerHood merged 2 commits into
get-bb:mainfrom
vburojevic:fix-acp-fs-write-ids

Conversation

@vburojevic

Copy link
Copy Markdown
Contributor

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.

@agustif agustif left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

@agustif agustif left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@SawyerHood

Copy link
Copy Markdown
Collaborator

🚨 SLOP COP 🚨 · review

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[] = [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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) ??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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 SawyerHood left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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.

  1. High: whole-item timeline closure ignores the turn scope. Legacy IDs can exceed the event budget and make older rows disappear.
  2. Medium: the host-daemon wire behavior changed without a protocol update. Enrolled daemons will continue to emit legacy IDs.
  3. 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.

SawyerHood added a commit to vburojevic/bb that referenced this pull request Aug 9, 2026
…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>
SawyerHood added a commit to vburojevic/bb that referenced this pull request Aug 9, 2026
…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>
@SawyerHood
SawyerHood force-pushed the fix-acp-fs-write-ids branch from 5e1dcd1 to b96fbdf Compare August 9, 2026 17:51
vburojevic and others added 2 commits August 9, 2026 17:52
…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>
@SawyerHood
SawyerHood force-pushed the fix-acp-fs-write-ids branch from b96fbdf to 087df27 Compare August 9, 2026 17:54
@SawyerHood

Copy link
Copy Markdown
Collaborator

Sorry about that @vburojevic I just asked the review bot to fix and test my b!

@SawyerHood
SawyerHood merged commit f60cf84 into get-bb:main Aug 9, 2026
9 checks passed
SawyerHood pushed a commit that referenced this pull request Aug 11, 2026
…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>
amadad pushed a commit to amadad/bb that referenced this pull request Aug 14, 2026
…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>
ymichael added a commit that referenced this pull request Aug 15, 2026
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>
ymichael added a commit that referenced this pull request Aug 17, 2026
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>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
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>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
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>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…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>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
)

## 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>
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.

3 participants