From a8b4ffe8a46c44ecdb83b387b0186cb8e690459e Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:00:10 +0800 Subject: [PATCH 1/6] fix: keep stop available during context compaction Model: gpt-5.6-luna --- packages/acp-extension-codex | 2 +- .../src/components/sessions/session-chat-interface.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 5f0aab0f6..0e2a6d8df 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 5f0aab0f6614ef50dc57c2b137cdc0d4b864359e +Subproject commit 0e2a6d8df022549383ed108bf1cc398627974ddd diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index e6629465c..d1038b986 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3494,7 +3494,9 @@ export const SessionChatInterface = memo( isGoalActive, }); const canStopAgent = - (isSessionActive && activeAssistantTurnId != null) || (isGoalActive && canPauseGoal); + isContextCompacting || + (isSessionActive && activeAssistantTurnId != null) || + (isGoalActive && canPauseGoal); const latestCompletedProposedPlan = useMemo( () => findLatestCompletedCodexProposedPlan(sessionDoc?.history), [sessionDoc?.history] From 387007a6d64b61df1a3329d00b555623a881cf01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:36:02 +0800 Subject: [PATCH 2/6] fix(components): gate compaction Stop on cancellable turn Chatgpt-codex-connector[bot] P2 review (2026-09-01T16:05:21Z) on session-chat-interface.tsx:3348: when a pending/in-progress compaction marker remains in history but its assistant entry is already finished (restart, interrupted notification stream), isContextCompacting stays true while activeAssistantTurnId is null. The previous canStopAgent branch showed Stop in that state, but handleStop rejects the click as missing_active_turn, leaving an idle session with a permanently nonfunctional Stop button. Gate the compaction case on a cancellable assistant turn by extracting canStopAgentEnabled into session-context-compaction.ts and adding regression tests. Model: gpt-5.6-luna --- .../sessions/session-chat-interface.tsx | 13 ++-- .../src/lib/session-context-compaction.ts | 31 ++++++++ .../tests/session-context-compaction.test.ts | 72 ++++++++++++++++++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index d1038b986..987983a54 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -55,7 +55,7 @@ import { Button } from '@/ui/button'; import { isMacOSElectronRenderer, useElectronFullscreen } from '@/lib/electron'; import { getIpcServices } from '@/lib/electron-ipc-client'; import { matchesKeyboardEvent } from '@/lib/commands/key-matcher'; -import { isSessionContextCompacting } from '@/lib/session-context-compaction'; +import { isSessionContextCompacting, canStopAgentEnabled } from '@/lib/session-context-compaction'; import { hasFileTransfer, readDroppedTransfer } from '@/lib/file-drop'; import { resolveProgrammaticTurnAgentRole } from '@/lib/composer-agent-roles'; import { mergeDropZoneHandlers, useDropZone } from '@/hooks/use-drop-zone'; @@ -3493,10 +3493,13 @@ export const SessionChatInterface = memo( isSessionWorking, isGoalActive, }); - const canStopAgent = - isContextCompacting || - (isSessionActive && activeAssistantTurnId != null) || - (isGoalActive && canPauseGoal); + const canStopAgent = canStopAgentEnabled({ + isContextCompacting, + isSessionActive, + activeAssistantTurnId, + isGoalActive, + canPauseGoal, + }); const latestCompletedProposedPlan = useMemo( () => findLatestCompletedCodexProposedPlan(sessionDoc?.history), [sessionDoc?.history] diff --git a/packages/components/src/lib/session-context-compaction.ts b/packages/components/src/lib/session-context-compaction.ts index e7d16dc21..b191c5362 100644 --- a/packages/components/src/lib/session-context-compaction.ts +++ b/packages/components/src/lib/session-context-compaction.ts @@ -13,3 +13,34 @@ export const isSessionContextCompacting = ( } return false; }; + +export type CanStopAgentOptions = { + isContextCompacting: boolean; + isSessionActive: boolean; + activeAssistantTurnId: string | null; + isGoalActive: boolean; + canPauseGoal: boolean; +}; + +/** + * Whether the session Stop control should be exposed. + * + * The compaction branch is gated on a cancellable assistant turn: when a + * pending/in-progress compaction marker remains in history but its assistant + * entry is already finished (restart, interrupted notification stream), + * `isContextCompacting` is true while `activeAssistantTurnId` is null. In that + * state Stop would be shown but `handleStop` rejects the click as + * `missing_active_turn`, leaving an idle session with a permanently + * nonfunctional Stop button. Only expose Stop during compaction when there is + * a turn to cancel. + */ +export const canStopAgentEnabled = ({ + isContextCompacting, + isSessionActive, + activeAssistantTurnId, + isGoalActive, + canPauseGoal, +}: CanStopAgentOptions): boolean => + (isContextCompacting && activeAssistantTurnId != null) || + (isSessionActive && activeAssistantTurnId != null) || + (isGoalActive && canPauseGoal); diff --git a/packages/components/tests/session-context-compaction.test.ts b/packages/components/tests/session-context-compaction.test.ts index dac623cb4..398cf4ccc 100644 --- a/packages/components/tests/session-context-compaction.test.ts +++ b/packages/components/tests/session-context-compaction.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory } from '@lody/shared'; -import { isSessionContextCompacting } from '../src/lib/session-context-compaction'; +import { canStopAgentEnabled, isSessionContextCompacting } from '../src/lib/session-context-compaction'; const historyWithStatus = ( status: 'pending' | 'in_progress' | 'completed' | 'failed', @@ -38,3 +38,73 @@ describe('isSessionContextCompacting', () => { expect(isSessionContextCompacting(historyWithStatus('in_progress', true))).toBe(true); }); }); + +describe('canStopAgentEnabled', () => { + const base = { + isContextCompacting: false, + isSessionActive: false, + activeAssistantTurnId: null, + isGoalActive: false, + canPauseGoal: false, + }; + + it('does not expose Stop during compaction without a cancellable turn', () => { + // A pending/in-progress compaction marker remains in history but its + // assistant entry is already finished (restart, interrupted notification + // stream). Stop must NOT be shown — clicking it would be rejected as + // missing_active_turn, leaving a permanently nonfunctional button. + expect( + canStopAgentEnabled({ ...base, isContextCompacting: true, activeAssistantTurnId: null }) + ).toBe(false); + }); + + it('exposes Stop during compaction when a cancellable turn exists', () => { + expect( + canStopAgentEnabled({ + ...base, + isContextCompacting: true, + activeAssistantTurnId: 'turn-1', + }) + ).toBe(true); + }); + + it('exposes Stop for an active assistant turn regardless of compaction', () => { + expect( + canStopAgentEnabled({ + ...base, + isSessionActive: true, + activeAssistantTurnId: 'turn-2', + }) + ).toBe(true); + }); + + it('does not expose Stop for an active session without a turn', () => { + expect( + canStopAgentEnabled({ + ...base, + isSessionActive: true, + activeAssistantTurnId: null, + }) + ).toBe(false); + }); + + it('exposes Stop for a pausable goal', () => { + expect( + canStopAgentEnabled({ + ...base, + isGoalActive: true, + canPauseGoal: true, + }) + ).toBe(true); + }); + + it('does not expose Stop for an unpausable goal', () => { + expect( + canStopAgentEnabled({ + ...base, + isGoalActive: true, + canPauseGoal: false, + }) + ).toBe(false); + }); +}); From 76c48242d177d0e99d54e6c9e8d3e33846ab0930 Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:46:57 +0800 Subject: [PATCH 3/6] fix(components): normalize active turn id for Stop control Model: gpt-5.6-luna --- .../src/components/sessions/session-chat-interface.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 987983a54..f89ca5839 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3496,7 +3496,7 @@ export const SessionChatInterface = memo( const canStopAgent = canStopAgentEnabled({ isContextCompacting, isSessionActive, - activeAssistantTurnId, + activeAssistantTurnId: activeAssistantTurnId ?? null, isGoalActive, canPauseGoal, }); From b4f999f3edb0264b1bde54479517c9b857cc74af Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:11:21 +0800 Subject: [PATCH 4/6] fix: finalize stale compaction stop requests Model: gpt-5.6-luna --- .../src/session/session-execution-service.ts | 46 ++++++++++++++ .../tests/session-execution-service.test.ts | 63 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 7783c8433..ef5ae14db 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -5303,6 +5303,52 @@ export class SessionExecutionService { const currentTurnId = activeTurnId ?? this.currentTurnBySession.get(sessionId); // Cancel is exact-match only: a stale stop request must not interrupt a newer assistant turn. if (!isPrompting && !isCurrentExecutionTurn) { + const history = await sessionDoc.getHistory(); + const hasUnfinishedRequestedTurn = history.some( + (entry) => + entry.id === turnId && + entry.role === 'assistant' && + entry.finished !== true && + typeof entry.endedAt !== 'number' && + entry.items?.some( + (item) => + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) === true + ); + if (currentTurnId == null && hasUnfinishedRequestedTurn) { + this.deps.logger.debug( + `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` + ); + this.deps.clearSessionActivePresence(sessionId); + await sessionDoc.updateHistory((nextHistory) => { + for (const entry of nextHistory) { + if (entry.id !== turnId) continue; + entry.finished = true; + entry.endedAt = getServerNow(); + if (!entry.items) continue; + for (const item of entry.items) { + if ( + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) { + item.status = 'failed'; + } + } + } + return nextHistory; + }); + + await this.finalizeCancelledTurn({ + sessionId, + sessionDoc, + turnId, + reportTurnError: false, + }); + return { success: true }; + } this.deps.logger.debug( `[${sessionId}] Ignoring stop request for stale turn ${turnId} (current=${currentTurnId ?? 'none'})` ); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index b8f1b9111..9e5efb924 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -6276,6 +6276,69 @@ describe('SessionExecutionService', () => { }); }); + it('finalizes a stale unfinished compaction turn when no live runtime owns it', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const compactionItem = { + type: 'tool_call', + toolCallId: 'context-compaction-stale', + title: 'Context compacting', + status: 'in_progress', + activityKind: 'context_compaction', + }; + const history = [ + { + id: 'assistant-stale-compaction', + role: 'assistant', + items: [compactionItem], + finished: false, + }, + ]; + const sessionDoc = { + getHistory: vi.fn(async () => history), + setStatus: vi.fn(async () => {}), + updateHistory: vi.fn(async (update: (value: typeof history) => typeof history) => { + update(history); + }), + }; + const sessionManager = { + getSession: vi.fn(() => null), + getPendingSession: vi.fn(() => null), + createSession: vi.fn(), + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager; + const deps = createBaseDeps({ + sessionManager, + getActiveTurnId: vi.fn(() => undefined), + workspaceDocument: { + repo: { + upsertDocMeta, + getDocMeta: vi.fn(async () => ({ meta: {} })), + }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + updateAcpCapabilities: vi.fn(async () => {}), + } as unknown as LoroDocumentManager, + }); + + const service = new SessionExecutionService(deps); + const result = await service.cancelSession({ + type: 'session/cancel', + sessionId: 'session-stale-compaction' as SessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + turnId: 'assistant-stale-compaction', + }); + + expect(result).toEqual({ success: true }); + expect(compactionItem.status).toBe('failed'); + expect(sessionDoc.updateHistory).toHaveBeenCalled(); + expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); + expect(upsertDocMeta).toHaveBeenCalledWith('session-session-stale-compaction', { + lastCanceledTurn: undefined, + }); + }); + it('keeps a newer queued turn pending when cancelling the currently running turn', async () => { const upsertDocMeta = vi.fn(async () => {}); const sessionDoc = { From 5e2845aff407edb90513c84c67ad6ee6065efbe3 Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:54:32 +0800 Subject: [PATCH 5/6] fix: preserve live turn on stale compaction stop Model: gpt-5.6-luna --- .../src/session/session-execution-service.ts | 88 ++++++++++--------- packages/acp-extension-codex | 2 +- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index ef5ae14db..4cae2132a 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -5298,56 +5298,62 @@ export class SessionExecutionService { const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const activeTurnId = this.deps.getActiveTurnId(sessionId); const executionTurnId = this.currentTurnBySession.get(sessionId); + const runtimeTurnId = this.turnRuntimeBySession.get(sessionId)?.turnId; const isPrompting = activeTurnId === turnId; - const isCurrentExecutionTurn = executionTurnId === turnId; - const currentTurnId = activeTurnId ?? this.currentTurnBySession.get(sessionId); + const isCurrentExecutionTurn = executionTurnId === turnId || runtimeTurnId === turnId; + const currentTurnId = activeTurnId ?? executionTurnId ?? runtimeTurnId; // Cancel is exact-match only: a stale stop request must not interrupt a newer assistant turn. if (!isPrompting && !isCurrentExecutionTurn) { - const history = await sessionDoc.getHistory(); - const hasUnfinishedRequestedTurn = history.some( - (entry) => - entry.id === turnId && - entry.role === 'assistant' && - entry.finished !== true && - typeof entry.endedAt !== 'number' && - entry.items?.some( - (item) => - item.type === 'tool_call' && - item.activityKind === 'context_compaction' && - (item.status === 'pending' || item.status === 'in_progress') - ) === true - ); - if (currentTurnId == null && hasUnfinishedRequestedTurn) { - this.deps.logger.debug( - `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` - ); - this.deps.clearSessionActivePresence(sessionId); - await sessionDoc.updateHistory((nextHistory) => { - for (const entry of nextHistory) { - if (entry.id !== turnId) continue; - entry.finished = true; - entry.endedAt = getServerNow(); - if (!entry.items) continue; - for (const item of entry.items) { - if ( + // Only inspect and repair durable history when no live turn owns the session. + // A stale request that races with a newer turn must not touch that turn's + // session-wide presence or require history methods on lightweight test/docs. + if (currentTurnId == null) { + const history = await sessionDoc.getHistory(); + const hasUnfinishedRequestedTurn = history.some( + (entry) => + entry.id === turnId && + entry.role === 'assistant' && + entry.finished !== true && + typeof entry.endedAt !== 'number' && + entry.items?.some( + (item) => item.type === 'tool_call' && item.activityKind === 'context_compaction' && (item.status === 'pending' || item.status === 'in_progress') - ) { - item.status = 'failed'; + ) === true + ); + if (hasUnfinishedRequestedTurn) { + this.deps.logger.debug( + `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` + ); + this.deps.clearSessionActivePresence(sessionId); + await sessionDoc.updateHistory((nextHistory) => { + for (const entry of nextHistory) { + if (entry.id !== turnId) continue; + entry.finished = true; + entry.endedAt = getServerNow(); + if (!entry.items) continue; + for (const item of entry.items) { + if ( + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) { + item.status = 'failed'; + } } } - } - return nextHistory; - }); + return nextHistory; + }); - await this.finalizeCancelledTurn({ - sessionId, - sessionDoc, - turnId, - reportTurnError: false, - }); - return { success: true }; + await this.finalizeCancelledTurn({ + sessionId, + sessionDoc, + turnId, + reportTurnError: false, + }); + return { success: true }; + } } this.deps.logger.debug( `[${sessionId}] Ignoring stop request for stale turn ${turnId} (current=${currentTurnId ?? 'none'})` diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 0e2a6d8df..314b38237 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 0e2a6d8df022549383ed108bf1cc398627974ddd +Subproject commit 314b38237273f3b71335d5193498b60755665769 From 546fb733aceccfa93dee2d9527d125e3dff78efc Mon Sep 17 00:00:00 2001 From: UniversePeak <113168673+UniversePeak@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:03:39 +0800 Subject: [PATCH 6/6] fix: recheck live turn ownership under the rewrite conflict lease Holding the conflict lease across the awaited getHistory() read closes the race where a newer turn starts while stale repair is mid-await: dispatch defers on the busy lease, and the recheck after acquisition keeps the newer turn's presence and dispatch metadata untouched. Model: glm-5.3-flash --- .../src/session/session-execution-service.ts | 107 ++++++++++-------- 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 4cae2132a..227fa4ed0 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -5304,55 +5304,70 @@ export class SessionExecutionService { const currentTurnId = activeTurnId ?? executionTurnId ?? runtimeTurnId; // Cancel is exact-match only: a stale stop request must not interrupt a newer assistant turn. if (!isPrompting && !isCurrentExecutionTurn) { - // Only inspect and repair durable history when no live turn owns the session. - // A stale request that races with a newer turn must not touch that turn's - // session-wide presence or require history methods on lightweight test/docs. + // Stale repair mutates session-wide presence and history, so it must not + // overlap a newer turn or another durable rewrite. Hold the conflict lease + // across the awaited history read and recheck live ownership before + // cleaning up: a turn that starts while getHistory() is awaited must keep + // its presence and dispatch metadata. if (currentTurnId == null) { - const history = await sessionDoc.getHistory(); - const hasUnfinishedRequestedTurn = history.some( - (entry) => - entry.id === turnId && - entry.role === 'assistant' && - entry.finished !== true && - typeof entry.endedAt !== 'number' && - entry.items?.some( - (item) => - item.type === 'tool_call' && - item.activityKind === 'context_compaction' && - (item.status === 'pending' || item.status === 'in_progress') - ) === true - ); - if (hasUnfinishedRequestedTurn) { - this.deps.logger.debug( - `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` - ); - this.deps.clearSessionActivePresence(sessionId); - await sessionDoc.updateHistory((nextHistory) => { - for (const entry of nextHistory) { - if (entry.id !== turnId) continue; - entry.finished = true; - entry.endedAt = getServerNow(); - if (!entry.items) continue; - for (const item of entry.items) { - if ( - item.type === 'tool_call' && - item.activityKind === 'context_compaction' && - (item.status === 'pending' || item.status === 'in_progress') - ) { - item.status = 'failed'; - } + const releaseConflict = this.tryAcquireSessionRewriteConflictLease(sessionId); + if (releaseConflict) { + try { + const liveTurnId = + this.deps.getActiveTurnId(sessionId) ?? + this.currentTurnBySession.get(sessionId) ?? + this.turnRuntimeBySession.get(sessionId)?.turnId; + if (liveTurnId == null) { + const history = await sessionDoc.getHistory(); + const hasUnfinishedRequestedTurn = history.some( + (entry) => + entry.id === turnId && + entry.role === 'assistant' && + entry.finished !== true && + typeof entry.endedAt !== 'number' && + entry.items?.some( + (item) => + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) === true + ); + if (hasUnfinishedRequestedTurn) { + this.deps.logger.debug( + `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` + ); + this.deps.clearSessionActivePresence(sessionId); + await sessionDoc.updateHistory((nextHistory) => { + for (const entry of nextHistory) { + if (entry.id !== turnId) continue; + entry.finished = true; + entry.endedAt = getServerNow(); + if (!entry.items) continue; + for (const item of entry.items) { + if ( + item.type === 'tool_call' && + item.activityKind === 'context_compaction' && + (item.status === 'pending' || item.status === 'in_progress') + ) { + item.status = 'failed'; + } + } + } + return nextHistory; + }); + + await this.finalizeCancelledTurn({ + sessionId, + sessionDoc, + turnId, + reportTurnError: false, + }); + return { success: true }; } } - return nextHistory; - }); - - await this.finalizeCancelledTurn({ - sessionId, - sessionDoc, - turnId, - reportTurnError: false, - }); - return { success: true }; + } finally { + releaseConflict(); + } } } this.deps.logger.debug(