Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f7ce806
fix(serve): post-merge fixes for #4291 review (7 threads) (#4305)
doudouOUC May 19, 2026
066cab2
fix(serve): post-merge P2 corrections from Codex review on #4282 (#4297)
doudouOUC May 19, 2026
981bc7c
feat(acp-bridge): F1 — acp-bridge package self-sufficiency (#4175 mec…
doudouOUC May 19, 2026
dfa8ca4
feat(serve): F1 follow-up — BridgeFileSystem wiring + #4325 channelIn…
doudouOUC May 20, 2026
8eeb510
feat(acp-bridge): F3 — multi-client permission coordination (#4175) (…
doudouOUC May 20, 2026
a60c1c5
feat(serve+sdk): F4 prereq — daemon protocol completion (serverTimest…
doudouOUC May 21, 2026
46f8d48
feat(serve): shared MCP transport pool [F2] (#4336)
doudouOUC May 21, 2026
d0563ec
feat(daemon): add shared UI transcript layer (#4328)
chiga0 May 22, 2026
c6deb58
perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) …
doudouOUC May 23, 2026
57d0478
refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) …
doudouOUC May 23, 2026
0c04309
fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) …
doudouOUC May 23, 2026
cf5c245
feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353)
chiga0 May 24, 2026
a9d0c5f
chore(integration): sync main into daemon_mode_b_main (2026-05-24) (#…
doudouOUC May 24, 2026
63803de
docs(serve): v0.16-alpha known limits + SDK QWEN_SERVER_TOKEN env fal…
doudouOUC May 24, 2026
74c5d45
docs(deploy): local launch templates for v0.16-alpha (PR 30a) (#4483)
doudouOUC May 25, 2026
817f042
feat(daemon+sdk): cross-client real-time sync completeness (#4484)
chiga0 May 25, 2026
81b46c2
chore(integration): sync main into daemon_mode_b_main (2026-05-25) (#…
doudouOUC May 25, 2026
b2a3bef
fix(daemon): cross-client sync follow-up cleanup (D1/A3/D2/C3/D4 + ca…
May 25, 2026
a32d3b4
fix(daemon): address review on cross-client sync follow-up cleanup
May 26, 2026
ef20591
test(daemon): make the D6 guard test fail without the guard
May 26, 2026
11a32dc
test(daemon): use const+catch idiom in the D6 guard test
May 26, 2026
41070f8
fix(daemon): serialize approval-mode persist + publish inside the que…
May 26, 2026
00fbc01
test(daemon): cover the per-prompt cancel-broadcast latch reset (D2)
May 27, 2026
7bc6ff4
fix(bridge): address round-8 review findings
May 27, 2026
d45df46
Merge remote-tracking branch 'origin/daemon_mode_b_main' into fix/dae…
May 27, 2026
15082b8
fix(daemon): cross-client sync follow-up cleanup (D1/A3/D2/C3/D4 + ca…
May 25, 2026
863ff68
fix(daemon): address review on cross-client sync follow-up cleanup
May 26, 2026
b13e8b8
test(daemon): make the D6 guard test fail without the guard
May 26, 2026
9851193
test(daemon): use const+catch idiom in the D6 guard test
May 26, 2026
64e94fc
fix(daemon): serialize approval-mode persist + publish inside the que…
May 26, 2026
aa4c676
test(daemon): cover the per-prompt cancel-broadcast latch reset (D2)
May 27, 2026
43988bf
fix(bridge): address round-8 review findings
May 27, 2026
b07e53f
test(webui): cover clearAwaitingResync on same-session reconnect
May 27, 2026
74cf5fd
fix(daemon): address sync follow-up review comments
May 27, 2026
a6c9d0b
fix(webui): keep resync replay recovery live
May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
389 changes: 389 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts

Large diffs are not rendered by default.

279 changes: 200 additions & 79 deletions packages/acp-bridge/src/bridge.ts

Large diffs are not rendered by default.

92 changes: 80 additions & 12 deletions packages/acp-bridge/src/eventBus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ describe('EventBus', () => {
expect(events.map((e) => e.data)).toEqual([
'a',
'b',
expect.objectContaining({ lastEventId: 2, replayedCount: 2 }),
// D4: canonical `lastReplayedEventId` + deprecated `lastEventId` alias.
expect.objectContaining({
lastReplayedEventId: 2,
lastEventId: 2,
replayedCount: 2,
}),
'c',
]);
abort.abort();
Expand Down Expand Up @@ -619,9 +624,15 @@ describe('EventBus', () => {
abort.abort();
});

it('does NOT emit state_resync_required when ring is empty', async () => {
// No publishes yet → earliestInRing is undefined → resync check
// skipped. Subscriber waits for live events.
it('emits epoch_reset resync when lastEventId is past the bus high-water (D1)', async () => {
// doudouOUC #4484 post-merge review (D1): a fresh bus (nextId=1,
// empty ring) that receives a consumer presenting `lastEventId: 5`
// means the consumer's cursor is from a PREVIOUS bus epoch (daemon
// restart rebuilt the EventBus). Pre-fix this slid past the
// `ring_evicted` check (empty ring) and emitted a bare
// `replay_complete{replayedCount:0}` — a false "you're caught up"
// while the consumer's reducer still held dead-epoch state. Now it
// must emit `state_resync_required{reason:'epoch_reset'}` first.
const bus = new EventBus(10);
const abort = new AbortController();
const iter = bus.subscribe({
Expand All @@ -633,18 +644,75 @@ describe('EventBus', () => {
const out: BridgeEvent[] = [];
for await (const e of iter) {
out.push(e);
// The empty-ring case still emits `replay_complete` (zero
// frames replayed) so consumers always see the catch-up signal
// — then the one live event. 2 total.
// resync + replay_complete (0 frames) + 1 live = 3 total.
if (out.length === 3) break;
}
expect(out[0]?.type).toBe('state_resync_required');
expect(out[0]?.id).toBeUndefined();
const data = out[0]?.data as {
reason: string;
lastDeliveredId: number;
earliestAvailableId: number;
};
expect(data.reason).toBe('epoch_reset');
expect(data.lastDeliveredId).toBe(5);
expect(data.earliestAvailableId).toBe(1);
expect(out[1]?.type).toBe('replay_complete');
expect(out[1]?.data).toMatchObject({ replayedCount: 0 });
expect(out[2]?.type).toBe('foo');
expect(out[2]?.id).toBe(1);
abort.abort();
});

it('epoch_reset replays the WHOLE fresh ring (stale cursor must not filter new low ids)', async () => {
// After a restart the new epoch starts ids at 1 again. A consumer
// reconnecting with `lastEventId: 50` (dead epoch) must still receive
// the fresh ring's low-id events — filtering replay by 50 would drop
// ids 1..3 entirely, leaving the consumer permanently behind.
const bus = new EventBus(10);
for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i });
const abort = new AbortController();
const iter = bus.subscribe({
lastEventId: 50,
signal: abort.signal,
});
const out: BridgeEvent[] = [];
for await (const e of iter) {
out.push(e);
// resync + 3 replay frames + replay_complete = 5.
if (out.length === 5) break;
}
expect(out[0]?.type).toBe('state_resync_required');
expect((out[0]?.data as { reason: string }).reason).toBe('epoch_reset');
// All three fresh events replay despite ids < stale cursor.
expect(out.slice(1, 4).map((e) => e.id)).toEqual([1, 2, 3]);
expect(out[4]?.type).toBe('replay_complete');
expect(out[4]?.data).toMatchObject({ replayedCount: 3 });
abort.abort();
});

it('does NOT emit epoch_reset at the caught-up boundary (lastEventId === high-water)', async () => {
// Consumer fully caught up: lastEventId equals the bus high-water
// (nextId - 1). nextId is one past it, so `lastEventId >= nextId` is
// false — no epoch reset. Off-by-one guard for D1.
const bus = new EventBus(10);
for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i });
// high-water is 3; nextId is 4. lastEventId: 3 is the caught-up case.
const abort = new AbortController();
const iter = bus.subscribe({
lastEventId: 3,
signal: abort.signal,
});
setTimeout(() => bus.publish({ type: 'foo', data: 99 }), 0);
const out: BridgeEvent[] = [];
for await (const e of iter) {
out.push(e);
// replay_complete (0 frames) + 1 live = 2.
if (out.length === 2) break;
}
// No resync frame — but replay_complete (id-less sentinel) +
// the live event.
expect(out.some((e) => e.type === 'state_resync_required')).toBe(false);
expect(out[0]?.type).toBe('replay_complete');
expect(out[0]?.data).toMatchObject({ replayedCount: 0 });
expect(out[1]?.type).toBe('foo');
expect(out[1]?.id).toBe(1);
expect(out[1]?.id).toBe(4);
abort.abort();
});

Expand Down
60 changes: 51 additions & 9 deletions packages/acp-bridge/src/eventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,21 +386,54 @@ export class EventBus {
// loadSession clears the flag, but the frames stay on the
// wire so SDK has the option to compute a "what you missed"
// diff later. This is network-friendly (no extra reconnect).
const earliestInRing = this.ring[0]?.id;
if (
earliestInRing !== undefined &&
earliestInRing > opts.lastEventId + 1
) {
// Epoch-reset detection (doudouOUC #4484 post-merge review, D1).
// `this.nextId` is the next id this bus will assign, so the bus has
// only ever emitted ids `< nextId` THIS epoch. A consumer presenting
// `lastEventId >= nextId` therefore saw an id this epoch never
// produced — the only way that happens is a previous bus epoch
// (daemon restart / EventBus rebuild resets `nextId` to 1 and clears
// the ring). The `ring_evicted` check below is structurally blind to
// this: after a restart the ring is empty (`earliestInRing ===
// undefined`), so it is skipped and the consumer would otherwise get
// a bare `replay_complete{replayedCount:0}` — a false "you're caught
// up" while its accumulated reducer state is stale data from the dead
// epoch. Emit `state_resync_required` (reason `epoch_reset`) first.
const epochReset = opts.lastEventId >= this.nextId;
if (epochReset) {
queue.forcePush({
v: EVENT_SCHEMA_VERSION,
type: 'state_resync_required',
data: {
reason: 'ring_evicted',
reason: 'epoch_reset',
lastDeliveredId: opts.lastEventId,
earliestAvailableId: earliestInRing,
// Ring is typically empty right after a restart; fall back to
// `nextId` (the first id this epoch will assign) so the field
// stays meaningful ("fresh sequence starts here").
earliestAvailableId: this.ring[0]?.id ?? this.nextId,
Comment thread
chiga0 marked this conversation as resolved.
},
});
} else {
const earliestInRing = this.ring[0]?.id;
if (
earliestInRing !== undefined &&
earliestInRing > opts.lastEventId + 1
) {
queue.forcePush({
v: EVENT_SCHEMA_VERSION,
type: 'state_resync_required',
data: {
reason: 'ring_evicted',
lastDeliveredId: opts.lastEventId,
earliestAvailableId: earliestInRing,
},
});
}
}
// After an epoch reset the consumer's cursor belongs to a dead epoch,
// so every current-epoch event is "new" to it. Filtering replay by the
// stale `lastEventId` (e.g. 50) would drop the fresh low-id events
// (1,2,3…) entirely. Replay the whole current ring in that case.
const replayFrom = epochReset ? 0 : opts.lastEventId;
// Force-push replay frames so they bypass the per-subscriber size
// cap. The cap protects against a slow live consumer; replay is
// already historical and silently dropping it would undermine the
Expand All @@ -415,7 +448,7 @@ export class EventBus {
// undefined here — but the type system can't see that since
// BridgeEvent.id is optional for synthetic terminal frames.
// Guard explicitly to keep narrow typing without runtime cost.
if (e.id !== undefined && e.id > opts.lastEventId) {
if (e.id !== undefined && e.id > replayFrom) {
queue.forcePush(e);
replayedCount += 1;
lastReplayedId = e.id;
Expand Down Expand Up @@ -443,8 +476,17 @@ export class EventBus {
v: EVENT_SCHEMA_VERSION,
type: 'replay_complete',
data: {
// D4 (doudouOUC #4484 post-merge review): `lastReplayedEventId`
// is the canonical wire name — the old `lastEventId` collided
// semantically with the SSE protocol's `Last-Event-ID` (envelope
// `id`) in raw daemon traces. Emit both: `lastReplayedEventId`
// for current SDKs and `lastEventId` as a deprecated alias so
// pre-rename consumers keep working (additive, non-breaking).
...(lastReplayedId !== undefined
? { lastEventId: lastReplayedId }
? {
lastReplayedEventId: lastReplayedId,
lastEventId: lastReplayedId,
}
: {}),
replayedCount,
},
Expand Down
10 changes: 7 additions & 3 deletions packages/sdk-typescript/src/daemon/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,13 @@ export interface DaemonStreamErrorData {
*/
export interface DaemonStateResyncRequiredData {
/**
* Machine-readable resync reason. Currently always `'ring_evicted'`
* (the only case the daemon emits this frame for); reserved for
* future causes (e.g. `'schema_version_bump'`).
* Machine-readable resync reason. One of:
* - `'ring_evicted'`: consumer's `Last-Event-ID` fell behind the ring's
* earliest surviving id (same-epoch gap).
* - `'epoch_reset'`: consumer's `Last-Event-ID` is past the bus
* high-water — its cursor is from a previous bus epoch (daemon
* restart rebuilt the EventBus). The whole fresh ring is replayed.
* Reserved for future causes (e.g. `'schema_version_bump'`).
*/
reason: string;
/** Consumer's `Last-Event-ID` at reconnect time. */
Expand Down
23 changes: 20 additions & 3 deletions packages/sdk-typescript/src/daemon/ui/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,23 @@ export function normalizeDaemonEvent(
case 'state_resync_required':
return normalizeStateResyncRequired(event, base);

case 'prompt_cancelled':
return [{ ...base, type: 'prompt.cancelled' }];
case 'prompt_cancelled': {
// Forward the optional `reason` (e.g. `'forward_failed'` from the
// bridge's C3 compensating broadcast) so consumers can distinguish a
// user cancel from a forward failure.
const reason = stringField(event.data, 'reason');
return [
{ ...base, type: 'prompt.cancelled', ...(reason ? { reason } : {}) },
];
}

case 'replay_complete': {
const replayedCount = numberField(event.data, 'replayedCount') ?? 0;
const lastReplayedEventId = numberField(event.data, 'lastEventId');
// D4: prefer the canonical `lastReplayedEventId`; fall back to the
Comment thread
chiga0 marked this conversation as resolved.
// deprecated `lastEventId` alias for daemons predating the rename.
const lastReplayedEventId =
numberField(event.data, 'lastReplayedEventId') ??
numberField(event.data, 'lastEventId');
return [
{
...base,
Expand Down Expand Up @@ -1188,3 +1199,9 @@ function numberField(value: unknown, key: string): number | undefined {
const v = value[key];
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
}

function stringField(value: unknown, key: string): string | undefined {
if (!isRecord(value)) return undefined;
const v = value[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
8 changes: 8 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,14 @@ export interface DaemonUiStateResyncRequiredEvent extends DaemonUiEventBase {
*/
export interface DaemonUiPromptCancelledEvent extends DaemonUiEventBase {
type: 'prompt.cancelled';
/**
* Why the turn was cancelled. Absent for a user-initiated cancel;
* `'forward_failed'` when the daemon synthesized the cancel because the
* prompt forward rejected after the user echo was already published (the
* bridge's C3 compensating broadcast). Lets the UI distinguish "peer
* cancelled" from "the request failed to reach the agent".
*/
reason?: 'forward_failed' | (string & {});
}

/**
Expand Down
29 changes: 29 additions & 0 deletions packages/sdk-typescript/test/unit/daemonUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5043,6 +5043,21 @@ describe('cross-client event recognition (prompt_cancelled / replay_complete)',
originatorClientId: 'client-X',
}),
]);
// No reason for a plain user cancel.
expect(events[0]).not.toHaveProperty('reason');
});

it('forwards the prompt_cancelled reason (C3 forward_failed)', () => {
const events = normalizeDaemonEvent({
id: 1,
v: 1,
type: 'prompt_cancelled',
data: { sessionId: 's1', reason: 'forward_failed' },
} as never);
expect(events[0]).toMatchObject({
type: 'prompt.cancelled',
reason: 'forward_failed',
});
});

it('normalizes replay_complete to session.replay_complete with count', () => {
Expand All @@ -5059,6 +5074,20 @@ describe('cross-client event recognition (prompt_cancelled / replay_complete)',
});
});

it('prefers canonical lastReplayedEventId over the deprecated lastEventId alias (D4)', () => {
const events = normalizeDaemonEvent({
id: 1,
v: 1,
type: 'replay_complete',
// Both present, different values — canonical must win.
data: { replayedCount: 2, lastReplayedEventId: 9, lastEventId: 7 },
} as never);
expect(events[0]).toMatchObject({
type: 'session.replay_complete',
lastReplayedEventId: 9,
});
});

it('replay_complete with zero replay (empty ring) normalizes cleanly', () => {
const events = normalizeDaemonEvent({
id: 1,
Expand Down
Loading