Skip to content
Merged
46 changes: 46 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type { ChannelFactory } from './channel.js';
import type { BridgeTelemetry } from './bridgeOptions.js';
import { createInMemoryChannel } from './inMemoryChannel.js';
import { EventBus, type BridgeEvent } from './eventBus.js';
import { TurnBoundaryCompactionEngine } from './compactionEngine.js';
import {
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
Expand Down Expand Up @@ -2343,6 +2344,7 @@ describe('createAcpSessionBridge', () => {
compactedReplay: [],
liveJournal: [],
lastEventId: 0,
eventEpoch: expect.any(String),
});
expect(handles[0]?.agent.loadSessionCalls).toEqual([
{
Expand All @@ -2364,6 +2366,46 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('surfaces replayDegraded on loadSession when compaction fails', async () => {
const handle = makeChannel({
promptImpl: async (p) => {
await handle.agentConnection.sessionUpdate({
sessionId: p.sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello' },
},
});
return { stopReason: 'end_turn' };
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const spy = vi
.spyOn(TurnBoundaryCompactionEngine.prototype, 'ingest')
.mockImplementation(() => {
throw new Error('compaction test failure');
});

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});

spy.mockRestore();

const loaded = await bridge.loadSession({
sessionId: session.sessionId,
workspaceCwd: WS_A,
});
expect(loaded.replayDegraded).toBe(true);

await bridge.shutdown();
});

it('restores artifact snapshots that omit marker arrays after fork remap', async () => {
const sessionId = 'persisted-artifacts';
const artifactUrl = 'https://example.com/restored';
Expand Down Expand Up @@ -3707,6 +3749,7 @@ describe('createAcpSessionBridge', () => {
hasActivePrompt: false,
state: { modes: null },
lastEventId: 0,
eventEpoch: expect.any(String),
});
expect(handles[0]?.agent.loadSessionCalls).toHaveLength(0);
expect(handles[0]?.agent.resumeSessionCalls).toEqual([
Expand Down Expand Up @@ -3752,6 +3795,7 @@ describe('createAcpSessionBridge', () => {
hasActivePrompt: false,
state: { _meta: { tag: 'restored-foo' } },
lastEventId: expect.any(Number),
eventEpoch: expect.any(String),
});
expect(attached.clientId).not.toBe(loaded.clientId);
expect(handles[0]?.agent.loadSessionCalls).toHaveLength(1);
Expand Down Expand Up @@ -5268,6 +5312,8 @@ describe('createAcpSessionBridge', () => {
promptId: 'cont-1',
});
expect(typeof decision.lastEventId).toBe('number');
// Epoch token pairs with the cursor (DAEMON-001), same as the 202 envelope.
expect(decision.eventEpoch).toEqual(expect.any(String));

// The continuation runs through the tracked prompt path (fire-and-forget),
// so the agent receives a prompt() carrying the re-armed continue meta.
Expand Down
45 changes: 40 additions & 5 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3540,7 +3540,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
};

const createSessionEventBus = (): EventBus =>
const createSessionEventBus = (sessionId: string): EventBus =>
new EventBus(
eventRingSize,
undefined,
Expand All @@ -3552,6 +3552,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},
}),
{
// Fired once, on the FIRST ingest/seed failure (the bus keeps the
// degraded flag set silently afterwards). The bus doesn't know its
// session, so the sessionId context is injected here.
onCompactionError: (err) => {
writeStderrLine(
`qwen serve: compaction degraded for session=${sessionId}; replay snapshot may lag behind live events: ${
err instanceof Error ? err.message : String(err)
}`,
);
},
},
);

// §2.3 publish helpers — centralise cache + generation + bus publish so
Expand Down Expand Up @@ -3706,7 +3718,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
ci: ChannelInfo,
sessionId: string,
workspaceCwd: string,
events = createSessionEventBus(),
events = createSessionEventBus(sessionId),
options: {
drainEarlyEvents?: boolean;
lifecycleReason?: string;
Expand Down Expand Up @@ -3961,6 +3973,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
| 'compactedReplay'
| 'liveJournal'
| 'lastEventId'
| 'eventEpoch'
| 'replayDegraded'
| 'partial'
| 'replayError'
| 'historyHasMore'
Expand All @@ -3974,22 +3988,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
: {}),
}
: {};
// Clients seed their reconnect cursor from `lastEventId`; the epoch
// token must travel with it so a daemon restart between this response
// and the first subscribe is detected (stale cursor + dead epoch).
const eventEpoch = entry.events.epoch;
const snapshot = entry.events.snapshotReplay();
if (!snapshot) {
return { lastEventId: entry.events.lastEventId, ...replayStatus };
return {
lastEventId: entry.events.lastEventId,
eventEpoch,
...replayStatus,
};
}
if (action === 'load') {
return {
compactedReplay: snapshot.compactedTurns,
liveJournal: snapshot.liveJournal,
lastEventId: snapshot.lastEventId,
eventEpoch,
...replayStatus,
...(snapshot.degraded ? { replayDegraded: true } : {}),
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
...(entry.restoreHistoryHasMore === true
? { historyHasMore: true }
: {}),
};
}
return { lastEventId: snapshot.lastEventId, ...replayStatus };
return { lastEventId: snapshot.lastEventId, eventEpoch, ...replayStatus };
};

const restoredArtifactSnapshotFromState = (
Expand Down Expand Up @@ -4239,7 +4263,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
throw new SessionLimitExceededError(maxSessions);
}

const restoreEvents = createSessionEventBus();
const restoreEvents = createSessionEventBus(req.sessionId);
let registeredEntry: SessionEntry | undefined;
let ci: ChannelInfo | undefined;
// Live counter shared with coalesced waiters (see InFlightRestore
Expand Down Expand Up @@ -5739,6 +5763,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return entry.events.lastEventId;
},

getSessionEventEpoch(sessionId) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
return entry.events.epoch;
},

