diff --git a/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts b/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts new file mode 100644 index 00000000000..e3ebef1053e --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-archive-sweep.test.ts @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + createSessionNavigationRowActions, + type SessionNavigationSessionService, +} from '../../renderer/features/session-navigation/testing.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; + +const copy = getShellCopy('en').sessionRowActions; + +/** What Electron IPC does to a thrown error: keep the message, drop the class. */ +function ipcError(token: string): Error { + return new Error(`Error invoking remote method 'sessions:archive': Error: ${token}`); +} + +type Harness = { + /** Each `archive` call, in order. */ + archives: string[]; + /** Each error toast, in order. */ + errors: Array<{ title: string; description?: string }>; + /** Each success toast, in order. */ + successes: Array<{ title: string; description?: string }>; + /** Archive refusals per session id, popped one per call; absent archives fine. */ + refusals: Map; +}; + +function harness(): Harness { + return { archives: [], errors: [], successes: [], refusals: new Map() }; +} + +function installService(h: Harness): SessionNavigationSessionService { + return { + list: async () => [], + setFlagged: async () => undefined, + archive: async (sessionId: string) => { + h.archives.push(sessionId); + const refusal = h.refusals.get(sessionId)?.shift(); + if (refusal) throw refusal; + }, + unarchive: async () => undefined, + rename: async () => undefined, + remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), + previewRemoval: async () => 0, + previewRemovals: async () => ({ + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + }), + moveToProject: async () => ({ ok: true }), + }; +} + +function createActions(h: Harness) { + return createSessionNavigationRowActions({ + uiLocale: 'en', + acquireAutomaticQueryBlock: () => ({ release: () => undefined }), + clearSessionRendererState: () => undefined, + pendingSessionRowActionsRef: { current: new Set() }, + refreshSessions: async () => [], + service: installService(h), + sessionsRef: { current: [] }, + toastApi: { + success: (title: string, description?: string) => { + h.successes.push({ title, description }); + }, + error: (title: string, description?: string) => { + h.errors.push({ title, description }); + }, + confirm: async () => true, + }, + }); +} + +describe('archiveSession refusals', () => { + it('names the delegation action when the Host refuses for a live WorkHub delegation', async () => { + const h = harness(); + h.refusals.set('s1', [ + ipcError('session_archive_refused: workhub_delegation'), + ]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.deepEqual(h.errors, [ + { title: copy.archiveFailedTitle, description: copy.archiveRefusedDelegation }, + ]); + }); + + it('names the subtask action when the Host refuses for a live linked child', async () => { + const h = harness(); + h.refusals.set('s1', [ipcError('session_archive_refused: linked_child')]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.deepEqual(h.errors, [ + { title: copy.archiveFailedTitle, description: copy.archiveRefusedSubtasks }, + ]); + }); + + it('keeps the generic line for a refusal it cannot explain', async () => { + const h = harness(); + h.refusals.set('s1', [ipcError('persistence_failed: disk on fire')]); + const actions = createActions(h); + + await actions.archiveSession('s1'); + + assert.equal(h.errors.length, 1); + assert.equal(h.errors[0]!.title, copy.archiveFailedTitle); + assert.notEqual(h.errors[0]!.description, copy.archiveRefusedDelegation); + assert.notEqual(h.errors[0]!.description, copy.archiveRefusedSubtasks); + }); +}); + +describe('archiveSelected sweep', () => { + it('retries a parent refused ahead of its own subtask and reports success', async () => { + const h = harness(); + // The selection lists the parent first; the Host refuses it while the + // subtask later in the sweep is still live. + h.refusals.set('parent', [ipcError('session_archive_refused: linked_child')]); + const actions = createActions(h); + + await actions.archiveSelected(['parent', 'child']); + + assert.deepEqual(h.archives, ['parent', 'child', 'parent']); + assert.deepEqual(h.errors, []); + assert.deepEqual(h.successes, [ + { title: copy.bulkArchivedTitle(2), description: copy.bulkArchiveDescription }, + ]); + }); + + it('retries a genuine refusal once and still reports it as failed', async () => { + const h = harness(); + h.refusals.set('s1', [ + ipcError('session_archive_refused: workhub_delegation'), + ipcError('session_archive_refused: workhub_delegation'), + ]); + const actions = createActions(h); + + await actions.archiveSelected(['s1']); + + assert.deepEqual(h.archives, ['s1', 's1']); + assert.deepEqual(h.errors, [ + { title: copy.bulkArchiveFailedTitle, description: copy.archiveRefusedDelegation }, + ]); + }); +}); 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 4ce9a437912..7bc8efccd8c 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 @@ -159,7 +159,11 @@ export function registerRuntimeHostSessionCatalogIpc( ipcMain.handle('sessions:archive', async (_event, sessionId: string, options?: unknown) => { requestsRevisionFamily(options); const ids = await actionIds(sessionId, { revisionFamily: true }); - await deps.client.setSessionLifecycle(sessionId, 'archived'); + try { + await deps.client.setSessionLifecycle(sessionId, 'archived'); + } catch (error) { + throw asArchiveRefusal(error); + } await finishSessionRetirement(deps, ids, 'archived'); }); ipcMain.handle('sessions:unarchive', async (_event, sessionId: string, options?: unknown) => { @@ -384,6 +388,26 @@ async function updateConfiguration( return { ok: true, session: toDesktopHostSessionSummary(session) }; } +/** + * Electron IPC strips the error class off a `RuntimeHostOperationError`, so a + * refusal the rail can explain has to travel as a stable token in the message + * text. These are the archive guard's `session_busy` refusals a user can act + * on; any other failure keeps the Host's own message. + */ +const ARCHIVE_REFUSAL_TOKENS: ReadonlyArray = [ + ['has an active WorkHub delegation', 'workhub_delegation'], + ['has an undelivered WorkHub result', 'workhub_result'], + ['has a live linked child Session', 'linked_child'], +]; + +function asArchiveRefusal(error: unknown): unknown { + if (!(error instanceof RuntimeHostOperationError) || error.code !== 'session_busy') return error; + for (const [needle, reason] of ARCHIVE_REFUSAL_TOKENS) { + if (error.message.includes(needle)) return new Error(`session_archive_refused: ${reason}`); + } + return error; +} + const EXPECTED_UPDATE_FAILURES = [ 'session_busy', 'operation_conflict', diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index 028270b040b..8e108b3f101 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -165,6 +165,7 @@ export function createSessionNavigationRowActions(deps: { actionId: 'flag' | 'archive' | 'rename' | 'delete' | 'move', errorTitle: string, action: () => Promise, + describeError?: (error: unknown) => string | undefined, ): Promise { const sessionPrefix = `${sessionId}:`; if (Array.from(pendingSessionRowActionsRef.current).some((key) => key.startsWith(sessionPrefix))) return; @@ -175,7 +176,7 @@ export function createSessionNavigationRowActions(deps: { } catch (error) { toastApi.error( errorTitle, - localizedShellErrorMessage(error, copy.actionFallback, uiLocale), + describeError?.(error) ?? localizedShellErrorMessage(error, copy.actionFallback, uiLocale), undefined, { sessionId }, ); @@ -184,6 +185,23 @@ export function createSessionNavigationRowActions(deps: { } } + /** + * Archive refusals worth explaining arrive re-keyed by the main process as + * `session_archive_refused: ` — Electron IPC strips the error class, + * so the guard's session_busy message cannot carry it any other way. Each + * token names the action that unblocks the archive; anything else keeps the + * generic failure line. + */ + function archiveRefusalDescription(error: unknown): string | undefined { + const reason = (error instanceof Error ? error.message : String(error)).match( + /session_archive_refused: (\S+)/, + )?.[1]; + if (reason === 'workhub_delegation') return copy.archiveRefusedDelegation; + if (reason === 'workhub_result') return copy.archiveRefusedResult; + if (reason === 'linked_child') return copy.archiveRefusedSubtasks; + return undefined; + } + async function flagSession(sessionId: string, flagged: boolean) { return runSessionRowAction(sessionId, 'flag', flagged ? copy.flagFailedTitle : copy.unflagFailedTitle, async () => { await service.setFlagged(sessionId, flagged, { revisionFamily: true }); @@ -192,13 +210,19 @@ export function createSessionNavigationRowActions(deps: { } async function archiveSession(sessionId: string) { - return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { - await withAutomaticQueryBlock(sessionId, async (familyIds) => { - await service.archive(sessionId, { revisionFamily: true }); - for (const id of familyIds) clearSessionRendererState(id); - await refreshSessions(); - }); - }); + return runSessionRowAction( + sessionId, + 'archive', + copy.archiveFailedTitle, + async () => { + await withAutomaticQueryBlock(sessionId, async (familyIds) => { + await service.archive(sessionId, { revisionFamily: true }); + for (const id of familyIds) clearSessionRendererState(id); + await refreshSessions(); + }); + }, + archiveRefusalDescription, + ); } async function unarchiveSession(sessionId: string) { @@ -495,6 +519,12 @@ export function createSessionNavigationRowActions(deps: { * the delete sweep's shape, which would carry a `restored` field that can * never be anything but empty. * + * A selection can hold a parent picked before its own subtask; the Host + * refuses that first call while the subtask is still live. One retry after + * the sweep settles the order inside the selection without teaching the rail + * about subtask links — and a refusal that was never about ordering fails + * once more, exactly as before. + * * Like the sweep, it raises no toast per task: one action is one message, and * a run of them is what a sweep exists to avoid. */ @@ -503,18 +533,17 @@ export function createSessionNavigationRowActions(deps: { new Set(sessionIds.flatMap((sessionId) => revisionFamilySessionIds(sessionsRef.current, sessionId))), ); return withAutomaticQueryBlockOn(familyIds, async () => { - const failed: string[] = []; - let firstFailure: SessionArchiveOutcome['firstFailure']; let archived = 0; - for (const sessionId of sessionIds) { + const failures = new Map(); + const attempt = async (sessionId: string): Promise => { const key = `${sessionId}:archive`; if ( Array.from(pendingSessionRowActionsRef.current).some((pending) => pending.startsWith(`${sessionId}:`), ) ) { - failed.push(sessionId); - continue; + failures.set(sessionId, {}); + return; } pendingSessionRowActionsRef.current.add(key); try { @@ -522,17 +551,27 @@ export function createSessionNavigationRowActions(deps: { await service.archive(sessionId, { revisionFamily: true }); for (const id of rowFamilyIds) clearSessionRendererState(id); archived += 1; + failures.delete(sessionId); } catch (error) { - failed.push(sessionId); - firstFailure ??= { error, sessionId }; + failures.set(sessionId, { error }); } finally { pendingSessionRowActionsRef.current.delete(key); } - } + }; + for (const sessionId of sessionIds) await attempt(sessionId); + for (const sessionId of [...failures.keys()]) await attempt(sessionId); + const failed = [...failures.keys()]; + const firstFailedId = failed.find((sessionId) => failures.get(sessionId)?.error !== undefined); // Once, after the whole sweep. Refreshing per task would re-render the rail // under the user's cursor for every id in the selection. await refreshSessions(); - return { archived, failed, firstFailure }; + return { + archived, + failed, + firstFailure: firstFailedId + ? { error: failures.get(firstFailedId)!.error, sessionId: firstFailedId } + : undefined, + }; }); } @@ -561,7 +600,8 @@ export function createSessionNavigationRowActions(deps: { toastApi.error( copy.bulkArchiveFailedTitle, outcome.firstFailure - ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) + ? archiveRefusalDescription(outcome.firstFailure.error) ?? + localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) : copy.bulkFailedBody(outcome.failed.length), undefined, outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index bbecc4bc4d2..b8c89790e52 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -268,6 +268,10 @@ type ShellCopy = { 'session_busy' | 'operation_conflict' | 'operation_unavailable' | 'not_found', string >; + /** Why the Host refused an archive, keyed by the guard's refusal token. */ + archiveRefusedDelegation: string; + archiveRefusedResult: string; + archiveRefusedSubtasks: string; currentConversation: string; deleteTitle(name: string): string; deleteDescription: string; @@ -886,6 +890,9 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: '当前无法移动这个任务。', not_found: '找不到该项目或任务。', }, + archiveRefusedDelegation: 'WorkHub 仍在向该任务委派工作。先停止或更换委派,再归档。', + archiveRefusedResult: '委派结果还没有送达 WorkHub。请打开 WorkHub 收取结果,或停止该委派。', + archiveRefusedSubtasks: '该任务还有进行中的子任务。请等子任务完成或停止它们,再归档该任务。', currentConversation: '当前任务', deleteTitle: (name: string) => `删除 "${name}"`, deleteDescription: '任务和全部消息会从磁盘上永久移除。该操作不可撤销。', @@ -1410,6 +1417,9 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: '目前無法移動這個任務。', not_found: '找不到該專案或任務。', }, + archiveRefusedDelegation: 'WorkHub 仍在向該任務委派工作。先停止或更換委派,再歸檔。', + archiveRefusedResult: '委派結果還沒有送達 WorkHub。請開啟 WorkHub 收取結果,或停止該委派。', + archiveRefusedSubtasks: '該任務還有進行中的子任務。請等子任務完成或停止它們,再歸檔該任務。', currentConversation: '目前任務', deleteTitle: (name: string) => `刪除 "${name}"`, deleteDescription: '任務和全部訊息會從磁碟上永久移除。該操作不可撤銷。', @@ -1936,6 +1946,12 @@ const SHELL_COPY_BY_LOCALE = { operation_unavailable: 'This task cannot be moved right now.', not_found: 'That project or task could not be found.', }, + archiveRefusedDelegation: + 'WorkHub still delegates work to this task. Stop or replace the delegation, then archive it.', + archiveRefusedResult: + "WorkHub has not received this task's result yet. Open WorkHub so it can collect it, or stop the delegation.", + archiveRefusedSubtasks: + 'This task has subtasks still in flight. Wait for them to finish or stop them, then archive this task.', currentConversation: 'Current task', deleteTitle: (name: string) => `Delete "${name}"`, deleteDescription: diff --git a/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts b/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts new file mode 100644 index 00000000000..d37fe437563 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/workhub-delegation.ts @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + type WorkHubDelegationAssignedMessage, +} from '@maka/core/session'; +import { createSessionStore } from '@maka/storage/session-store'; + +type DelegationStore = Pick< + ReturnType, + 'createStableSession' | 'assignWorkHubMessage' +>; + +export async function createCoordinationSession( + store: Pick, + root: string, +): Promise { + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: root, + name: 'WorkHub', + role: WORKHUB_COORDINATION_SESSION_ROLE, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'explore', + toolProfile: 'workhub-coordination-v1', + }, + }); +} + +export type AssignmentRequest = ReturnType; + +export function assignmentRequest( + actionId: string, + targetSessionId: string, + targetSessionName: string, + targetTurnId: string, +) { + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const content = normalizeMessageContent({ text: 'Continue payment work' }); + const assignment: WorkHubDelegationAssignedMessage = { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: actionId, + ts: 10, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId, + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: actionId, + targetSessionId, + targetSessionName, + targetTurnId, + targetMessageId: `whm_${suffix}`, + delegationId: `whd_${suffix}`, + disposition: 'delegate_existing', + userText: content.text, + }; + return { + assignment, + admission: { + sessionId: targetSessionId, + turnId: targetTurnId, + runId: `whr_${suffix}`, + messageId: assignment.targetMessageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }, + }; +} diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index ac9272b7cef..13f9e3ee4dc 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -48,6 +48,7 @@ import { MemoryExtractionSessionLane } from '../server/memory-extraction-session import { HostSessionRetirementCoordinator } from '../server/session-retirement-coordinator.js'; import { purgeSessionSidecars } from '../server/session-sidecar-purge.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { assignmentRequest, createCoordinationSession } from './fixtures/workhub-delegation.js'; const CONNECTION_CONTEXT: ConnectionContext = { hostEpoch: 'retirement-test', @@ -347,6 +348,238 @@ describe('Host Session retirement coordinator', () => { }); }); + test('rejects manual archive while a WorkHub delegation targets the Session family', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-archive-block', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + assert.deepEqual( + await harness.store.readActiveWorkHubAssignmentsByTarget([harness.revisionId]), + [request.assignment], + ); + + // Archiving any member of the family would hide the delegated target + // from the WorkHub candidate and result views. + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, false); + if (archived.ok) return; + assert.equal(archived.error.code, 'session_busy'); + assert.ok(archived.error.message.includes(harness.revisionId)); + assert.match(archived.error.message, /active WorkHub delegation/); + await assertFamilyLifecycle(harness, false); + }); + }); + + test('archives a family whose WorkHub delegation has finished', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-archive-finished', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + // The Coordination ledger keeps the assignment row until the delegation + // is superseded or stopped; finishing its Turn retires the delegation + // without touching that row. + harness.workHubRetirement.set(request.assignment.delegationId, 'retired'); + + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('rejects manual archive while a retired delegation still owes WorkHub its result', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const request = assignmentRequest( + 'delegation-result-owed', + harness.revisionId, + 'Revision child', + 'target-turn', + ); + await harness.store.assignWorkHubMessage(request); + harness.workHubRetirement.set(request.assignment.delegationId, 'retired'); + harness.workHubUndeliveredResults.add(request.assignment.delegationId); + + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.match(blocked.error.message, /undelivered WorkHub result/); + await assertFamilyLifecycle(harness, false); + + // Once the Host delivers the result, the archive goes through. + harness.workHubUndeliveredResults.delete(request.assignment.delegationId); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('rejects manual archive while an older WorkHub delegation of the same target is still live', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + // Two delegations to the same target coexist in the Coordination ledger + // until one supersedes or stops the other; finishing the newest does not + // retire the older one. + const older = assignmentRequest( + 'delegation-archive-older-live', + harness.revisionId, + 'Revision child', + 'target-turn-older', + ); + await harness.store.assignWorkHubMessage(older); + const newer = assignmentRequest( + 'delegation-archive-newer-retired', + harness.revisionId, + 'Revision child', + 'target-turn-newer', + ); + await harness.store.assignWorkHubMessage(newer); + assert.deepEqual( + await harness.store.readActiveWorkHubAssignmentsByTarget([harness.revisionId]), + [newer.assignment, older.assignment], + ); + harness.workHubRetirement.set(newer.assignment.delegationId, 'retired'); + + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, false); + if (archived.ok) return; + assert.equal(archived.error.code, 'session_busy'); + assert.match(archived.error.message, /active WorkHub delegation/); + await assertFamilyLifecycle(harness, false); + }); + }); + + test('rejects manual archive while an older retired delegation still owes WorkHub its result', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const older = assignmentRequest( + 'delegation-archive-older-result', + harness.revisionId, + 'Revision child', + 'target-turn-older', + ); + await harness.store.assignWorkHubMessage(older); + const newer = assignmentRequest( + 'delegation-archive-newer-result', + harness.revisionId, + 'Revision child', + 'target-turn-newer', + ); + await harness.store.assignWorkHubMessage(newer); + harness.workHubRetirement.set(newer.assignment.delegationId, 'retired'); + harness.workHubRetirement.set(older.assignment.delegationId, 'retired'); + harness.workHubUndeliveredResults.add(older.assignment.delegationId); + + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.match(blocked.error.message, /undelivered WorkHub result/); + await assertFamilyLifecycle(harness, false); + + // With every delegation retired and no result owed, two assignments no + // more block the archive than one did. + harness.workHubUndeliveredResults.delete(older.assignment.delegationId); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('rejects manual archive while a linked child Session is still live', async () => { + await withHarness(async (harness) => { + const childId = await createClosedSubagent(harness, harness.rootId, 1); + // In-flight work is what keeps the relationship live: a reserved root + // Turn on the child, not the mere existence of the link. + harness.blockers.root.add(childId); + const blocked = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(blocked.ok, false); + if (blocked.ok) return; + assert.equal(blocked.error.code, 'session_busy'); + assert.ok(blocked.error.message.includes(harness.rootId)); + assert.match(blocked.error.message, /linked child/); + await assertFamilyLifecycle(harness, false); + assert.equal((await harness.store.readHeaderSnapshot(childId)).isArchived, false); + + // The child carries no live relationship of its own, so once its work + // ends it archives alone. + harness.blockers.root.delete(childId); + const childArchived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: childId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(childArchived.ok, true); + + // With no live child left, the parent archives again. + const parentArchived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(parentArchived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('archives a family beside a finished linked subtask', async () => { + await withHarness(async (harness) => { + await createClosedSubagent(harness, harness.rootId, 1); + // A subtask whose work has ended does not keep its parent out of the + // archive; unlike a removal, the parent's archive leaves it in place. + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.rootId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + + test('archives a family without WorkHub relationships beside a live coordination Session', async () => { + await withHarness(async (harness) => { + await createCoordinationSession(harness.store, harness.workspaceRoot); + const archived = await harness.coordinator.handlers['session.lifecycle.set']( + { sessionId: harness.revisionId, state: 'archived' }, + CONNECTION_CONTEXT, + ); + assert.equal(archived.ok, true); + await assertFamilyLifecycle(harness, true); + }); + }); + test('archives direct subagent Sessions when their parent family is removed', async () => { await withHarness(async (harness) => { const childSessionIds: string[] = []; @@ -930,8 +1163,10 @@ describe('Host Session retirement coordinator', () => { others.push((await harness.store.create(sessionInput(`Archived ${index}`))).id); } const active = await harness.store.create(sessionInput('Still active')); - // An ordinary subtask is archived on its own, not with its parent. - for (const sessionId of [harness.rootId, worktreeChildId, ...others]) { + // An ordinary subtask is archived on its own, not with its parent — and + // the parent archive refuses while the subtask is still live, so the + // subtask retires first. + for (const sessionId of [worktreeChildId, harness.rootId, ...others]) { const archived = await harness.coordinator.handlers['session.lifecycle.set']( { sessionId, state: 'archived' }, CONNECTION_CONTEXT, @@ -1468,6 +1703,8 @@ async function withHarness( const quiescentGraphs = new Set(); const memoryExtractionLane = new MemoryExtractionSessionLane(); const admission = new SessionAdmissionGate(); + const workHubRetirement = new Map(); + const workHubUndeliveredResults = new Set(); const harness: RetirementHarness = { workspaceRoot: root, store, @@ -1480,6 +1717,8 @@ async function withHarness( quiescentGraphs, admission, memoryExtractionLane, + workHubRetirement, + workHubUndeliveredResults, failRemoveCommit: false, failRemovalPublication: false, failArtifactCleanup: false, @@ -1506,6 +1745,8 @@ async function withHarness( probeSessionRemoval: (sessionId) => store.probeSessionRemoval(sessionId), readCatalogRecord: (sessionId) => store.readCatalogRecord(sessionId), readHeaderRecordSnapshot: (sessionId) => store.readHeaderRecordSnapshot(sessionId), + readActiveWorkHubAssignmentsByTarget: (sessionIds) => + store.readActiveWorkHubAssignmentsByTarget(sessionIds), reconcileOrphanedAgentGraphRetirements: () => store.reconcileOrphanedAgentGraphRetirements(), listPendingSessionRetirementCleanupIds: (sessionId) => @@ -1537,6 +1778,12 @@ async function withHarness( hasLiveSessionState: (sessionId) => blockers.message.has(sessionId), retireSessions: (sessionIds) => actions.retiredMessages.push(...sessionIds), }, + workHub: { + readDelegationRetirement: async (assignment) => + workHubRetirement.get(assignment.delegationId) ?? 'not_retired', + hasUndeliveredResult: async (assignment) => + workHubUndeliveredResults.has(assignment.delegationId), + }, interactions: { hasPendingSession: async (sessionId) => blockers.interaction.has(sessionId), }, @@ -1687,6 +1934,10 @@ interface RetirementHarness { readonly quiescentGraphs: Set; readonly admission: SessionAdmissionGate; readonly memoryExtractionLane: MemoryExtractionSessionLane; + /** Delegation-retirement verdict per delegationId; absent reads as live. */ + readonly workHubRetirement: Map; + /** Delegations whose returnResults payload has not reached WorkHub. */ + readonly workHubUndeliveredResults: Set; coordinator: HostSessionRetirementCoordinator; failRemoveCommit: boolean; failMeasurement: boolean; diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 29564c00b7e..8e1a7710372 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -108,6 +108,7 @@ test('two Clients share exact retryable Session branch and revision authority', linkedChildSourceSessionId, metadataLinkedSourceSessionId, ordinaryLinkedChildSessionId, + ordinaryChildSessionId, archivedOwnedSourceSessionId, graphChildSessionId, continuationSourceSessionId, @@ -138,7 +139,7 @@ test('two Clients share exact retryable Session branch and revision authority', host = undefined; host = await startHost(root, capability.rootId); - await verifySecondRestartRetention(root, sourceSessionId); + await verifySecondRestartRetention(root, sourceSessionId, ordinaryChildSessionId); await stopHost(host); host = undefined; @@ -771,7 +772,11 @@ async function verifyRestartRecoveryAndAdmission( } } -async function verifySecondRestartRetention(root: string, sourceSessionId: string): Promise { +async function verifySecondRestartRetention( + root: string, + sourceSessionId: string, + ordinaryChildSessionId: string, +): Promise { const recovered = await connectClient(root); try { assert.deepEqual( @@ -793,6 +798,16 @@ async function verifySecondRestartRetention(root: string, sourceSessionId: strin (await querySession(recovered, GRAPH_REVISION_TARGET_ID)).revisionState, 'committed', ); + // The graph revision family carries linkedChildSource, whose ordinary + // subtask must retire first: a manual archive refuses while a linked + // child is still live. + const archivedOrdinaryChild = requireSessionProjection( + await recovered.request('session.lifecycle.set', { + sessionId: ordinaryChildSessionId, + state: 'archived', + }), + ); + assert.equal(archivedOrdinaryChild.isArchived, true); const archivedGraphRevision = requireSessionProjection( await recovered.request('session.lifecycle.set', { sessionId: GRAPH_REVISION_TARGET_ID, @@ -855,6 +870,7 @@ async function seedSource( linkedChildSourceSessionId: string; metadataLinkedSourceSessionId: string; ordinaryLinkedChildSessionId: string; + ordinaryChildSessionId: string; archivedOwnedSourceSessionId: string; graphChildSessionId: string; continuationSourceSessionId: string; @@ -1782,6 +1798,7 @@ async function seedSource( linkedChildSourceSessionId: linkedChildSource.id, metadataLinkedSourceSessionId: metadataLinkedSource.id, ordinaryLinkedChildSessionId: ordinaryLinkedChild.header.id, + ordinaryChildSessionId: ordinaryChild.header.id, archivedOwnedSourceSessionId: archivedOwnedSource.id, graphChildSessionId: graphChild.header.id, continuationSourceSessionId: continuationSource.id, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d751ed9ae37..96ddd464264 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -40,6 +40,7 @@ import { } from '@maka/core/runtime-invocation'; import { type SessionHeader, + type WorkHubDelegationAssignedMessage, WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, } from '@maka/core/session'; @@ -237,7 +238,8 @@ import { resolveSandboxBoundaryRootSession } from './sandbox-boundary-graph-wake import { HostRuntimePolicyCoordinator } from './runtime-policy-coordinator.js'; import { startHostModelMetadataRefresh } from './model-metadata-refresh.js'; import { HostRuntimeResourceCoordinator } from './runtime-resource-coordinator.js'; -import { SessionAdmissionGate } from './session-admission-gate.js'; +import { SessionAdmissionGate, type SessionAdmissionLease } from './session-admission-gate.js'; +import { workHubResultOrigin } from './workhub-result-coordinator.js'; import { HostSessionCatalogCoordinator } from './session-catalog-coordinator.js'; import { HostWorkspaceResolver } from './workspace-resolver.js'; import { HostSessionRetirementCoordinator } from './session-retirement-coordinator.js'; @@ -2299,6 +2301,43 @@ export async function createExecutionRuntimeHostComposition( return outcome.result.kind === 'committed' ? 'committed' : 'revision_conflict'; }, }); + /** + * One delegation-retirement read for both consumers: the coordination + * coordinator's own actions and the archive guard's relationship check. + * A finished Turn retires the delegation long before anything removes its + * ledger row, so "live" must come from the execution, not the ledger. + */ + const readWorkHubDelegationRetirement = async ( + assignment: WorkHubDelegationAssignedMessage, + admission?: SessionAdmissionLease, + ): Promise<'retired' | 'not_retired' | 'recovering'> => { + const disposition = admission + ? await messages.readMessageExecutionDispositionAdmitted( + assignment.targetSessionId, + assignment.targetMessageId, + admission, + ) + : await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') return 'recovering'; + if (disposition.kind === 'pending') return 'not_retired'; + if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { + return 'retired'; + } + const identity = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + const latest = await coordinator.readLatestRootTurnLineage(identity); + if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; + // The same restart window as `stopOwnedWorkHubRoot`: an unregistered + // root is not evidence that its work ended. + const snapshot = await coordinator.read(latest); + return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; + }; workHubCoordination = new HostWorkHubCoordinationCoordinator({ routingModel: dependencies.workHubRoutingModel ?? createJevRoutingModel({ stores: runtimePolicyStores }), @@ -2316,34 +2355,7 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, executions: coordinator, sessionActions: { - readDelegationRetirement: async (assignment, admission) => { - const disposition = admission - ? await messages.readMessageExecutionDispositionAdmitted( - assignment.targetSessionId, - assignment.targetMessageId, - admission, - ) - : await messages.readMessageExecutionDisposition( - assignment.targetSessionId, - assignment.targetMessageId, - ); - if (disposition.kind === 'recovering') return 'recovering'; - if (disposition.kind === 'pending') return 'not_retired'; - if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { - return 'retired'; - } - const identity = { - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }; - const latest = await coordinator.readLatestRootTurnLineage(identity); - if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; - // The same restart window as `stopOwnedWorkHubRoot`: an unregistered - // root is not evidence that its work ended. - const snapshot = await coordinator.read(latest); - return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; - }, + readDelegationRetirement: readWorkHubDelegationRetirement, // Resolve and resume only the execution lineage owned by this // delegation. A Session-wide latest-failure query could otherwise // continue unrelated work started directly in the same Session. @@ -2785,6 +2797,23 @@ export async function createExecutionRuntimeHostComposition( root: coordinator, messages, interactions, + workHub: { + readDelegationRetirement: (assignment, admission) => + readWorkHubDelegationRetirement(assignment, admission), + hasUndeliveredResult: async (assignment, admission) => { + if (!workHubResults || !assignment.returnResults) return false; + // Same observation delivery is keyed on; the transcript content is + // not needed to identify the result event. + const observed = await workHubResults.inspect(assignment, admission, false); + if (!observed) return false; + // Root admission is the delivery receipt (see startWorkHubResult). + const receipt = await stores.agentRunStore.readRootTurnAdmission( + WORKHUB_COORDINATION_SESSION_ID, + workHubResultOrigin(assignment, observed).eventId, + ); + return !receipt; + }, + }, goals: requireGoal(goal), scheduledTasks, resources: runtimeResources, diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 9c23a463040..8031b178d84 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -21,6 +21,7 @@ import { isWorkHubCoordinationSessionTarget, sessionRevisionFamilyId, type SessionHeader, + type WorkHubDelegationAssignedMessage, } from '@maka/core/session'; import type { SubagentWorkspaceBinding, @@ -67,12 +68,16 @@ import type { MemoryExtractionSessionLane } from './memory-extraction-session-la const FAMILY_STABILIZATION_ATTEMPTS = 4; +/** The store rejects a WorkHub target batch above its own bound; page below it. */ +const WORKHUB_ASSIGNMENT_TARGET_PAGE = 256; + type RetirementStores = Pick< ExecutionSessionWriter, | 'listHeaders' | 'probeSessionRemoval' | 'readCatalogRecord' | 'readHeaderRecordSnapshot' + | 'readActiveWorkHubAssignmentsByTarget' | 'reconcileOrphanedAgentGraphRetirements' | 'listPendingSessionRetirementCleanupIds' | 'completeSessionRetirementCleanup' @@ -115,6 +120,21 @@ export interface HostSessionRetirementCoordinatorOptions { readonly root: RetirementRoot; readonly messages: RetirementMessages; readonly interactions: RetirementInteractions; + /** + * WorkHub relationship reads shared with the coordination coordinator. The + * archive guard needs the delegation's execution state, not just its ledger + * row, and both must agree on what "still live" means. + */ + readonly workHub: { + readDelegationRetirement( + assignment: WorkHubDelegationAssignedMessage, + admission: SessionAdmissionLease, + ): Promise<'retired' | 'not_retired' | 'recovering'>; + hasUndeliveredResult( + assignment: WorkHubDelegationAssignedMessage, + admission: SessionAdmissionLease, + ): Promise; + }; readonly goals: RetirementGoals; readonly scheduledTasks: { beginSessionRetirement( @@ -195,6 +215,7 @@ export class HostSessionRetirementCoordinator { readonly #root: RetirementRoot; readonly #messages: RetirementMessages; readonly #interactions: RetirementInteractions; + readonly #workHub: HostSessionRetirementCoordinatorOptions['workHub']; readonly #goals: RetirementGoals; readonly #scheduledTasks: HostSessionRetirementCoordinatorOptions['scheduledTasks']; readonly #resources: RetirementResources; @@ -225,6 +246,7 @@ export class HostSessionRetirementCoordinator { this.#root = options.root; this.#messages = options.messages; this.#interactions = options.interactions; + this.#workHub = options.workHub; this.#goals = options.goals; this.#scheduledTasks = options.scheduledTasks; this.#resources = options.resources; @@ -289,6 +311,7 @@ export class HostSessionRetirementCoordinator { let handles: RetirementHandles | undefined; let committed = false; try { + await this.#assertNoLiveRelationships(family); handles = await this.#prepareRetirement(family, 'archive'); await this.#finalizeWorkspacePatches(family.sessionIds); await this.#disposeBackends(family.sessionIds); @@ -720,6 +743,71 @@ export class HostSessionRetirementCoordinator { ); } + /** + * A manual archive must not hide a Session that a WorkHub delegation or a + * linked child still points at: WorkHub candidate and result views exclude + * archived Sessions, so either relationship would lose its visible target. + * "Live" means the relationship is still in flight — the delegation's + * execution has not retired (or its result has not been delivered), or the + * child still carries active work. A finished delegation and a finished + * subtask hold nothing visible hostage, so they no longer block the archive. + * This guards the manual archive path only — a removal retires or archives + * the whole relationship set under its own confirm. + */ + async #assertNoLiveRelationships(family: StableFamily): Promise { + const familyIds = new Set(family.sessionIds); + for ( + let offset = 0; + offset < family.sessionIds.length; + offset += WORKHUB_ASSIGNMENT_TARGET_PAGE + ) { + const page = family.sessionIds.slice(offset, offset + WORKHUB_ASSIGNMENT_TARGET_PAGE); + // The ledger keeps every live delegation of a target, newest first, so a + // finished newest delegation must not hide an older one that is still in + // flight or still owes WorkHub its result. + const assignments = await this.#stores.readActiveWorkHubAssignmentsByTarget(page); + for (const assignment of assignments) { + // The Coordination ledger keeps a delegation row until it is superseded + // or stopped, so the row alone says nothing about the work: read the + // delegation's execution state the same way WorkHub actions do. + const retirement = await this.#workHub.readDelegationRetirement( + assignment, + family.admission, + ); + if (retirement !== 'retired') { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an active WorkHub delegation`, + ); + } + if (await this.#workHub.hasUndeliveredResult(assignment, family.admission)) { + throw new SessionRetirementBusyError( + `Session ${assignment.targetSessionId} has an undelivered WorkHub result`, + ); + } + } + } + for (const header of await this.#stores.listHeaders()) { + const parent = header.subagentParent; + if (parent === undefined || parent.graph !== undefined) continue; + if (!familyIds.has(parent.parentSessionId)) continue; + if (header.conversationCopy?.state === 'preparing' || header.isArchived) continue; + if (await this.#hasLiveChildWork(header.id)) { + throw new SessionRetirementBusyError( + `Session ${parent.parentSessionId} has a live linked child Session`, + ); + } + } + } + + /** The in-flight signals that make a linked child worth keeping visible. */ + async #hasLiveChildWork(sessionId: string): Promise { + return ( + this.#root.readRootState(sessionId).kind !== 'idle' || + this.#messages.hasLiveSessionState(sessionId) || + (await this.#interactions.hasPendingSession(sessionId)) + ); + } + async #prepareRetirement( family: StableFamily, kind: 'archive' | 'remove', diff --git a/packages/runtime-host/src/server/workhub-result-runtime.ts b/packages/runtime-host/src/server/workhub-result-runtime.ts index 6ae79a75aec..b87f3d70518 100644 --- a/packages/runtime-host/src/server/workhub-result-runtime.ts +++ b/packages/runtime-host/src/server/workhub-result-runtime.ts @@ -340,5 +340,7 @@ export function createWorkHubResultRuntime(options: { return { status: outcome.result.status, targetSessionId: assignment.targetSessionId }; }, }; - return { coordinator, tool, notify }; + // Exposed for the archive guard: the receipt read needs the same observation + // (and therefore the same event identity) delivery itself is keyed on. + return { coordinator, tool, notify, inspect }; }