diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index 6a66833fbcd6..f57c60684d8b 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -714,6 +714,45 @@ describe("createCopilotAgentHarness", () => { expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-reuse"); }); + it("reuses a replacement session while an older cleanup is pending", async () => { + const cleanup = createDeferred<"aborted" | "completed" | "deadline">(); + let attempt = 0; + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + attempt += 1; + if (attempt === 1) { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-old", + pooledClient: { key: {} as any, client: {} as any }, + sessionConfig: TEST_SESSION_CONFIG, + }); + deps.onDeferredCompaction?.({ + abort: () => undefined, + cleanup: cleanup.promise, + sdkSessionId: "sdk-sess-old", + }); + } else if (attempt === 2) { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-replacement", + pooledClient: { key: {} as any, client: {} as any }, + sessionConfig: TEST_SESSION_CONFIG, + }); + } + return ATTEMPT_RESULT; + }); + const harness = createCopilotAgentHarness({ pool: makePoolMock() }); + + await harness.runAttempt(makeAttemptParams({ runId: "t1" })); + await harness.runAttempt(makeAttemptParams({ runId: "t2" })); + await harness.runAttempt(makeAttemptParams({ runId: "t3" })); + + const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(thirdCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-replacement"); + cleanup.resolve("completed"); + await flushAsyncWork(); + }); + it("invalidates the retained SDK binding when deferred compaction is cancelled", async () => { const pool = makePoolMock(); const sessionStore = makeSessionStoreMock(); diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 4c6c2632ef56..1542d5aba3fb 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -95,6 +95,10 @@ type LegacyCopilotSessionBinding = { type CopilotAttemptSessionBinding = Pick; type DeferredCompactionCleanupOutcome = "aborted" | "completed" | "deadline"; +type DeferredCompactionCleanup = { + abort: () => void; + sdkSessionId: string; +}; type CopilotSessionBindingStore = Pick< PluginStateSyncKeyedStore, @@ -427,7 +431,7 @@ export function createCopilotAgentHarness( const inFlight = new Set>(); const deferredCompactionCleanups = new Map< string, - Map, () => void> + Map, DeferredCompactionCleanup> >(); // Maps OpenClaw session id (from AgentHarnessAttemptParams.sessionId) to // the SDK session id + client that owns it. Populated by @@ -454,11 +458,12 @@ export function createCopilotAgentHarness( abort: () => void; cleanup: Promise; sessionId: string; + sdkSessionId: string; }): void { const cleanups = deferredCompactionCleanups.get(params.sessionId) ?? - new Map, () => void>(); - cleanups.set(params.cleanup, params.abort); + new Map, DeferredCompactionCleanup>(); + cleanups.set(params.cleanup, { abort: params.abort, sdkSessionId: params.sdkSessionId }); deferredCompactionCleanups.set(params.sessionId, cleanups); void params.cleanup.then( () => removeDeferredCompactionCleanup(params.sessionId, params.cleanup), @@ -480,14 +485,28 @@ export function createCopilotAgentHarness( } } + function hasPendingDeferredCompactionCleanup(sessionId: string): boolean { + const cleanups = deferredCompactionCleanups.get(sessionId); + if (!cleanups) { + return false; + } + const currentSdkSessionId = + trackedSessions.get(sessionId)?.sdkSessionId ?? + lookupStoredBinding(options?.sessionStore, sessionId)?.sdkSessionId; + return ( + currentSdkSessionId !== undefined && + [...cleanups.values()].some((cleanup) => cleanup.sdkSessionId === currentSdkSessionId) + ); + } + async function abortDeferredCompactionCleanups(sessionId: string): Promise { const cleanups = deferredCompactionCleanups.get(sessionId); if (!cleanups) { return; } const pending = [...cleanups.entries()]; - for (const [, abort] of pending) { - abort(); + for (const [, cleanup] of pending) { + cleanup.abort(); } await Promise.allSettled(pending.map(([cleanup]) => cleanup)); } @@ -553,7 +572,7 @@ export function createCopilotAgentHarness( const currentCompatKey = computeSessionCompatKey(params); const currentCompactKey = computeSessionCompactKey(params); const compactionCleanupPending = - openclawSessionId !== undefined && deferredCompactionCleanups.has(openclawSessionId); + openclawSessionId !== undefined && hasPendingDeferredCompactionCleanup(openclawSessionId); const tracked = openclawSessionId && !compactionCleanupPending ? trackedSessions.get(openclawSessionId) @@ -635,6 +654,7 @@ export function createCopilotAgentHarness( abort, cleanup, sessionId: openclawSessionId, + sdkSessionId, }); // The attempt retains this SDK session until its background // compaction resolves. Preserve its binding for a successful @@ -730,7 +750,7 @@ export function createCopilotAgentHarness( reason: "missing-required-params", }; } - if (deferredCompactionCleanups.has(openclawSessionId)) { + if (hasPendingDeferredCompactionCleanup(openclawSessionId)) { return { ok: false, compacted: false,