diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index f4d88123ec..138525a2d5 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -133,7 +133,7 @@ import { createProjectRootController, type ProjectRootController, } from "./project-root-controller.js"; -import { createSessionCopyCleanupAuthority } from "./quote-companion-cleanup.js"; +import { createSessionCopyCleanupAuthority } from "@maka/storage/session-copy-cleanup"; import { projectHostConnections, registerRuntimeHostConnectionsIpc, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index e51c7efc4f..db8bab7e58 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -53,7 +53,7 @@ import { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import type { SessionCopyCleanupAuthority, SessionCopyCleanupDisposition, -} from "./quote-companion-cleanup.js"; +} from "@maka/storage/session-copy-cleanup"; import { createDesktopNativeCapabilityProvider, type DesktopNativeCapabilityProvider, diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index ce933fcf5b..c2ccd258d8 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -41,7 +41,7 @@ import { resolveSessionActionIds, } from './session-family-action.js'; import { normalizeSessionModelSelection } from './session-model-input.js'; -import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import type { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; import { handleReconnectableRead, type ReconnectableReadIpcMain, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index bd961f7fdd..f18bcb2061 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -48,7 +48,7 @@ import { type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; -import type { SessionCopyCleanupAuthority } from './quote-companion-cleanup.js'; +import type { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; import type { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 84d559537f..dd378337aa 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -336,7 +336,8 @@ describe('Maka Pi TUI runner', () => { const firstCounter = /^\s*\((\d+)\/(\d+)\)\s*$/.exec(screen[topBorder - 1] ?? ''); assert.ok(firstCounter); const totalCommands = Number(firstCounter[2]); - assert.ok(totalCommands > autocompleteSuggestionLines(screen).length); + const shortVisibleCommands = autocompleteSuggestionLines(screen).length; + assert.ok(totalCommands > shortVisibleCommands); assert.equal(bottomBorder, terminal.rows - 2); assert.ok(autocompleteSuggestionLines(screen).some((line) => line.includes('→ /compact'))); @@ -360,13 +361,10 @@ describe('Maka Pi TUI runner', () => { terminal.resize(80, 40); await waitFor(() => { const current = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/); - return autocompleteSuggestionLines(current).length === totalCommands; + return autocompleteSuggestionLines(current).length > shortVisibleCommands; }); screen = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/); - assert.equal( - screen.some((line) => /^\s*\(\d+\/\d+\)\s*$/.test(line)), - false, - ); + assert.ok(screen.some((line) => line.includes(`(${totalCommands}/${totalCommands})`))); assert.ok(autocompleteSuggestionLines(screen).some((line) => line.includes('→ /'))); terminal.resize(40, 20); @@ -6233,6 +6231,148 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('/side submits in a temporary Session and one empty Ctrl-C closes it', async () => { + const terminal = new FakeTerminal(); + const driver = new SideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side explain this choice'); + terminal.input('\r'); + await waitFor(() => driver.prompts.length === 1); + + assert.deepEqual(driver.openedFrom, ['session-1']); + assert.deepEqual(driver.promptSessionIds, ['side-1']); + assert.deepEqual(driver.prompts, ['explain this choice']); + + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + await waitFor(() => driver.closedSides.length === 1); + assert.deepEqual(driver.closedSides, [ + { sideSessionId: 'side-1', parentSessionId: 'session-1' }, + ]); + assert.equal(driver.getSessionId(), 'session-1'); + + terminal.input('continue main'); + terminal.input('\r'); + await waitFor(() => driver.prompts.length === 2); + assert.deepEqual(driver.promptSessionIds, ['side-1', 'session-1']); + + exitMaka(terminal); + await run; + }); + + test('/side detaches from a running parent Turn without stopping it', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningParentSideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('main work'); + terminal.input('\r'); + await driver.parentStarted.promise; + terminal.input('/side inspect while main runs'); + terminal.input('\r'); + await waitFor(() => driver.prompts.length === 2); + + assert.deepEqual(driver.prompts, ['main work', 'inspect while main runs']); + assert.deepEqual(driver.promptSessionIds, ['session-1', 'side-1']); + assert.equal(driver.stopCalls, 0); + + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + await waitFor(() => driver.closedSides.length === 1); + exitMaka(terminal); + await run; + }); + + test('one empty Ctrl-C interrupts and closes a running side conversation', async () => { + const terminal = new FakeTerminal(); + const driver = new RunningSideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side keep checking'); + terminal.input('\r'); + await driver.sideStarted.promise; + terminal.input('\x03'); + + await waitFor(() => driver.closedSides.length === 1); + assert.equal(driver.stopCalls, 1); + assert.equal(driver.getSessionId(), 'session-1'); + + exitMaka(terminal); + await run; + }); + + test('blocks Session identity changes until the side conversation closes', async () => { + const terminal = new FakeTerminal(); + const driver = new SideConversationDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('/side'); + terminal.input('\r'); + await waitFor(() => driver.getSessionId() === 'side-1'); + + terminal.input('/session session-2'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Close the side conversation before switching Sessions.', + ), + ); + terminal.input('/new'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Close the side conversation before starting a new Session.', + ), + ); + terminal.input('/rewind'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Close the side conversation before rewinding.', + ), + ); + + assert.equal(driver.getSessionId(), 'side-1'); + assert.deepEqual(driver.sessionIds, []); + assert.equal(driver.startNewSessionCalls, 0); + + exitMaka(terminal); + await run; + }); + test('relocates a moved session before resuming it at startup', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver([ @@ -7546,6 +7686,97 @@ class HostSkillDriver extends SlashCommandDriver { } } +class SideConversationDriver extends SlashCommandDriver { + readonly openedFrom: string[] = []; + readonly closedSides: Array<{ sideSessionId: string; parentSessionId: string }> = []; + readonly promptSessionIds: string[] = []; + + override preparePrompt( + prompt: string, + options: MakaPreparePromptOptions = {}, + ): Promise { + this.promptSessionIds.push(this.sessionId); + return super.preparePrompt(prompt, options); + } + + async openSideConversation() { + const parentSessionId = this.sessionId; + this.openedFrom.push(parentSessionId); + this.sessionId = 'side-1'; + return { + summary: { + ...fakeSessionSummary('side-1'), + labels: ['mode:side_conversation'], + parentSessionId, + branchOfTurnId: 'turn-settled', + }, + messages: [], + parentSessionId, + sideSessionId: 'side-1', + }; + } + + async closeSideConversation(sideSessionId: string, parentSessionId: string) { + this.closedSides.push({ sideSessionId, parentSessionId }); + this.sessionId = parentSessionId; + return { ...switchResult(fakeSessionSummary(parentSessionId)), cleanup: 'removed' as const }; + } +} + +class RunningParentSideConversationDriver extends SideConversationDriver { + readonly parentStarted = deferred(); + readonly #releaseParent = deferred(); + stopCalls = 0; + + override async *promptEvents(prompt: string, turnId = 'turn-1'): AsyncIterable { + if (prompt === 'main work') { + this.parentStarted.resolve(); + await this.#releaseParent.promise; + } + yield { + type: 'complete', + id: `event-complete-${turnId}`, + turnId, + ts: 1, + stopReason: 'end_turn', + }; + } + + override async openSideConversation() { + this.#releaseParent.resolve(); + return super.openSideConversation(); + } + + override async stop(): Promise { + this.stopCalls += 1; + } +} + +class RunningSideConversationDriver extends SideConversationDriver { + readonly sideStarted = deferred(); + readonly #releaseSide = deferred(); + stopCalls = 0; + + override async *promptEvents(prompt: string, turnId = 'turn-1'): AsyncIterable { + if (prompt === 'keep checking') { + this.sideStarted.resolve(); + await this.#releaseSide.promise; + } + yield { + type: 'complete', + id: `event-complete-${turnId}`, + turnId, + ts: 1, + stopReason: 'end_turn', + }; + } + + override async stop(): Promise { + this.stopCalls += 1; + this.#releaseSide.resolve(); + } +} + class FailingSwitchSessionDriver extends SlashCommandDriver { async switchSession(_sessionId: string): Promise { throw new Error('session not found'); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index a6fb92e2db..b163b6689f 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -1226,6 +1226,99 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('opens a hidden side copy at the latest completed Turn and removes it on close', async (t) => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'maka-tui-side-')); + t.after(() => rm(cleanupRoot, { recursive: true, force: true })); + const sourceMessages: StoredMessage[] = [ + userMessage('turn-completed', 'Settled question'), + assistantMessage('turn-completed', 'Settled answer'), + turnStateMessage('turn-completed', 'completed'), + userMessage('turn-failed', 'Failed question'), + turnStateMessage('turn-failed', 'failed'), + ]; + const subscriptions = [ + new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve(sourceMessages)), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-copy-source', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages.slice(0, 3)), + 'subscription-side', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve([ + ...sourceMessages.slice(0, 3), + userMessage('turn-side', 'Side question'), + assistantMessage('turn-side', 'Side answer'), + ]), + 'subscription-side-read', + ), + new FakeSubscription( + continuitySnapshot({ rootTurn: null }), + Promise.resolve(sourceMessages), + 'subscription-parent-return', + ), + ]; + const connection = new FakeConnection(subscriptions); + connection.sessionQueries.push( + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'session-1', revision: 4 }), + sessionProjection({ + id: 'side-1', + labels: ['mode:side_conversation'], + parentSessionId: 'session-1', + branchOfTurnId: 'turn-completed', + }), + sessionProjection({ id: 'session-1' }), + sessionProjection({ id: 'side-1', labels: ['mode:side_conversation'] }), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'side-1', + sessionCopyCleanupRoot: cleanupRoot, + }); + await driver.switchSession('session-1'); + + const opened = await driver.openSideConversation!(); + + assert.equal((await stat(join(cleanupRoot, 'a'.repeat(64), 'runtime.sqlite'))).isFile(), true); + + assert.equal(opened.parentSessionId, 'session-1'); + assert.equal(opened.sideSessionId, 'side-1'); + assert.deepEqual(opened.messages, []); + assert.deepEqual( + (await driver.readMessages()).map((message) => + 'turnId' in message ? `${message.type}:${message.turnId}` : message.type, + ), + ['user:turn-side', 'assistant:turn-side'], + ); + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.branch.create')?.input, + { + sourceSessionId: 'session-1', + targetSessionId: 'side-1', + sourceTurnId: 'turn-completed', + expectedSourceRevision: 4, + intent: 'side_conversation', + }, + ); + + const closed = await driver.closeSideConversation!('side-1', 'session-1'); + assert.equal(closed.summary.id, 'session-1'); + assert.equal(closed.cleanup, 'removed'); + assert.deepEqual( + connection.requests.find(({ operation }) => operation === 'session.remove')?.input, + { sessionId: 'side-1', expectedRevision: 1 }, + ); + }); + test('reopens a failed Session channel before starting the next turn', async () => { const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([])); const second = new FakeSubscription( @@ -1603,6 +1696,7 @@ class FakeConnection { ) { this.value = { ...(reconnecting ? { reconnecting: true as const } : {}), + rootId: 'a'.repeat(64), hostEpoch: 'host-1', request: (operation: K, input: OperationInput) => this.request(operation, input), @@ -1641,6 +1735,24 @@ class FakeConnection { }, }) as OperationOutput; } + if (operation === 'session.branch.create') { + const copy = input as OperationInput<'session.branch.create'>; + return { + kind: 'committed', + session: sessionProjection({ + id: copy.targetSessionId, + labels: copy.intent === 'side_conversation' ? ['mode:side_conversation'] : [], + parentSessionId: copy.sourceSessionId, + branchOfTurnId: copy.sourceTurnId, + }), + } as OperationOutput; + } + if (operation === 'session.remove') { + return { + kind: 'removed', + sessionId: (input as OperationInput<'session.remove'>).sessionId, + } as OperationOutput; + } if (operation === 'goal.control') { const outcome = this.goalControlOutcomes.shift(); if (outcome === undefined) throw new Error('Unexpected goal.control request'); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 61d335b547..6a44cc4bb6 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -362,6 +362,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // that window would target the freshly attached Session instead of the Turn // being left behind. let detaching = false; + let sideConversation: + | { readonly parentSessionId: string; readonly sideSessionId: string } + | undefined; // True while the /session picker is open mid-turn: Escape must close the // overlay, not arm the double-Escape interrupt for the running Turn (#3380). let sessionPickerOverlayOpen = false; @@ -462,8 +465,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const activityStrip = new MakaActivityStripComponent(metadata); const pendingQueue = new MakaPendingQueueComponent(state); const statusLine = new MakaStatusLineComponent(metadata); - // Show the whole slash-command set at once — discoverability is the point of - // the menu. Keep a little headroom above the current command count. + // Use the vendor editor's full 20-item autocomplete capacity. Larger command + // catalogs remain scrollable and keep an exact position/total counter. const editor = new MakaSkillHighlightEditor(tui, editorTheme(), { paddingX: 0, autocompleteMaxVisible: EDITOR_AUTOCOMPLETE_MAX_VISIBLE, @@ -1651,11 +1654,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; + const blockIdentityChangeWhileSideOpen = (action: string): boolean => { + if (!sideConversation) return false; + state.entries.push({ + kind: 'notice', + level: 'error', + text: `Close the side conversation before ${action}.`, + }); + requestRender(); + return true; + }; + // `/session` is view navigation (#3380). Idle, it runs under runControl's // serial lock like any control action; mid-turn that lock is held by the // running Turn, so the switch goes through the detach path instead of // silently no-oping on the busy gate. const goToSession = async (sessionId: string): Promise => { + if (blockIdentityChangeWhileSideOpen('switching Sessions')) return; if (!turnRunning) { await runControl(() => switchSession(sessionId)); return; @@ -1666,6 +1681,87 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (detaching) return; await switchAwayMidTurn(sessionId).catch(reportError); }; + + const openSideConversation = async (prompt: string): Promise => { + if (sideConversation) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Close the current side conversation before opening another.', + }); + requestRender(); + return; + } + if (!input.driver.openSideConversation) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Side conversations are unavailable on this runtime.', + }); + requestRender(); + return; + } + const previousActivity = currentActivityCompletion; + let opened = false; + const adopt = async () => { + const result = await input.driver.openSideConversation!(); + if (turnRunning) turnEpoch += 1; + await applySwitchResult(result); + sideConversation = { + parentSessionId: result.parentSessionId, + sideSessionId: result.sideSessionId, + }; + opened = true; + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'Side conversation opened.', + }); + requestRender(); + }; + if (turnRunning) { + if (detaching) return; + detaching = true; + try { + await adopt(); + } catch (error) { + reportError(error); + } finally { + detaching = false; + startPendingAttachedTurn(); + } + } else { + await runControl(adopt); + } + if (!opened || !prompt) return; + await previousActivity?.catch(() => undefined); + submitPrompt(prompt); + }; + + const closeSideConversation = async (): Promise => { + const pair = sideConversation; + if (!pair || !input.driver.closeSideConversation) return; + const result = await input.driver.closeSideConversation( + pair.sideSessionId, + pair.parentSessionId, + ); + await applySwitchResult(result); + sideConversation = undefined; + if (result.cleanup === 'pending') { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Side conversation closed; cleanup will be retried on the next launch.', + }); + } + requestRender(); + }; + const interruptAndCloseSideConversation = (): void => { + if (interruptRequested) return; + const completion = currentActivityCompletion; + requestTurnInterrupt(); + void completion?.then(() => runControl(closeSideConversation)); + }; const openSessionPicker = (): Promise => { if (!turnRunning) return runControl(showSessionList); // The picker itself is a passive overlay; only its selection detaches. @@ -2305,6 +2401,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const showRewindPicker = async () => { + if (blockIdentityChangeWhileSideOpen('rewinding')) return; const targets = await input.driver.listRewindTargets(); if (targets.length === 0) { state.entries.push({ @@ -2348,6 +2445,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const newSession = () => { + if (blockIdentityChangeWhileSideOpen('starting a new Session')) return; input.driver.startNewSession(); // A fresh session is not bound by the previous one's boundary. Falling back // to the *current* label would keep the previous Session's mode, including @@ -2377,6 +2475,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // short line shows in the transcript. const importForeignSession = async (summary: ForeignSessionSummary): Promise => { if (busy || input.foreignSessions === undefined) return; + if (blockIdentityChangeWhileSideOpen('importing another Session')) return; busy = true; const activity = beginActivity(); editor.disableSubmit = true; @@ -3239,6 +3338,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void goToSession(sessionId); }, }, + side: { + description: primaryGuidance.commands.side, + midTurn: 'switch', + run: (_parts: string[], rawTail?: string) => { + void openSideConversation(rawTail?.trim() ?? ''); + }, + }, graph: { description: primaryGuidance.commands.graph, // parseGraphCommand answers status and refuses changes ahead of generic @@ -3325,6 +3431,18 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // press+release pair could count as a double Escape. We never act on // releases here; returning undefined lets the TUI apply its own filtering. if (isKeyRelease(data)) return undefined; + if ( + sideConversation && + matchesKey(data, Key.ctrl('c')) && + !isKeyRepeat(data) && + editor.getText().length === 0 && + !editorPastePending + ) { + lastIdleCtrlCAt = 0; + if (turnRunning) interruptAndCloseSideConversation(); + else if (!busy) void runControl(closeSideConversation); + return { consume: true }; + } if ( activeUserQuestionRequest(state) && turnRunning && @@ -3526,10 +3644,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const BOTTOM_PICKER_MARGIN_ROWS = 4; -// The editor's autocomplete window height. Keep it at least as large as the -// full slash-command menu, so a bare `/` shows every command rather than -// silently clipping the last command. -const EDITOR_AUTOCOMPLETE_MAX_VISIBLE = 24; +// The vendor editor clamps this window to 20 rows. Additional commands remain +// reachable by scrolling and are identified by its exact position counter. +const EDITOR_AUTOCOMPLETE_MAX_VISIBLE = 20; export function formatContextDiagnostics(diagnostics: ContextDiagnostics): string { if (diagnostics.status === 'unavailable') { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 2eeebf0e8c..99c7bee950 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -18,10 +18,12 @@ */ import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { decodeStoredMessage as decodePersistedStoredMessage, + deriveTurnRecords, userFacingText, type SessionSummary, type StoredMessage, @@ -33,6 +35,11 @@ import { type SessionEvent, type ShellRunUpdate, } from '@maka/core/events'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import { + createSessionCopyCleanupAuthority, + type SessionCopyCleanupAuthority, +} from '@maka/storage/session-copy-cleanup'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; @@ -70,6 +77,8 @@ import { import type { InspectCwdChanges, MakaAttachedSessionTurn, + MakaSideConversationCloseResult, + MakaSideConversationOpenResult, MakaPreparePromptOptions, MakaPreparedSessionTurn, MakaSessionDriver, @@ -115,11 +124,13 @@ export interface RuntimeHostMakaSessionDriverInput { now?: () => number; inspectCwdChanges?: InspectCwdChanges; executionLocation?: { readonly kind: 'client_path' } | { readonly kind: 'host' }; + /** Client-local durable lease parent for temporary TUI conversation copies. */ + sessionCopyCleanupRoot?: string; } type RuntimeHostSessionDriverConnection = Pick< RuntimeHostConnection, - 'hostEpoch' | 'openSessionSubscription' | 'request' + 'rootId' | 'hostEpoch' | 'openSessionSubscription' | 'request' >; export interface RuntimeHostMakaSessionDriver extends MakaSessionDriver { @@ -141,6 +152,8 @@ export interface RuntimeHostMakaSessionDriver extends MakaSessionDriver { ): () => void; listShellRunUpdates(sessionId: string): Promise; subscribeShellRunUpdates(listener: (update: ShellRunUpdate) => void): () => void; + recoverSideConversations(): Promise; + cleanupOwnedSideConversations(): Promise; } export function createRuntimeHostMakaSessionDriver( @@ -155,6 +168,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { readonly #now: () => number; readonly #inspectCwdChanges: InspectCwdChanges; readonly #executionLocation: NonNullable; + readonly #sessionCopyCleanup: SessionCopyCleanupAuthority | undefined; readonly moveSession: MakaSessionDriver['moveSession']; #sessionId: string | null = null; #workspace: { target?: WorkspaceTarget; hostCwd: string }; @@ -174,6 +188,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { #activeBoundaryDisplayMode: PermissionMode | undefined; #orchestrationMode: OrchestrationMode; #channel: RuntimeHostSessionChannel | undefined; + #hiddenTranscriptThroughTurnId: string | undefined; #channelOpening: { sessionId: string; promise: Promise } | undefined; readonly #startedTurnReattachTails = new Map>(); #sessionGeneration = 0; @@ -202,6 +217,18 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#now = input.now ?? Date.now; this.#inspectCwdChanges = input.inspectCwdChanges ?? inspectGitCwdChanges; this.#executionLocation = input.executionLocation ?? { kind: 'client_path' }; + this.#sessionCopyCleanup = input.sessionCopyCleanupRoot + ? createSessionCopyCleanupAuthority({ + // rootId is the durable Host authority identity. HostEpoch would + // strand cleanup across an ordinary Host restart, while one shared + // Client root lets a different Host erase this Host's recovery lease. + workspaceRoot: join(input.sessionCopyCleanupRoot, this.#connection.rootId), + removeSession: (sessionId) => this.#removeSessionCopy(sessionId), + resumeSessionCopy: (creation) => this.#resumeSessionCopy(creation), + processId: `tui:${process.pid}`, + isOwnerProcessActive: isTuiProcessActive, + }) + : undefined; this.moveSession = this.#executionLocation.kind === 'host' ? undefined : (cwd) => this.#moveSession(cwd); this.#workspace = { @@ -219,7 +246,10 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { } readMessages(): Promise { - return loadCurrentMessages(this.#connection, this.#requireSession('read messages')); + const hiddenThroughTurnId = this.#hiddenTranscriptThroughTurnId; + return loadCurrentMessages(this.#connection, this.#requireSession('read messages')).then( + (messages) => visibleTranscriptMessages(messages, hiddenThroughTurnId), + ); } async createSession(input: CreateSessionRequest): Promise { @@ -243,6 +273,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async listSessions(): Promise { const sessions = (await readRuntimeHostSessions(this.#connection)) .flatMap(representableSession) + .filter((session) => !isSideConversationSession(session.labels)) .map(projectSessionCatalogSummary); if (this.#executionLocation.kind === 'host') return sessions; return sessions @@ -521,12 +552,15 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { await this.#replaceChannel(opened.channel); this.#workspace = session.workspace; this.#adoptConfiguration(session); + this.#hiddenTranscriptThroughTurnId = isSideConversationSession(session.labels) + ? session.branchOfTurnId + : undefined; this.#activeBoundaryDisplayMode = executionBoundaryDisplayMode(boundary); const attachedTurnId = opened.attachedTurnId ?? opened.channel.firstObservedTurnId; opened.channel.activate(attachedTurnId); return { summary, - messages: opened.messages, + messages: visibleTranscriptMessages(opened.messages, this.#hiddenTranscriptThroughTurnId), ...(relocation === undefined ? {} : { relocation }), ...(attachedTurnId ? { @@ -555,7 +589,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async listRewindTargets(): Promise { if (!this.#sessionId) return []; - const messages = await loadCurrentMessages(this.#connection, this.#sessionId); + const messages = await this.readMessages(); const seenTurnIds = new Set(); const targets: RewindTarget[] = []; for (const message of messages) { @@ -569,7 +603,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async rewindToTurn(turnId: string): Promise { const sourceSessionId = this.#requireSession('rewind'); - const messages = await loadCurrentMessages(this.#connection, sourceSessionId); + const messages = await this.readMessages(); const promptMessage = messages.find( (message): message is Extract => message.type === 'user' && message.turnId === turnId, @@ -598,10 +632,75 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { throw new Error(`Session kept changing while rewinding: ${sourceSessionId}`); } + async recoverSideConversations(): Promise { + await this.#requireSessionCopyCleanup().recover(); + } + + async openSideConversation(): Promise { + const parentSessionId = this.#requireSession('open a side conversation'); + const messages = await loadCurrentMessages(this.#connection, parentSessionId); + const sourceTurnId = deriveTurnRecords(messages) + .reverse() + .find((turn) => turn.status === 'completed')?.turnId; + if (!sourceTurnId) { + throw new Error('A side conversation requires at least one completed Turn.'); + } + const sideSessionId = this.#newId(); + const cleanup = this.#requireSessionCopyCleanup(); + await cleanup.ownCreation( + { + sessionId: sideSessionId, + kind: 'branch', + sourceSessionId: parentSessionId, + sourceTurnId, + intent: 'side_conversation', + ownerId: 'tui-side', + }, + () => + this.#resumeSessionCopy({ + sessionId: sideSessionId, + kind: 'branch', + sourceSessionId: parentSessionId, + sourceTurnId, + intent: 'side_conversation', + }), + ); + try { + return { + ...(await this.switchSession(sideSessionId)), + parentSessionId, + sideSessionId, + }; + } catch (error) { + await cleanup.schedule(sideSessionId).catch(() => undefined); + throw error; + } + } + + async closeSideConversation( + sideSessionId: string, + parentSessionId: string, + ): Promise { + if (this.#sessionId !== sideSessionId) { + throw new Error('The active Session is not the side conversation being closed.'); + } + const parent = await this.switchSession(parentSessionId); + const cleanup = await this.#requireSessionCopyCleanup() + .cleanup(sideSessionId) + .then(() => 'removed' as const) + .catch(() => 'pending' as const); + return { ...parent, cleanup }; + } + + async cleanupOwnedSideConversations(): Promise { + await this.#requireSessionCopyCleanup().abandonOwner('tui-side'); + } + startNewSession(): void { this.#sessionGeneration += 1; this.#channelGeneration += 1; this.#sessionId = null; + this.#hiddenTranscriptThroughTurnId = undefined; // A fresh Session carries no client claim on its mode: leaving a previous // Session's elevation here would both misreport the mode and create the // next Session with it (#3020). Full access stays an explicit per-session @@ -943,6 +1042,51 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return this.#sessionId; } + #requireSessionCopyCleanup(): SessionCopyCleanupAuthority { + if (!this.#sessionCopyCleanup) { + throw new Error('Side conversations are unavailable without durable cleanup storage.'); + } + return this.#sessionCopyCleanup; + } + + async #resumeSessionCopy(input: { + sessionId: string; + kind: 'branch' | 'revision'; + sourceSessionId: string; + sourceTurnId: string; + intent?: 'side_conversation'; + }): Promise { + if (input.kind !== 'branch') { + throw new Error(`TUI side cleanup cannot resume a ${input.kind} copy.`); + } + for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) { + const source = await getRuntimeHostSession(this.#connection, input.sourceSessionId); + if (!source) throw new Error(`Session not found: ${input.sourceSessionId}`); + const result = await this.#request('session.branch.create', { + sourceSessionId: input.sourceSessionId, + targetSessionId: input.sessionId, + sourceTurnId: input.sourceTurnId, + expectedSourceRevision: source.revision, + ...(input.intent ? { intent: input.intent } : {}), + }); + if (result.kind === 'committed') return; + } + throw new Error(`Session kept changing while copying: ${input.sourceSessionId}`); + } + + async #removeSessionCopy(sessionId: string): Promise<'removed'> { + for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) { + const session = await getRuntimeHostSession(this.#connection, sessionId); + if (!session) return 'removed'; + const result = await this.#request('session.remove', { + sessionId, + expectedRevision: session.revision, + }); + if (result.kind === 'removed') return 'removed'; + } + throw new Error(`Session kept changing while removing: ${sessionId}`); + } + #publishStartedTurn(turn: MakaPreparedSessionTurn, sessionGeneration: number): void { if (this.#claimedTurnIds.delete(turn.turnId)) return; const sourceChannel = this.#channel; @@ -1082,6 +1226,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { #refreshTranscript(sessionId: string, sessionGeneration: number, turnId: string): void { const refreshSequence = ++this.#transcriptRefreshSequence; + const hiddenThroughTurnId = this.#hiddenTranscriptThroughTurnId; void loadCurrentMessages(this.#connection, sessionId) .then((messages) => { if ( @@ -1095,7 +1240,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { sessionId, sessionGeneration, turnId, - messages, + visibleTranscriptMessages(messages, hiddenThroughTurnId), 'reconcile', ); }) @@ -1171,6 +1316,33 @@ function representableSession(item: SessionCatalogItem): SessionCatalogProjectio return 'kind' in item ? [] : [item]; } +function isTuiProcessActive(ownerProcessId: string): boolean { + const match = /^tui:(\d+)$/.exec(ownerProcessId); + if (!match) return false; + const processId = Number(match[1]); + if (!Number.isSafeInteger(processId) || processId <= 0) return false; + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +} + +function visibleTranscriptMessages( + messages: StoredMessage[], + hiddenThroughTurnId: string | undefined, +): StoredMessage[] { + if (!hiddenThroughTurnId) return messages; + let boundary = -1; + for (let index = 0; index < messages.length; index += 1) { + if ('turnId' in messages[index]! && messages[index]!.turnId === hiddenThroughTurnId) { + boundary = index; + } + } + return boundary < 0 ? messages : messages.slice(boundary + 1); +} + function requireSession(item: SessionCatalogItem): SessionCatalogProjection { if (!('kind' in item)) return item; throw new Error(`Runtime Host Session is not representable by this CLI: ${item.id}`); diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index 52293a35db..e4f02d151b 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -117,6 +117,7 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< if (hint) process.stdout.write(`${hint}\n`); return 0; } finally { + await context.driver.cleanupOwnedSideConversations().catch(() => undefined); await context.close(); } } diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index e19ad9f4b0..c176f3db6e 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; import type { PermissionMode } from '@maka/core/permission'; import { createGenesisExecutionBoundary, @@ -123,11 +124,13 @@ export async function createRuntimeHostTuiContext( llmConnectionSlug: target.connection.slug, model: target.model, prospectivePermissionMode, + sessionCopyCleanupRoot: join(input.clientDataRoot, 'tui-session-copies'), executionLocation: connected.profile.kind === 'local' ? { kind: 'client_path' } : { kind: 'host' }, ...(workspace ? { workspace } : {}), }; const driver = createRuntimeHostMakaSessionDriver(driverInput); + await driver.recoverSideConversations(); return { connection, driver, diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 42e179efab..e9fe200a6e 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -60,6 +60,15 @@ export interface MakaSessionRewindResult extends MakaSessionSwitchResult { prompt: string; } +export interface MakaSideConversationOpenResult extends MakaSessionSwitchResult { + parentSessionId: string; + sideSessionId: string; +} + +export interface MakaSideConversationCloseResult extends MakaSessionSwitchResult { + cleanup: 'removed' | 'pending'; +} + export interface MakaPreparedSessionTurn { sessionId: string; turnId: string; @@ -115,6 +124,11 @@ export interface MakaSessionDriver { ): Promise; listRewindTargets(): Promise; rewindToTurn(turnId: string): Promise; + openSideConversation?(): Promise; + closeSideConversation?( + sideSessionId: string, + parentSessionId: string, + ): Promise; subscribeStartedTurns?(listener: (turn: MakaAttachedSessionTurn) => void): () => void; subscribeResolvedInteractions?( listener: (sessionId: string, requestId: string) => void, diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 28c1621a99..259f0b2698 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -65,6 +65,7 @@ const TUI_PRIMARY_GUIDANCE = { rewind: '回退到较早的对话轮次', session: '切换或恢复会话', setup: '配置模型提供商(API Key)', + side: '打开临时 Side Conversation', skill: '调用 Skill(也可直接输入 /skill:)', swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', thinking: '设置思考级别', @@ -112,6 +113,7 @@ const TUI_PRIMARY_GUIDANCE = { rewind: 'Rewind to an earlier turn', session: 'Resume session', setup: 'Set up a model provider (API key)', + side: 'Open a temporary side conversation', skill: 'Invoke a skill (or type /skill: inline)', swarm: 'Show, enable, disable, or run one Swarm turn', thinking: 'Set thinking level', diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 211eea51bd..c9a8937dd4 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -44,7 +44,7 @@ export const SLASH_COMMAND_CATALOG = [ { id: 'rewind', session: 'required', surfaces: ['tui'] }, { id: 'session', session: 'none', surfaces: ['tui'] }, { id: 'setup', session: 'none', surfaces: ['tui'] }, - { id: 'side', session: 'required', surfaces: ['desktop'] }, + { id: 'side', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'skill', session: 'required', surfaces: ['tui'] }, { id: 'swarm', session: 'none', surfaces: ['desktop', 'tui'] }, { id: 'thinking', session: 'required', surfaces: ['tui'] }, diff --git a/packages/storage/package.json b/packages/storage/package.json index 0065779df1..fc466ae84f 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -39,6 +39,7 @@ "./runtime-policy-stores": "./dist/runtime-policy-stores.js", "./scheduled-task-store": "./dist/scheduled-task-store.js", "./session-bundle-policy": "./dist/session-bundle-policy.js", + "./session-copy-cleanup": "./dist/session-copy-cleanup.js", "./session-store": "./dist/session-store.js", "./settings-store": "./dist/settings-store.js", "./shell-run-authority": "./dist/shell-run-authority.js", diff --git a/packages/storage/src/__tests__/public-entrypoints.test.ts b/packages/storage/src/__tests__/public-entrypoints.test.ts index f9457b8a6c..c177133c03 100644 --- a/packages/storage/src/__tests__/public-entrypoints.test.ts +++ b/packages/storage/src/__tests__/public-entrypoints.test.ts @@ -64,6 +64,7 @@ const SQLITE_BACKED_ENTRYPOINTS = [ './runtime-event-persistence', './scheduled-task-store', './session-bundle-policy', + './session-copy-cleanup', './session-store', './settings-store', './shell-run-authority', diff --git a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts b/packages/storage/src/__tests__/session-copy-cleanup.test.ts similarity index 89% rename from apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts rename to packages/storage/src/__tests__/session-copy-cleanup.test.ts index 0c9b9cac64..b83ae4a65d 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts +++ b/packages/storage/src/__tests__/session-copy-cleanup.test.ts @@ -23,12 +23,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; -import { createSessionCopyCleanupAuthority } from '../quote-companion-cleanup.js'; +import { createSessionCopyCleanupAuthority } from '../session-copy-cleanup.js'; const roots: string[] = []; async function createWorkspace(): Promise { - const root = await mkdtemp(join(tmpdir(), 'maka-quote-companion-cleanup-')); + const root = await mkdtemp(join(tmpdir(), 'maka-session-copy-cleanup-')); roots.push(root); return root; } @@ -37,7 +37,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); -describe('quote companion cleanup authority', () => { +describe('session copy cleanup authority', () => { it('forgets a known rejected creation without trying to resume or remove it', async () => { const workspaceRoot = await createWorkspace(); let resumes = 0; @@ -195,6 +195,38 @@ describe('quote companion cleanup authority', () => { assert.deepEqual(await readPendingIds(workspaceRoot), []); }); + it('does not recover a live copy whose owning process is still active', async () => { + const workspaceRoot = await createWorkspace(); + const owner = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:101', + removeSession: async () => {}, + }); + await owner.ownCreation( + { + sessionId: 'fork-live-owner', + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + ownerId: 'tui-side', + }, + async () => 'created', + ); + const removed: string[] = []; + const concurrent = createSessionCopyCleanupAuthority({ + workspaceRoot, + processId: 'tui:202', + isOwnerProcessActive: (ownerProcessId) => ownerProcessId === 'tui:101', + removeSession: async (sessionId) => { + removed.push(sessionId); + }, + }); + + assert.deepEqual(await concurrent.recover(), { removed: [], failed: [] }); + assert.deepEqual(removed, []); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-live-owner']); + }); + it('abandons every live copy owned by a renderer that exits', async () => { const workspaceRoot = await createWorkspace(); const removed: string[] = []; diff --git a/apps/desktop/src/main/quote-companion-cleanup.ts b/packages/storage/src/session-copy-cleanup.ts similarity index 92% rename from apps/desktop/src/main/quote-companion-cleanup.ts rename to packages/storage/src/session-copy-cleanup.ts index ebc8327469..1f98e84df3 100644 --- a/apps/desktop/src/main/quote-companion-cleanup.ts +++ b/packages/storage/src/session-copy-cleanup.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; +import { acquireOperationalStateDatabase } from './operational-state-store.js'; export interface SessionCopyCreationLease { sessionId: string; @@ -72,16 +72,16 @@ export interface SessionCopyCleanupAuthority { export function createSessionCopyCleanupAuthority(input: { workspaceRoot: string; removeSession: (sessionId: string) => Promise; - resumeSessionCopy?: ( - creation: Omit, - ) => Promise; + resumeSessionCopy?: (creation: Omit) => Promise; processId?: string; + isOwnerProcessActive?: (ownerProcessId: string) => boolean | Promise; }): SessionCopyCleanupAuthority { return new SessionCopyCleanupAuthorityImpl( new SqliteSessionCopyCleanupStore(input.workspaceRoot), input.removeSession, input.resumeSessionCopy, input.processId ?? randomUUID(), + input.isOwnerProcessActive, ); } @@ -97,10 +97,13 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { private readonly removeSession: ( sessionId: string, ) => Promise, - private readonly resumeSessionCopy: (( - creation: Omit, - ) => Promise) | undefined, + private readonly resumeSessionCopy: + | ((creation: Omit) => Promise) + | undefined, private readonly processId: string, + private readonly isOwnerProcessActive: + | ((ownerProcessId: string) => boolean | Promise) + | undefined, ) {} ownCreation(creation: SessionCopyCreationLease, operation: () => Promise): Promise { @@ -159,8 +162,7 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { async abandonOwner(ownerId: string): Promise { const normalizedOwnerId = normalizeOwnerId(ownerId); const owned = (await this.store.list()).filter( - (record) => - record.ownerProcessId === this.processId && record.ownerId === normalizedOwnerId, + (record) => record.ownerProcessId === this.processId && record.ownerId === normalizedOwnerId, ); await Promise.all(owned.map((record) => this.schedule(record.sessionId))); } @@ -170,7 +172,9 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { const failed: SessionCopyCleanupRecovery['failed'] = []; for (const record of await this.store.list()) { const staleOwner = - record.ownerProcessId !== undefined && record.ownerProcessId !== this.processId; + record.ownerProcessId !== undefined && + record.ownerProcessId !== this.processId && + !(await this.isOwnerProcessActive?.(record.ownerProcessId)); if (record.phase !== 'cleanup' && !record.cancelRequested && !staleOwner) continue; try { await this.store.requestCleanup(record.sessionId); @@ -238,9 +242,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { FROM workflow_quote_companion_cleanup WHERE session_id = ? `) - .get(sessionId) as - | { sessionId: string; trackedAt: number; recordJson: string } - | undefined; + .get(sessionId) as { sessionId: string; trackedAt: number; recordJson: string } | undefined; return row ? decodeLeaseRow(row) : undefined; }); } @@ -250,10 +252,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { ownerProcessId: string, ): Promise { return this.mutate(creation.sessionId, (current) => { - if ( - current?.creation && - !samePersistedCreation(current.creation, creation) - ) { + if (current?.creation && !samePersistedCreation(current.creation, creation)) { throw new Error('Session copy target is already bound to another creation'); } if (current?.phase === 'cleanup' || current?.cancelRequested) { @@ -318,9 +317,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { private mutate( sessionId: string, - update: ( - current: PersistedSessionCopyLease | undefined, - ) => PersistedSessionCopyLease, + update: (current: PersistedSessionCopyLease | undefined) => PersistedSessionCopyLease, ): PersistedSessionCopyLease { return this.withDatabase('write', (database) => { const current = readLease(database, sessionId); @@ -366,9 +363,7 @@ function readLease( FROM workflow_quote_companion_cleanup WHERE session_id = ? `) - .get(sessionId) as - | { sessionId: string; trackedAt: number; recordJson: string } - | undefined; + .get(sessionId) as { sessionId: string; trackedAt: number; recordJson: string } | undefined; return row ? decodeLeaseRow(row) : undefined; } @@ -415,10 +410,7 @@ function normalizeCreationLease(creation: SessionCopyCreationLease): SessionCopy }; } -function sameCreation( - left: SessionCopyCreationLease, - right: SessionCopyCreationLease, -): boolean { +function sameCreation(left: SessionCopyCreationLease, right: SessionCopyCreationLease): boolean { return ( left.sessionId === right.sessionId && left.kind === right.kind &&