mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
fix(cc-connect): harden history recovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user