Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions apps/cli/src/session/session-execution-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5298,11 +5298,78 @@ 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) {
// 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 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record cancellation before terminalizing the stale assistant

When the stale compaction belongs to an Operation target turn, this history write publishes a terminal assistant entry before finalizeCancelledTurn records the canceled user status. LodyOperationCoordinator checks for cancellation first but then treats any terminal assistant as succeeded (apps/cli/src/orchestration/operation-coordinator.ts:538-547), so its history watcher can durably finish the operation as successful during this window, and the later cancellation cannot undo that completion. Preserve the existing cancellation-first ordering by recording the owning user turn's cancellation before setting finished/endedAt, or make the updates atomic.

Useful? React with 👍 / 👎.

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 };
}
}
} finally {
releaseConflict();
}
}
}
this.deps.logger.debug(
`[${sessionId}] Ignoring stop request for stale turn ${turnId} (current=${currentTurnId ?? 'none'})`
);
Expand Down
63 changes: 63 additions & 0 deletions apps/cli/tests/session-execution-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3493,8 +3493,13 @@ export const SessionChatInterface = memo(
isSessionWorking,
isGoalActive,
});
const canStopAgent =
(isSessionActive && activeAssistantTurnId != null) || (isGoalActive && canPauseGoal);
const canStopAgent = canStopAgentEnabled({
isContextCompacting,
isSessionActive,
activeAssistantTurnId: activeAssistantTurnId ?? null,
isGoalActive,
canPauseGoal,
});
const latestCompletedProposedPlan = useMemo(
() => findLatestCompletedCodexProposedPlan(sessionDoc?.history),
[sessionDoc?.history]
Expand Down
31 changes: 31 additions & 0 deletions packages/components/src/lib/session-context-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use live turn ownership before exposing Stop

After a daemon restart or interrupted stream leaves both an in-progress compaction marker and an unfinished assistant entry in durable history, this condition still exposes Stop even though nothing is cancellable. Fresh evidence beyond the prior comment is that resolveActiveAssistantTurnId accepts any history entry lacking finished/endedAt, whereas cancelSession requires that ID in its in-memory active/execution maps and otherwise returns success without updating history; consequently, clicking Stop silently no-ops and the button remains permanently visible. Base this branch on a live cancellability signal, or finalize the stale history when cancellation finds no matching runtime.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The fix gates the compaction Stop branch on a non-null activeAssistantTurnId, but as noted that ID is history-derived and may not be live-cancellable after a daemon restart or interrupted stream. Finalizing stale history at cancellation time would require resolving the user turn ID in cancelSession and routing it through markDispatchCancelled, which is a backend change beyond this PR's frontend scope. Happy to take it on as a follow-up if the maintainers agree the live-cancellability signal or stale-finalization path is the right direction.

(isSessionActive && activeAssistantTurnId != null) ||
(isGoalActive && canPauseGoal);
72 changes: 71 additions & 1 deletion packages/components/tests/session-context-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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);
});
});
Loading