diff --git a/apps/desktop/e2e/parent-session-deletion.spec.ts b/apps/desktop/e2e/parent-session-deletion.spec.ts index cdee277d19..629022dfe4 100644 --- a/apps/desktop/e2e/parent-session-deletion.spec.ts +++ b/apps/desktop/e2e/parent-session-deletion.spec.ts @@ -43,6 +43,10 @@ test('deleting a parent task archives its linked subagent task', async ({ name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`, }); await expect(confirm).toBeVisible(); + // The confirm warns that the linked subtask is kept and archived rather than + // destroyed, so the archived row that appears next is not a surprise. It names + // no count — the Host owns the exact number and reports it in the toast. + await expect(confirm.getByText(/子任务.*归档/)).toBeVisible(); await confirm.getByRole('button', { name: '删除', exact: true }).click(); await expect(parentRow).toHaveCount(0); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 40dfc58ecc..082b89dd08 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -309,7 +309,10 @@ test('abandons a remove whose task was restored under it', async () => { { kind: 'removed' }, ]); - assert.equal(await client.removeSession('session-1', { requireArchived: true }), 'restored'); + assert.deepEqual(await client.removeSession('session-1', { requireArchived: true }), { + disposition: 'restored', + archivedSubtaskCount: 0, + }); assert.deepEqual( requests.map(({ operation }) => operation), ['session.catalog.query', 'session.remove', 'session.catalog.query'], @@ -323,10 +326,14 @@ test('retries a remove through revision churn that left the task archived', asyn { kind: 'session', session: session('session-1', 4, { isArchived: true }) }, { kind: 'revision_conflict', expectedRevision: 4, actualRevision: 5 }, { kind: 'session', session: session('session-1', 5, { isArchived: true }) }, - { kind: 'removed' }, + // The Host reports what it archived; the client surfaces it verbatim. + { kind: 'removed', archivedSubtaskCount: 2 }, ]); - assert.equal(await client.removeSession('session-1', { requireArchived: true }), 'removed'); + assert.deepEqual(await client.removeSession('session-1', { requireArchived: true }), { + disposition: 'removed', + archivedSubtaskCount: 2, + }); assert.deepEqual( requests.filter(({ operation }) => operation === 'session.remove').map(({ input }) => input), [ @@ -344,7 +351,10 @@ test('removes a task that was never archived when no premise was stated', async { kind: 'removed' }, ]); - assert.equal(await client.removeSession('session-1'), 'removed'); + assert.deepEqual(await client.removeSession('session-1'), { + disposition: 'removed', + archivedSubtaskCount: 0, + }); }); test('rebuilds a Runtime Policy mutation from each fresh CAS projection', async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 887a852605..5d417d26a5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -293,13 +293,16 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn // A purge sweep asks for the task it saw archived. Restored under it, the // deletion is called off rather than replayed at the fresh revision (#3050). restoreUnderNextRemove = true; - assert.equal( + assert.deepEqual( await ipc.invoke('sessions:remove', 'session-ipc', { revisionFamily: true, requireArchived: true }), - 'restored', + { disposition: 'restored', archivedSubtaskCount: 0 }, ); assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, false); await ipc.invoke('sessions:archive', 'session-ipc'); - assert.equal(await ipc.invoke('sessions:remove', 'session-ipc'), 'removed'); + assert.deepEqual(await ipc.invoke('sessions:remove', 'session-ipc'), { + disposition: 'removed', + archivedSubtaskCount: 0, + }); assert.deepEqual(await ipc.invoke('sessions:list'), []); // Nothing was retired for the restored task: no `deleted` between the two // archives, and the renderer keeps everything it holds for it. diff --git a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts index 324065fe05..9fcef73f00 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts @@ -40,7 +40,15 @@ function summary(id: string, overrides: Partial = {}): SessionSu }; } -function createService(calls: string[]) { +function createService( + calls: string[], + opts: { + disposition?: 'removed' | 'restored'; + archivedSubtaskCount?: number; + preview?: { count?: number; throws?: boolean }; + } = {}, +) { + const { disposition = 'removed', archivedSubtaskCount = 0, preview = {} } = opts; return { list: async () => [], setFlagged: async (id: string, value: boolean, options: { revisionFamily: true }) => { @@ -60,7 +68,12 @@ function createService(calls: string[]) { options: { revisionFamily: true; requireArchived: boolean }, ) => { calls.push(`remove:${id}:${options.revisionFamily}:${options.requireArchived}`); - return 'removed' as const; + return { disposition, archivedSubtaskCount }; + }, + previewRemoval: async (id: string) => { + calls.push(`preview:${id}`); + if (preview.throws) throw new Error('preview failed'); + return preview.count ?? 0; }, }; } @@ -104,6 +117,9 @@ describe('revision-family session row actions', () => { 'flag:version:true:true', 'rename:branch:Independent branch:true', 'archive:version:true', + // The delete asks the Host how many subtasks it would archive before the + // confirm, then removes. + 'preview:root', // `root` is not archived, so the delete states no archived premise — // requiring one would refuse every delete from the rail. 'remove:root:true:false', @@ -112,3 +128,107 @@ describe('revision-family session row actions', () => { assert.deepEqual(cleared, ['root', 'version', 'root', 'version']); }); }); + +function deleteHarness( + sessions: readonly SessionSummary[], + disposition: 'removed' | 'restored' = 'removed', + archivedSubtaskCount = 0, + preview: { count?: number; throws?: boolean } = {}, +) { + const calls: string[] = []; + const confirms: Array<{ title: string; description: string }> = []; + const successes: Array<{ title: string; description?: string }> = []; + const actions = createSessionNavigationRowActions({ + uiLocale: 'en', + activeIdRef: { current: undefined }, + clearActiveMessages: () => undefined, + clearSessionRendererState: () => undefined, + pendingSessionRowActionsRef: { current: new Set() }, + refreshSessions: async () => [...sessions], + service: createService(calls, { disposition, archivedSubtaskCount, preview }), + sessionsRef: { current: [...sessions] }, + setActiveId: () => undefined, + toastApi: { + success: (title, description) => { successes.push({ title, description }); }, + error: () => undefined, + confirm: async (options) => { confirms.push({ title: options.title, description: options.description }); return true; }, + }, + }); + return { actions, calls, confirms, successes }; +} + +describe('delete confirm warns off the Host preview, toast reports the Host count', () => { + it('warns when the Host preview reports subtasks, and the toast reports the executed count', async () => { + const parent = summary('parent', { name: 'hi' }); + // The confirm warns off the Host preview (1); the toast reports the Host's + // executed count (2). Neither is a renderer estimate, and the two Host reads + // are independent — the confirm never leaks the executed number. + const { actions, calls, confirms, successes } = deleteHarness( + [parent], + 'removed', + 2, + { count: 1 }, + ); + + await actions.deleteSession('parent'); + + // Preview runs before the remove. + assert.deepEqual( + calls.filter((c) => c.startsWith('preview:') || c.startsWith('remove:')), + ['preview:parent', 'remove:parent:true:false'], + ); + assert.equal(confirms.length, 1); + assert.match(confirms[0].description, /kept and moved to Archived/); + assert.doesNotMatch(confirms[0].description, /\d/); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: '2 subtasks moved to Archived' }]); + }); + + it('shows no subtask note when the Host preview reports zero', async () => { + // e.g. a parent whose only children are graph operators: the renderer can't + // tell from its projection, but the Host preview says 0, so no false promise. + const { actions, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'removed', + 0, + { count: 0 }, + ); + + await actions.deleteSession('parent'); + + assert.equal(confirms.length, 1); + assert.doesNotMatch(confirms[0].description, /subtask/); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: undefined }]); + }); + + it('still deletes and drops the note when the preview call fails', async () => { + const { actions, calls, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'removed', + 0, + { throws: true }, + ); + + await actions.deleteSession('parent'); + + // The delete is not blocked by a preview failure. + assert.ok(calls.includes('remove:parent:true:false')); + assert.doesNotMatch(confirms[0].description, /subtask/); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: undefined }]); + }); + + it('stays silent on the toast when a concurrent restore calls the delete off', async () => { + const { actions, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'restored', + 0, + { count: 1 }, + ); + + await actions.deleteSession('parent'); + + // The confirm still warns — the person is deciding before the race resolves. + assert.match(confirms[0].description, /kept and moved to Archived/); + // But nothing was deleted, so nothing moved to the archive. + assert.deepEqual(successes, [{ title: 'hi was restored, so it was kept', description: undefined }]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 3281407947..870c9ce9a8 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -81,6 +81,11 @@ function installService( } = {}, ): SessionNavigationSessionService { return { + list: async () => { + harness.listCalls += 1; + if (!options.surviving) throw new Error('catalog unavailable'); + return [...options.surviving]; + }, setFlagged: async () => undefined, archive: async () => undefined, unarchive: async () => undefined, @@ -92,16 +97,14 @@ function installService( } if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); const target = options.catalog?.find((session) => session.id === id); - if (removeOptions.requireArchived && target && !target.isArchived) return 'restored'; + if (removeOptions.requireArchived && target && !target.isArchived) { + return { disposition: 'restored', archivedSubtaskCount: 0 }; + } harness.removed.push(id); options.onRemove?.(id); - return 'removed'; - }, - list: async () => { - harness.listCalls += 1; - if (!options.surviving) throw new Error('catalog unavailable'); - return [...options.surviving]; + return { disposition: 'removed', archivedSubtaskCount: 0 }; }, + previewRemoval: async () => 0, }; } diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ff9e34e11b..f2d98a6f9d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -151,6 +151,17 @@ export type DesktopSessionConfigurationPatch = Partial; */ export type SessionRemoveDisposition = "removed" | "restored"; +/** + * How a remove settled together with what it archived. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the archive + * — 0 when the delete was called off (`restored`) or archived nothing — so the + * renderer's toast reports a fact rather than a renderer-side estimate. + */ +export interface SessionRemoveOutcome { + readonly disposition: SessionRemoveDisposition; + readonly archivedSubtaskCount: number; +} + export type DesktopRuntimeHostClientErrorCode = | "catalog_unstable" | "client_closed" @@ -957,19 +968,34 @@ export class DesktopRuntimeHostClient { async removeSession( sessionId: string, options: { requireArchived?: boolean } = {}, - ): Promise { + ): Promise { for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); - if (options.requireArchived && !current.isArchived) return "restored"; + if (options.requireArchived && !current.isArchived) { + return { disposition: "restored", archivedSubtaskCount: 0 }; + } const result = await this.request("session.remove", { sessionId, expectedRevision: current.revision, }); - if (result.kind === "removed") return "removed"; + if (result.kind === "removed") { + return { disposition: "removed", archivedSubtaskCount: result.archivedSubtaskCount ?? 0 }; + } } throw revisionConflict("remove", sessionId); } + /** + * How many linked subtasks a delete of this parent would move to the archive, + * per the Host's own removal plan. The delete confirm warns off this so the + * renderer never re-derives the plan from a catalog projection that omits the + * operator marker and copy state. + */ + async previewSessionRemoval(sessionId: string): Promise { + const result = await this.request("session.remove.preview", { sessionId }); + return result.archivableSubtaskCount; + } + async removeSessionCopy(sessionId: string): Promise<'removed' | 'retained'> { try { const current = await this.#requireSession(sessionId); 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 c2ccd258d8..a3d5a9b413 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 @@ -51,6 +51,7 @@ type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, | 'createSession' | 'listSessions' + | 'previewSessionRemoval' | 'removeSession' | 'setSessionLifecycle' | 'updateSessionConfiguration' @@ -213,11 +214,15 @@ export function registerRuntimeHostSessionCatalogIpc( const ids = await actionIds(sessionId, { revisionFamily: true }); // A task restored under the caller's decision is left alone, and nothing // downstream of the deletion runs for it. - const disposition = await deps.client.removeSession(sessionId, { + const outcome = await deps.client.removeSession(sessionId, { requireArchived: requiresArchivedSession(options), }); - if (disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); - return disposition; + if (outcome.disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); + return outcome; + }); + ipcMain.handle('sessions:removePreview', async (_event, sessionId: string) => { + // Read-only: how many subtasks the delete would archive, for the confirm. + return deps.client.previewSessionRemoval(sessionId); }); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 81e5862974..739c0276c1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -961,12 +961,20 @@ export interface MakaBridge { setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; /** * `requireArchived` holds the caller's premise through the deletion: a task - * restored meanwhile answers `restored` and is kept. + * restored meanwhile answers `restored` and is kept. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the + * archive — 0 when restored or when nothing was archived. */ remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'>; + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }>; + /** + * How many linked subtasks a delete of this parent would move to the + * archive, per the Host's removal plan. The confirm warns off this instead + * of estimating from the catalog projection. + */ + previewRemoval(sessionId: string): Promise; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d6d68036f1..de69f3ae30 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1961,9 +1961,12 @@ const makaBridge = { remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'> { + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }> { return invokeSessionRuntimeHost('sessions:remove', sessionId, options); }, + previewRemoval(sessionId: string): Promise { + return invokeSessionRuntimeHost('sessions:removePreview', sessionId); + }, cleanupSessionCopy(sessionId: string): Promise { return invokeSessionRuntimeHost('sessions:cleanupSessionCopy', sessionId); }, 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 aca2d257f1..b960385e9a 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 @@ -28,6 +28,16 @@ type RefBox = { current: T }; /** What `sessions.remove` settled on. `restored` means the task is still there. */ type SessionRemoveDisposition = 'removed' | 'restored'; +/** + * How a delete settled together with the count the Host actually archived. + * `archivedSubtaskCount` is the Host's executed number — 0 when the delete was + * called off (`restored`) — so the toast reports a fact, not a renderer guess. + */ +type SessionRemoveOutcome = { + disposition: SessionRemoveDisposition; + archivedSubtaskCount: number; +}; + type ToastApi = { success(title: string, description?: string): void; error( @@ -164,9 +174,24 @@ export function createSessionNavigationRowActions(deps: { return runSessionRowAction(sessionId, 'delete', copy.deleteFailedTitle, async () => { const session = sessionsRef.current.find((entry) => entry.id === sessionId); const name = session?.name ?? copy.currentConversation; + // Ask the Host how many subtasks the delete would archive. It owns the + // removal plan; the renderer's catalog projection lacks the operator + // marker and copy state, so a renderer estimate would over-promise (e.g. + // claim archival for a parent whose only children are graph operators). + // A preview failure just drops the note — the delete still proceeds and + // the toast reports the real count afterwards. + let previewSubtaskCount = 0; + try { + previewSubtaskCount = await service.previewRemoval(sessionId); + } catch { + previewSubtaskCount = 0; + } const ok = await toastApi.confirm({ title: copy.deleteTitle(name), - description: copy.deleteDescription, + description: + previewSubtaskCount > 0 + ? `${copy.deleteDescription} ${copy.deleteSubtaskNote()}` + : copy.deleteDescription, confirmLabel: copy.deleteLabel, cancelLabel: copy.cancelLabel, destructive: true, @@ -174,12 +199,18 @@ export function createSessionNavigationRowActions(deps: { if (!ok) return; // The confirm named an archived task, so a restore revokes it. An active // task has no such premise to lose. - const disposition = await removeSessionFamily(sessionId, { + const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { requireArchived: session?.isArchived === true, }); await refreshSessions(); + // `restored` means nothing was deleted, so no subtask moved either. On a + // real delete the count is the Host's executed number, not an estimate. if (disposition === 'restored') toastApi.success(copy.deleteRestoredTitle(name)); - else toastApi.success(copy.deletedTitle(name)); + else + toastApi.success( + copy.deletedTitle(name), + archivedSubtaskCount > 0 ? copy.deletedSubtaskNote(archivedSubtaskCount) : undefined, + ); }); } @@ -193,21 +224,21 @@ export function createSessionNavigationRowActions(deps: { async function removeSessionFamily( sessionId: string, options: { requireArchived: boolean }, - ): Promise { + ): Promise { // Read before the write: the family comes off the live catalog, which no // longer lists it afterwards. const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - const disposition = await service.remove(sessionId, { + const outcome = await service.remove(sessionId, { revisionFamily: true, requireArchived: options.requireArchived, }); - if (disposition === 'restored') return disposition; + if (outcome.disposition === 'restored') return outcome; if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { setActiveId(undefined); clearActiveMessages(); } for (const id of familyIds) clearSessionRendererState(id); - return disposition; + return outcome; } /** @@ -251,7 +282,7 @@ export function createSessionNavigationRowActions(deps: { } pendingSessionRowActionsRef.current.add(key); try { - const disposition = await removeSessionFamily(sessionId, { requireArchived: true }); + const { disposition } = await removeSessionFamily(sessionId, { requireArchived: true }); if (disposition === 'restored') restored.push(sessionId); else removed += 1; } catch (error) { diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index 60c45db690..1bd6d1497d 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -21,6 +21,16 @@ import type { SessionSummary } from '@maka/core/session'; export type SessionNavigationRemoveDisposition = 'removed' | 'restored'; +/** + * How a delete settled together with the count the Host actually archived. + * `archivedSubtaskCount` is the Host's executed number — 0 when the delete was + * called off (`restored`) — so the toast reports a fact, not a renderer guess. + */ +export interface SessionNavigationRemoveOutcome { + readonly disposition: SessionNavigationRemoveDisposition; + readonly archivedSubtaskCount: number; +} + export interface SessionNavigationSession extends SessionSummary { readonly profileId: string; readonly profileName: string; @@ -51,7 +61,13 @@ export interface SessionNavigationSessionService { remove( sessionId: string, options: { revisionFamily: true; requireArchived: boolean }, - ): Promise; + ): Promise; + /** + * How many linked subtasks a delete of this parent would move to the archive, + * per the Host's removal plan. The delete confirm warns off this instead of + * estimating from the catalog projection. + */ + previewRemoval(sessionId: string): Promise; } export interface SessionNavigationServices { diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index f39ec85855..2e320ccf31 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -52,7 +52,8 @@ export function createFakeSessionNavigationServices( archive: async () => undefined, unarchive: async () => undefined, rename: async () => undefined, - remove: async () => 'removed', + remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), + previewRemoval: async () => 0, }, ...overrides, }; diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index a858a39464..9f8f8513c7 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 = { deletedTitle(name: string): string; /** The task was restored elsewhere, so the delete was called off. */ deleteRestoredTitle(name: string): string; + /** Appended to the delete confirm when the task has linked subagent subtasks. */ + deleteSubtaskNote(): string; + /** Toast description after deleting a task that had linked subagent subtasks. */ + deletedSubtaskNote(count: number): string; }; skillActions: { refreshSkillsFailedTitle: string; @@ -892,6 +896,8 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: '取消', deletedTitle: (name: string) => `已删除 ${name}`, deleteRestoredTitle: (name: string) => `${name} 已被恢复,未删除`, + deleteSubtaskNote: () => '其链接的子任务不会被删除,将保留并移入归档。', + deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1417,6 +1423,9 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: 'Cancel', deletedTitle: (name: string) => `Deleted ${name}`, deleteRestoredTitle: (name: string) => `${name} was restored, so it was kept`, + deleteSubtaskNote: () => 'Its linked subtasks will be kept and moved to Archived.', + deletedSubtaskNote: (count: number) => + count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, }, skillActions: { refreshSkillsFailedTitle: 'Could not refresh Skills', diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts index de49032287..241c0cd656 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts @@ -39,6 +39,7 @@ export function createDesktopSessionNavigationServices( bridge.sessions.rename(sessionId, name, options), remove: (sessionId, options) => bridge.sessions.remove(sessionId, options), + previewRemoval: (sessionId) => bridge.sessions.previewRemoval(sessionId), }, }; } 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 4edfcf2188..a75df6913c 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -199,6 +199,14 @@ describe('Host Session retirement coordinator', () => { } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); + // The read-only preview reports the same deduped count the confirm warns + // off, before the delete executes. + const preview = await harness.coordinator.handlers['session.remove.preview']( + { sessionId: harness.revisionId }, + CONNECTION_CONTEXT, + ); + assert.deepEqual(preview, { ok: true, result: { archivableSubtaskCount: 32 } }); + const removed = await harness.coordinator.handlers['session.remove']( { sessionId: harness.revisionId, expectedRevision: target.revision }, CONNECTION_CONTEXT, @@ -206,7 +214,9 @@ describe('Host Session retirement coordinator', () => { assert.deepEqual(removed, { ok: true, - result: { kind: 'removed', sessionId: harness.revisionId }, + // Each of the 32 subagent children is a distinct subtask family, so the + // executed count the renderer reports is 32. + result: { kind: 'removed', sessionId: harness.revisionId, archivedSubtaskCount: 32 }, }); for (const sessionId of harness.familyIds) { assert.deepEqual(await harness.store.probeSessionRemoval(sessionId), { kind: 'removed' }); @@ -394,6 +404,13 @@ describe('Host Session retirement coordinator', () => { } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); + // Graph operators retire with the root rather than archive, so the delete + // preview promises nothing — the renderer must not warn about them. + const preview = await harness.coordinator.handlers['session.remove.preview']( + { sessionId: harness.revisionId }, + CONNECTION_CONTEXT, + ); + assert.deepEqual(preview, { ok: true, result: { archivableSubtaskCount: 0 } }); const removed = await harness.coordinator.handlers['session.remove']( { sessionId: harness.revisionId, expectedRevision: target.revision }, CONNECTION_CONTEXT, diff --git a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts index ac131e064a..49dabb00bb 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -108,6 +108,69 @@ describe('Session retirement protocol', () => { }, ); }); + + test('carries the archived-subtask count on a removed result and rejects a malformed one', () => { + const withCount = { + requestId: 'request-remove', + operation: 'session.remove' as const, + ok: true as const, + result: { kind: 'removed' as const, sessionId: 'session-1', archivedSubtaskCount: 3 }, + }; + assert.deepEqual(decodeHostFrame(withCount), withCount); + // Absent when nothing was archived — the common delete keeps its old shape. + const withoutCount = { + requestId: 'request-remove', + operation: 'session.remove' as const, + ok: true as const, + result: { kind: 'removed' as const, sessionId: 'session-1' }, + }; + assert.deepEqual(decodeHostFrame(withoutCount), withoutCount); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-remove', + operation: 'session.remove', + ok: true, + result: { kind: 'removed', sessionId: 'session-1', archivedSubtaskCount: -1 }, + }), + isInvalidFrame, + ); + }); + + test('round-trips the removal preview query and rejects a malformed count', () => { + const request = { + requestId: 'request-preview', + operation: 'session.remove.preview' as const, + input: { sessionId: 'session-1' }, + }; + assert.deepEqual(decodeClientFrame(request), request); + const response = { + requestId: 'request-preview', + operation: 'session.remove.preview' as const, + ok: true as const, + result: { archivableSubtaskCount: 4 }, + }; + assert.deepEqual(decodeHostFrame(response), response); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-preview', + operation: 'session.remove.preview', + ok: true, + result: { archivableSubtaskCount: -1 }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-preview', + operation: 'session.remove.preview', + input: { sessionId: 'session-1', expectedRevision: 2 }, + }), + isInvalidFrame, + ); + }); }); function projection(overrides: Partial = {}): SessionCatalogProjection { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..94341acf87 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: Session removal reports how many linked subtasks it archived, and adds a +// `session.remove.preview` query for that count before the delete. Older peers +// reject the extra removed-result field and the unknown operation. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..1ac10fa30f 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -301,6 +301,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.read_marker.set', 'session.recap.generate', 'session.remove', + 'session.remove.preview', 'session.revision.abandon', 'session.revision.create', 'session.transcript.page', diff --git a/packages/runtime-host/src/protocol/session-retirement.ts b/packages/runtime-host/src/protocol/session-retirement.ts index 5ffb9b6105..0f601e9fe5 100644 --- a/packages/runtime-host/src/protocol/session-retirement.ts +++ b/packages/runtime-host/src/protocol/session-retirement.ts @@ -18,7 +18,13 @@ */ import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js'; -import { requireEntityId, requireExactRecord, requireRecord } from './codec.js'; +import { + requireCount, + requireEntityId, + requireExactRecord, + requireRecord, + requireShapedRecord, +} from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; @@ -46,8 +52,32 @@ export interface SessionRemoveInput { readonly expectedRevision: number; } +export interface SessionRemovePreviewInput { + readonly sessionId: string; +} + +export interface SessionRemovePreviewResult { + /** + * How many ordinary linked subagent subtasks a delete of this parent would + * move to the archive rather than destroy, deduplicated by revision family. + * The Host owns the removal plan, so the confirm warns off this rather than + * re-deriving it from a catalog projection that lacks the operator marker. + */ + readonly archivableSubtaskCount: number; +} + export type SessionRemoveResult = - | { readonly kind: 'removed'; readonly sessionId: string } + | { + readonly kind: 'removed'; + readonly sessionId: string; + /** + * How many ordinary linked subagent subtasks this removal moved to the + * archive rather than destroyed, deduplicated by revision family. Absent + * when it archived none — the common case. This is the Host's executed + * count, so the renderer reports it verbatim instead of estimating. + */ + readonly archivedSubtaskCount?: number; + } | { readonly kind: 'revision_conflict'; readonly expectedRevision: number; @@ -98,6 +128,17 @@ export const SESSION_RETIREMENT_OPERATION_SPECS = { } }, }), + 'session.remove.preview': defineOperation< + SessionRemovePreviewInput, + SessionRemovePreviewResult, + (typeof LIFECYCLE_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: LIFECYCLE_ERRORS, + decodeInput: decodeSessionRemovePreviewInput, + decodeOutput: decodeSessionRemovePreviewResult, + }), } as const; export function decodeSessionLifecycleSetInput(value: unknown): SessionLifecycleSetInput { @@ -122,11 +163,38 @@ export function decodeSessionRemoveInput(value: unknown): SessionRemoveInput { }; } +export function decodeSessionRemovePreviewInput(value: unknown): SessionRemovePreviewInput { + const input = requireExactRecord(value, 'Session remove preview input', ['sessionId']); + return { sessionId: requireEntityId(input.sessionId, 'sessionId') }; +} + +export function decodeSessionRemovePreviewResult(value: unknown): SessionRemovePreviewResult { + const result = requireExactRecord(value, 'Session remove preview result', [ + 'archivableSubtaskCount', + ]); + return { + archivableSubtaskCount: requireCount(result.archivableSubtaskCount, 'archivableSubtaskCount'), + }; +} + export function decodeSessionRemoveResult(value: unknown): SessionRemoveResult { const result = requireRecord(value, 'Session remove result'); if (result.kind === 'removed') { - const exact = requireExactRecord(result, 'Removed Session result', ['kind', 'sessionId']); - return { kind: 'removed', sessionId: requireEntityId(exact.sessionId, 'sessionId') }; + const exact = requireShapedRecord( + result, + 'Removed Session result', + ['kind', 'sessionId'], + ['archivedSubtaskCount'], + ); + return { + kind: 'removed', + sessionId: requireEntityId(exact.sessionId, 'sessionId'), + ...(exact.archivedSubtaskCount === undefined + ? {} + : { + archivedSubtaskCount: requireCount(exact.archivedSubtaskCount, 'archivedSubtaskCount'), + }), + }; } if (result.kind !== 'revision_conflict') { throw invalidProtocolFrame('Invalid Session remove result kind'); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 208befbb28..b249aff0a1 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -112,7 +112,7 @@ export type SessionRevisionOperationKey = Extract< >; export type SessionRetirementOperationKey = Extract< OperationKey, - 'session.lifecycle.set' | 'session.remove' + 'session.lifecycle.set' | 'session.remove' | 'session.remove.preview' >; export type SessionEffectOperationKey = Extract; export type SessionCatalogOperationKey = Exclude< diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 8d9e713a78..aa3d13b5fb 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -37,6 +37,7 @@ import { type SessionCatalogItem, type SessionLifecycleSetInput, type SessionRemoveInput, + type SessionRemovePreviewInput, type SessionRemoveResult, } from '../protocol/index.js'; import { @@ -173,6 +174,7 @@ export class HostSessionRetirementCoordinator { readonly handlers: SessionRetirementOperationHandlerMap = { 'session.lifecycle.set': (input) => this.#setLifecycle(input), 'session.remove': (input) => this.#remove(input), + 'session.remove.preview': (input) => this.#previewRemoval(input), }; readonly #stores: RetirementStores; @@ -354,7 +356,15 @@ export class HostSessionRetirementCoordinator { this.#messages.retireSessions(allSessionIds); await this.#continuity.retireSessions(plan.remove.sessionIds, plan.remove.admission); await this.#refreshFamily(plan.archive); - return removeSuccess(input.sessionId); + // What the confirm warned about, now executed: the distinct subtasks + // (deduplicated by revision family) this removal moved to the archive. + // The renderer reports this verbatim rather than re-deriving the plan. + const archivedSubtaskCount = new Set( + plan.archive.sessionIds.map((id) => + sessionRevisionFamilyId(requireFamilyRecord(plan.archive, id).header), + ), + ).size; + return removeSuccess(input.sessionId, archivedSubtaskCount); } catch (error) { if (committed) return this.#uncertainRemove(); archiveHandles?.goal.rollback(); @@ -369,11 +379,50 @@ export class HostSessionRetirementCoordinator { } } + /** + * Read-only preview of how many subtasks a delete of this parent would move + * to the archive — the confirm warns off this so the renderer never has to + * re-derive the plan from a catalog projection that lacks the operator marker + * and the copy state. Absent or already-removed targets, and Agent Graph + * operators (which retire with their root rather than archive), preview zero. + */ + async #previewRemoval( + input: SessionRemovePreviewInput, + ): Promise> { + let probe; + try { + probe = await this.#stores.probeSessionRemoval(input.sessionId); + } catch { + return previewFailure('persistence_failed', 'Session removal state is unavailable'); + } + if (probe.kind !== 'present') return previewSuccess(0); + try { + const plan = await this.#readRemovalPlanSessionIds(input.sessionId); + return previewSuccess(plan.archivableSubtaskCount); + } catch (error) { + // A graph operator has no independent delete and archives nothing; a + // target that vanished mid-read has nothing left to archive either. + if ( + error instanceof SessionMetadataConflictError || + error instanceof SessionRetirementMissingSessionError + ) { + return previewSuccess(0); + } + return previewFailure('persistence_failed', 'Session removal plan is unavailable'); + } + } + async #withStableRemovalPlan( sessionId: string, operation: (plan: StableRemovalPlan) => Promise, ): Promise { - let planIds = await this.#readRemovalPlanSessionIds(sessionId); + // Only the id sets stabilize here; the archivable-subtask count is a + // preview-only read, so it is intentionally not threaded through the retry. + let planIds: { + removeSessionIds: readonly string[]; + archiveSessionIds: readonly string[]; + archiveGuardSessionIds: readonly string[]; + } = await this.#readRemovalPlanSessionIds(sessionId); for (let attempt = 0; attempt < FAMILY_STABILIZATION_ATTEMPTS; attempt += 1) { const allSessionIds = [ ...planIds.removeSessionIds, @@ -501,6 +550,7 @@ export class HostSessionRetirementCoordinator { removeSessionIds: readonly string[]; archiveSessionIds: readonly string[]; archiveGuardSessionIds: readonly string[]; + archivableSubtaskCount: number; }> { const removeSessionIds = await this.#readFamilySessionIds(sessionId); const removeIds = new Set(removeSessionIds); @@ -527,9 +577,8 @@ export class HostSessionRetirementCoordinator { !removeIds.has(header.id) && childFamilyIds.has(sessionRevisionFamilyId(header)), ); - const archiveSessionIds = childSessionHeaders - .filter((header) => !header.isArchived) - .map((header) => header.id); + const archiveHeaders = childSessionHeaders.filter((header) => !header.isArchived); + const archiveSessionIds = archiveHeaders.map((header) => header.id); const archiveGuardSessionIds = childSessionHeaders .filter((header) => header.isArchived) .map((header) => header.id); @@ -537,6 +586,10 @@ export class HostSessionRetirementCoordinator { removeSessionIds: [...removeIds].sort(), archiveSessionIds: [...new Set(archiveSessionIds)].sort(), archiveGuardSessionIds: [...new Set(archiveGuardSessionIds)].sort(), + // Distinct subtasks (by revision family) that a delete would move to the + // archive — the count the confirm warns off, matching what `#remove` + // reports afterwards. + archivableSubtaskCount: new Set(archiveHeaders.map(sessionRevisionFamilyId)).size, }; } @@ -829,8 +882,15 @@ function lifecycleFailure( return { ok: false, error: { code, message } }; } -function removeSuccess(sessionId: string): OperationOutcome<'session.remove'> { - return removeOutcome({ kind: 'removed', sessionId }); +function removeSuccess( + sessionId: string, + archivedSubtaskCount = 0, +): OperationOutcome<'session.remove'> { + return removeOutcome( + archivedSubtaskCount > 0 + ? { kind: 'removed', sessionId, archivedSubtaskCount } + : { kind: 'removed', sessionId }, + ); } function removeOutcome(result: SessionRemoveResult): OperationOutcome<'session.remove'> { @@ -843,3 +903,16 @@ function removeFailure( ): Extract, { ok: false }> { return { ok: false, error: { code, message } }; } + +function previewSuccess( + archivableSubtaskCount: number, +): OperationOutcome<'session.remove.preview'> { + return { ok: true, result: { archivableSubtaskCount } }; +} + +function previewFailure( + code: Extract, { ok: false }>['error']['code'], + message: string, +): Extract, { ok: false }> { + return { ok: false, error: { code, message } }; +}