getSessionReplaySnapshot(sessionId) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down Expand Up @@ -6682,6 +6712,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const liveEntry = byId.get(sessionId);
if (!liveEntry) throw new SessionNotFoundError(sessionId);
const lastEventId = liveEntry.events.lastEventId;
// Epoch token paired with the cursor above, mirroring the prompt 202
// envelope (DAEMON-001): without it a client that seeds its SSE resume
// position from this response cannot detect a daemon restart.
const eventEpoch = liveEntry.events.epoch;
const promptId = context?.promptId;

// Admit synchronously: `sendPrompt` throws synchronously for queue-full /
Expand Down Expand Up @@ -6717,6 +6751,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
...decision,
...(promptId !== undefined ? { promptId } : {}),
lastEventId,
eventEpoch,
};
},

Expand Down
27 changes: 27 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ export interface BridgeRestoredSession extends BridgeSession {
historyHasMore?: boolean;
/** High-water mark event ID — client uses this as initial SSE cursor. */
lastEventId?: number;
/**
* Epoch token of the session's event bus. Clients echo it (with
* `lastEventId`) on SSE subscribe so a daemon restart between this
* response and the subscribe is detected deterministically instead of
* via the numeric heuristic.
*/
eventEpoch?: string;
/**
* True when the compaction engine failed at some point, so
* `compactedReplay`/`liveJournal` may silently miss events. Clients
* should prefer the full transcript over this replay.
*/
replayDegraded?: boolean;
}

export interface BridgeSessionTranscriptPageRequest {
Expand Down Expand Up @@ -869,6 +882,13 @@ export interface AcpSessionBridge {
*/
getSessionLastEventId(sessionId: string): number;

/**
* Return the epoch token of this session's event bus. Regenerated on
* every bus construction (daemon restart), never persisted. Throws
* `SessionNotFoundError` when the id is unknown.
*/
getSessionEventEpoch(sessionId: string): string;

/**
* Return the current compacted replay snapshot for a loaded session, when
* the bridge has a compaction engine configured.
Expand Down Expand Up @@ -1151,6 +1171,13 @@ export interface AcpSessionBridge {
*/
promptId?: string;
lastEventId?: number;
/**
* Epoch token of the event bus that produced `lastEventId`, mirroring
* the `POST /session/:id/prompt` 202 envelope: a client seeding its SSE
* resume position from an accepted continuation must also learn the bus
* epoch so a daemon restart in between is detected (DAEMON-001).
*/
eventEpoch?: string;
}>;

/** Read structured session usage stats (tokens, tools, files). */
Expand Down
Loading
Loading