fix(cc-connect): harden history recovery

This commit is contained in:
ashione
2026-07-27 16:26:49 +08:00
parent a378c31261
commit 92f966424f
4 changed files with 107 additions and 40 deletions
+11 -6
View File
@@ -527,7 +527,7 @@ export class CcConnectBridgeAdapter {
this.shouldReconnect = false;
this.clearHeartbeat();
this.clearReconnectTimer();
await this.failPendingRuns('cc-connect runtime stopped before the run completed');
this.failPendingRuns('cc-connect runtime stopped before the run completed');
const sockets = new Set([
...this.connectingSockets,
...(this.socket ? [this.socket] : []),
@@ -841,13 +841,14 @@ export class CcConnectBridgeAdapter {
}
return;
}
this.handleServerMessage(parsed);
this.handleServerMessage(parsed, socket);
});
socket.once('error', (error) => finish(error));
socket.once('close', () => {
if (this.socket === socket) {
this.socket = null;
this.clearHeartbeat();
this.failPendingRuns('cc-connect bridge disconnected before the run completed');
this.scheduleReconnect();
}
finish(new Error('cc-connect bridge connection closed'));
@@ -904,7 +905,8 @@ export class CcConnectBridgeAdapter {
}
}
private handleServerMessage(message: Record<string, unknown>): void {
private handleServerMessage(message: Record<string, unknown>, sourceSocket: WebSocket): void {
if (sourceSocket !== this.socket) return;
const progressItems = parseBridgeProgressItems(message.content);
if (progressItems) {
logger.debug(
@@ -1587,7 +1589,7 @@ export class CcConnectBridgeAdapter {
this.terminalCardRuns.delete(pending.runId);
}
private async failPendingRuns(error: string): Promise<void> {
private failPendingRuns(error: string): void {
const failedAt = Date.now();
for (const pending of Array.from(this.pendingRuns.values())) {
if (this.pendingRuns.get(pending.runId) !== pending) continue;
@@ -1595,7 +1597,7 @@ export class CcConnectBridgeAdapter {
sessionKey: pending.sessionKey,
abortedAt: failedAt,
});
await this.finishPendingRun(pending, {
void this.finishPendingRun(pending, {
text: error,
isError: true,
appendMessage: false,
@@ -1641,7 +1643,10 @@ export class CcConnectBridgeAdapter {
pending.sessionKey === sessionKey || toCcConnectBridgeSessionKey(pending.sessionKey) === bridgeSessionKey
));
if (hasPendingForSession) return false;
return Array.from(this.abortedRuns.values()).some((aborted) => aborted.sessionKey === sessionKey);
return Array.from(this.abortedRuns.values()).some((aborted) => (
aborted.sessionKey === sessionKey
|| toCcConnectBridgeSessionKey(aborted.sessionKey) === bridgeSessionKey
));
}
private pruneAbortedRuns(): void {
+35 -21
View File
@@ -14,7 +14,10 @@ type CachedTranscriptFile = {
turnMetadata?: {
sessionTimestamp?: number;
sessionWorkDir?: string;
userMessages: string[];
userTurns: Array<{
content: string;
timestamp?: number;
}>;
};
toolMessages?: RawMessage[];
};
@@ -166,7 +169,7 @@ function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedT
if (file.turnMetadata) return file.turnMetadata;
let sessionTimestamp: number | undefined;
let sessionWorkDir: string | undefined;
const userMessages: string[] = [];
const userTurns: NonNullable<CachedTranscriptFile['turnMetadata']>['userTurns'] = [];
for (const line of file.jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
@@ -185,29 +188,37 @@ function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedT
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
const timestamp = parseTimestamp(record.timestamp) ?? sessionTimestamp;
for (const item of payload.content) {
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
userMessages.push(item.text.trim());
userTurns.push({
content: item.text.trim(),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userMessages };
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userTurns };
return file.turnMetadata;
}
function transcriptMatchesWorkDir(file: CachedTranscriptFile, expectedWorkDir?: string): boolean {
if (!expectedWorkDir) return true;
const { sessionWorkDir } = transcriptTurnMetadata(file);
return sessionWorkDir !== undefined && resolve(sessionWorkDir) === resolve(expectedWorkDir);
}
function transcriptMatchesTurn(
file: CachedTranscriptFile,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): boolean {
const { sessionTimestamp, sessionWorkDir, userMessages } = transcriptTurnMetadata(file);
if (sessionTimestamp === undefined || userMessages.length === 0) return false;
if (expectedWorkDir && (!sessionWorkDir || resolve(sessionWorkDir) !== resolve(expectedWorkDir))) {
return false;
}
return hints.some((hint) => (
Math.abs(sessionTimestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& userMessages.includes(hint.content.trim())
));
const { userTurns } = transcriptTurnMetadata(file);
if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false;
return hints.some((hint) => userTurns.some((turn) => (
turn.timestamp !== undefined
&& Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& turn.content === hint.content.trim()
)));
}
async function findTurnTranscriptFiles(
@@ -374,21 +385,24 @@ export async function loadCcConnectCodexTranscriptTools(
? [codexHomeDirs]
: Array.from(codexHomeDirs);
const transcriptPaths = new Set<string>();
const cachedSessionPath = hasValidAgentSessionId
? transcriptPathBySessionId.get(agentSessionId)
: undefined;
if (cachedSessionPath) transcriptPaths.add(cachedSessionPath);
for (const codexHomeDir of new Set(homes.filter(Boolean))) {
if (hasValidAgentSessionId && !transcriptPathBySessionId.has(agentSessionId)) {
const transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
if (hasValidAgentSessionId) {
const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`;
let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey);
if (!transcriptPath) {
transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
}
if (transcriptPath) {
setBoundedCache(
transcriptPathBySessionId,
agentSessionId,
sessionPathCacheKey,
transcriptPath,
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
);
transcriptPaths.add(transcriptPath);
const file = await readTranscriptFile(transcriptPath);
if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir)) {
transcriptPaths.add(transcriptPath);
}
}
}
for (const turnTranscriptPath of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
+56 -8
View File
@@ -118,8 +118,9 @@ describe('cc-connect BridgePlatform adapter', () => {
});
});
let adapter: CcConnectBridgeAdapter | undefined;
try {
const adapter = new CcConnectBridgeAdapter({
adapter = new CcConnectBridgeAdapter({
port,
token: 'token',
project: 'clawx-main',
@@ -132,11 +133,12 @@ describe('cc-connect BridgePlatform adapter', () => {
message: 'ping',
idempotencyKey: 'idem-1',
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
await expect(adapter.send({
const channelRun = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'channel ping',
idempotencyKey: 'idem-2',
})).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
});
expect(channelRun).toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) }));
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
@@ -154,6 +156,12 @@ describe('cc-connect BridgePlatform adapter', () => {
sessionKey: 'agent:research:desk',
status: 'completed',
})],
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: channelRun.runId,
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'completed',
})],
]));
});
@@ -185,6 +193,7 @@ describe('cc-connect BridgePlatform adapter', () => {
]);
await adapter.close();
} finally {
await adapter?.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -932,6 +941,7 @@ describe('cc-connect BridgePlatform adapter', () => {
});
const sockets: WebSocket[] = [];
const received: Record<string, unknown>[] = [];
const emitted: Array<[string, unknown]> = [];
server.on('connection', (socket) => {
sockets.push(socket);
@@ -940,6 +950,14 @@ describe('cc-connect BridgePlatform adapter', () => {
received.push(parsed);
if (parsed.type === 'register') {
socket.send(JSON.stringify({ type: 'register_ack', ok: true }));
return;
}
if (parsed.type === 'message' && sockets.indexOf(socket) === 1) {
socket.send(JSON.stringify({
type: 'reply',
session_key: parsed.session_key,
content: 'reply after dropped connection',
}));
}
});
});
@@ -948,7 +966,7 @@ describe('cc-connect BridgePlatform adapter', () => {
port,
token: 'token',
project: 'clawx-main',
emit: vi.fn(),
emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never,
heartbeatIntervalMs: 20,
reconnectDelayMs: 20,
});
@@ -961,10 +979,40 @@ describe('cc-connect BridgePlatform adapter', () => {
]));
});
const droppedRun = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'drop this run',
idempotencyKey: 'idem-before-drop',
});
sockets[0]?.close();
await vi.waitFor(() => {
expect(sockets).toHaveLength(2);
expect(adapter.isConnected()).toBe(true);
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: droppedRun.runId,
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'error',
error: 'cc-connect bridge disconnected before the run completed',
})],
]));
});
const replacementRun = await adapter.send({
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'answer after reconnect',
idempotencyKey: 'idem-after-drop',
});
await vi.waitFor(() => {
expect(emitted).toEqual(expect.arrayContaining([
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: replacementRun.runId,
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'completed',
})],
]));
});
await adapter.close();
@@ -1017,7 +1065,7 @@ describe('cc-connect BridgePlatform adapter', () => {
try {
const first = await adapter.send({
sessionKey: 'agent:main:first',
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'never answered',
idempotencyKey: 'idem-before-close',
});
@@ -1027,14 +1075,14 @@ describe('cc-connect BridgePlatform adapter', () => {
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: first.runId,
sessionKey: 'agent:main:first',
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'error',
error: 'cc-connect runtime stopped before the run completed',
})],
]));
const second = await adapter.send({
sessionKey: 'agent:main:second',
sessionKey: 'agent:coder:feishu:chat-1:user-1',
message: 'answer this',
idempotencyKey: 'idem-after-close',
});
@@ -1043,7 +1091,7 @@ describe('cc-connect BridgePlatform adapter', () => {
['chat:runtime-event', expect.objectContaining({
type: 'run.ended',
runId: second.runId,
sessionKey: 'agent:main:second',
sessionKey: 'agent:coder:feishu:chat-1:user-1',
status: 'completed',
})],
]));
@@ -2609,7 +2609,7 @@ describe('CcConnectRuntimeProvider', () => {
});
});
it('scopes transcript turn matching by workspace and parses direct Web Search and MCP tools', async () => {
it('rejects stale cross-workspace session IDs and matches later Web Search and MCP turns', async () => {
const transcriptDir = join(
tempDir,
'runtimes',
@@ -2623,11 +2623,11 @@ describe('CcConnectRuntimeProvider', () => {
await mkdir(transcriptDir, { recursive: true });
const timestamp = Date.parse('2026-07-27T06:30:10.000Z');
const sessionMeta = (id: string, cwd: string) => JSON.stringify({
timestamp: '2026-07-27T06:30:10.000Z',
timestamp: '2026-07-27T06:00:10.000Z',
type: 'session_meta',
payload: {
id,
timestamp: '2026-07-27T06:30:10.000Z',
timestamp: '2026-07-27T06:00:10.000Z',
cwd,
},
});
@@ -2665,7 +2665,7 @@ describe('CcConnectRuntimeProvider', () => {
},
}),
].join('\n'), 'utf8');
await writeFile(join(transcriptDir, 'rollout-main.jsonl'), [
await writeFile(join(transcriptDir, 'rollout-main-session.jsonl'), [
sessionMeta('main-session', '/workspace/main'),
userMessage,
JSON.stringify({
@@ -2683,7 +2683,7 @@ describe('CcConnectRuntimeProvider', () => {
const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript');
const messages = await loadCcConnectCodexTranscriptTools(
join(tempDir, 'runtimes', 'cc-connect', 'codex-home'),
'',
'main-session',
[{ content: '查一下状态', timestamp }],
'/workspace/coder',
);