From de5190423c8002563b080a7426a8a8d6c3d9d999 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Sun, 30 Aug 2026 01:35:18 +0800 Subject: [PATCH 1/2] feat(desktop): warn that deleting a parent keeps and archives its subtasks Deleting a parent task permanently removes it, but its ordinary linked subagent subtasks are intentionally kept and moved to the archive rather than cascade-deleted (#1467 / #3151). The confirm said nothing and the completion gave no feedback, so the archived rows that appeared next read as tasks from nowhere. The delete confirm now warns, when the task has linked subtasks, that they are kept and moved to Archived; the completion toast reports how many moved. Both counts are owned by the Host, not estimated by the renderer, whose catalog projection carries linked children as `subagent: { parentSessionId }` with no operator marker or copy state: - `session.remove` returns `archivedSubtaskCount` (deduplicated by revision family), which the toast reports verbatim. - A read-only `session.remove.preview` query returns how many subtasks a delete would archive; the confirm warns off it. Graph operators (retired with the parent), already-archived children, copies mid-preparation, and absent targets all preview zero. On preview failure the confirm falls back to an uncertain note rather than hiding the warning; the delete still proceeds. - Bulk purge sums the executed counts into SessionPurgeOutcome.archivedSubtasks; the archived-tasks purge confirm warns and its toast reports how many moved. Copy lives in shell-copy.ts / settings-tasks-copy.ts (zh + en). Deletion semantics are unchanged. Bumps RUNTIME_HOST_COMPATIBILITY_EPOCH for the new removed-result field and the new query. Fixes #3780 Generated-by: Claude Code --- .../e2e/parent-session-deletion.spec.ts | 4 + .../runtime-host-client-operations.test.ts | 18 ++- .../__tests__/runtime-host-client-uds.test.ts | 9 +- ...n-navigation-row-actions-revisions.test.ts | 126 +++++++++++++++++- .../session-navigation-session-purge.test.ts | 34 ++++- apps/desktop/src/main/runtime-host-client.ts | 32 ++++- .../runtime-host-session-catalog-ipc-main.ts | 11 +- apps/desktop/src/preload/bridge-contract.d.ts | 12 +- apps/desktop/src/preload/preload.ts | 5 +- .../controller/session-row-actions.ts | 70 ++++++++-- .../features/session-navigation/ports.ts | 18 ++- .../features/session-navigation/testing.ts | 3 +- .../renderer/locales/settings-tasks-copy.ts | 9 ++ .../src/renderer/locales/shell-copy.ts | 14 ++ .../create-session-navigation-services.ts | 1 + .../renderer/settings/tasks-settings-page.tsx | 14 +- .../settings/settings-pages.stories.tsx | 1 + .../session-retirement-coordinator.test.ts | 19 ++- .../session-retirement-protocol.test.ts | 63 +++++++++ packages/runtime-host/src/protocol/index.ts | 5 +- .../runtime-host/src/protocol/operations.ts | 1 + .../src/protocol/session-retirement.ts | 76 ++++++++++- .../src/server/operation-dispatcher.ts | 2 +- .../server/session-retirement-coordinator.ts | 87 +++++++++++- 24 files changed, 581 insertions(+), 53 deletions(-) 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 7c20c3d9c5..d3547e6658 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 @@ -332,7 +332,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'], @@ -346,10 +349,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), [ @@ -367,7 +374,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 0eaf7775b2..0f6909423e 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 @@ -311,13 +311,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..7e7a59f9ef 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,109 @@ 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('warns with uncertainty and still deletes when the preview call fails', async () => { + const { actions, calls, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'removed', + 0, + { throws: true }, + ); + + await actions.deleteSession('parent'); + + // Fail-open would hide the warning; instead the confirm hedges so it never + // silently omits that subtasks may survive. + assert.match(confirms[0].description, /if any.*kept and moved to Archived/); + // The delete is not blocked by a preview failure. + assert.ok(calls.includes('remove:parent:true:false')); + 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..44f863b727 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 @@ -78,9 +78,16 @@ function installService( * against whatever the renderer last saw. */ catalog?: readonly SessionSummary[]; + /** Subtasks the Host archives per removed id, summed into the outcome. */ + archivedByRemoval?: Record; } = {}, ): 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 +99,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: options.archivedByRemoval?.[id] ?? 0 }; }, + previewRemoval: async () => 0, }; } @@ -166,6 +171,7 @@ describe('purgeSessions', () => { assert.deepEqual(h.removed, ['a-v2', 'b']); assert.deepEqual(outcome, { removed: 2, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, @@ -184,6 +190,20 @@ describe('purgeSessions', () => { assert.equal(h.listCalls, 0); }); + it('sums the linked subtasks the Host archived across the sweep', async () => { + const h = harness(); + const sessions = [summary('p1'), summary('p2'), summary('p3')]; + const activeIdRef = { current: undefined as string | undefined }; + // p1 archives 2 subtasks, p3 archives 1; p2 archives none. + const service = installService(h, { archivedByRemoval: { p1: 2, p3: 1 } }); + const actions = createActions({ harness: h, sessions, activeIdRef, service }); + + const outcome = await actions.purgeSessions(['p1', 'p2', 'p3']); + + assert.equal(outcome.removed, 3); + assert.equal(outcome.archivedSubtasks, 3); + }); + it('reports a task restored before the sweep reached it, rather than dropping it', async () => { // The confirm named a set. One restored from another surface while the // dialog was up has left it, and a sweep that deleted it anyway would be diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index c95f8ac0fa..62f760c087 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -165,6 +165,17 @@ export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; */ 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" @@ -1020,19 +1031,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 4abdaa5ff7..cda8a0fb38 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 @@ -52,6 +52,7 @@ type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, | 'createSession' | 'listSessions' + | 'previewSessionRemoval' | 'removeSession' | 'setSessionLifecycle' | 'updateSessionConfiguration' @@ -219,11 +220,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 f8fbf7dcc3..6250ab5bf6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1220,12 +1220,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 49227d46e9..3e9beb6133 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2218,9 +2218,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..6b13529764 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( @@ -52,6 +62,12 @@ type ToastApi = { export interface SessionPurgeOutcome { /** Tasks confirmed gone. */ removed: number; + /** + * Linked subtasks the Host moved to the archive across the sweep, summed from + * each removal's executed count. Reported so a bulk purge does not silently + * archive active subtasks. + */ + archivedSubtasks: number; /** Tasks the catalog still reports. Empty when `verified` is false. */ remaining: string[]; /** @@ -164,9 +180,30 @@ 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 is not silence: fall back to an uncertain warning so + // the confirm never hides that subtasks may survive. The toast still + // reports the real executed count afterwards. + let previewSubtaskCount: number | undefined; + try { + previewSubtaskCount = await service.previewRemoval(sessionId); + } catch { + previewSubtaskCount = undefined; + } + const subtaskNote = + previewSubtaskCount === undefined + ? copy.deleteSubtaskNoteUncertain() + : previewSubtaskCount > 0 + ? copy.deleteSubtaskNote() + : undefined; const ok = await toastApi.confirm({ title: copy.deleteTitle(name), - description: copy.deleteDescription, + description: subtaskNote + ? `${copy.deleteDescription} ${subtaskNote}` + : copy.deleteDescription, confirmLabel: copy.deleteLabel, cancelLabel: copy.cancelLabel, destructive: true, @@ -174,12 +211,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 +236,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; } /** @@ -239,6 +282,7 @@ export function createSessionNavigationRowActions(deps: { const restored: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; let removed = 0; + let archivedSubtasks = 0; for (const sessionId of sessionIds) { const key = `${sessionId}:delete`; if ( @@ -251,9 +295,14 @@ export function createSessionNavigationRowActions(deps: { } pendingSessionRowActionsRef.current.add(key); try { - const disposition = await removeSessionFamily(sessionId, { requireArchived: true }); + const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { + requireArchived: true, + }); if (disposition === 'restored') restored.push(sessionId); - else removed += 1; + else { + removed += 1; + archivedSubtasks += archivedSubtaskCount; + } } catch (error) { unsettled.push(sessionId); firstFailure ??= { error, sessionId }; @@ -265,6 +314,7 @@ export function createSessionNavigationRowActions(deps: { await refreshSessions(); return { removed, + archivedSubtasks, remaining: [], restored, verified: true, @@ -281,6 +331,7 @@ export function createSessionNavigationRowActions(deps: { if (!listed) { return { removed, + archivedSubtasks, remaining: [], restored, verified: false, @@ -291,6 +342,7 @@ export function createSessionNavigationRowActions(deps: { const remaining = unsettled.filter((sessionId) => present.has(sessionId)); return { removed: removed + (unsettled.length - remaining.length), + archivedSubtasks, remaining, restored, verified: true, diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index 19cdcfaf47..0b90f29adc 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -22,6 +22,16 @@ import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; 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; @@ -52,7 +62,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 0995524bc8..283e209bcc 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -59,7 +59,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/settings-tasks-copy.ts b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts index cc7ac4e296..b0917f10bd 100644 --- a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts @@ -34,8 +34,12 @@ export type SettingsTasksCopy = { purgeAllConfirmTitle(count: number): string; purgeMatchesConfirmTitle(count: number): string; purgeConfirmBody: string; + /** Appended to the purge confirm: a bulk delete keeps linked subtasks. */ + purgeSubtaskNote: string; purgeConfirmAction: string; purgedToast(count: number): string; + /** Toast suffix after a purge that moved linked subtasks to the archive. */ + purgedSubtaskNote(count: number): string; /** * Tasks a sweep kept because they were restored while it ran. Reads after * either outcome, so a sweep never has to choose between reporting a failure @@ -66,8 +70,10 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { purgeAllConfirmTitle: (count: number) => `清空全部 ${count} 条已归档任务?`, purgeMatchesConfirmTitle: (count: number) => `删除搜索到的 ${count} 条任务?`, purgeConfirmBody: '这些任务及其全部消息会被永久删除,无法撤销。', + purgeSubtaskNote: '其关联的子任务不会被删除,将保留并移入归档。', purgeConfirmAction: '永久删除', purgedToast: (count: number) => `已删除 ${count} 条任务`, + purgedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, purgeKeptRestored: (count: number) => `另有 ${count} 条在此期间被恢复,已保留。`, purgeFailedTitle: '删除任务失败', purgeFailedBody: (count: number) => `${count} 条仍在,请重试。`, @@ -94,8 +100,11 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { count === 1 ? 'Delete the 1 task you searched for?' : `Delete the ${count} tasks you searched for?`, purgeConfirmBody: 'The tasks and all of their messages are removed permanently. This cannot be undone.', + purgeSubtaskNote: 'Any linked subtasks are kept and moved to Archived.', purgeConfirmAction: 'Delete permanently', purgedToast: (count: number) => (count === 1 ? 'Deleted 1 task' : `Deleted ${count} tasks`), + purgedSubtaskNote: (count: number) => + count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, purgeKeptRestored: (count: number) => count === 1 ? '1 more was restored meanwhile and kept.' diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 855143e079..fb622d5fe5 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -268,6 +268,12 @@ 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; + /** Appended to the delete confirm when the subtask preview could not be read. */ + deleteSubtaskNoteUncertain(): string; + /** Toast description after deleting a task that had linked subagent subtasks. */ + deletedSubtaskNote(count: number): string; }; skillActions: { refreshSkillsFailedTitle: string; @@ -893,6 +899,9 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: '取消', deletedTitle: (name: string) => `已删除 ${name}`, deleteRestoredTitle: (name: string) => `${name} 已被恢复,未删除`, + deleteSubtaskNote: () => '其链接的子任务不会被删除,将保留并移入归档。', + deleteSubtaskNoteUncertain: () => '其链接的子任务(如有)不会被删除,将保留并移入归档。', + deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1419,6 +1428,11 @@ 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.', + deleteSubtaskNoteUncertain: () => + 'Its linked subtasks, if any, 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/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 427d9ea152..2b36e16ee2 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -125,7 +125,7 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { title: isSearching ? copy.purgeMatchesConfirmTitle(ids.length) : copy.purgeAllConfirmTitle(ids.length), - description: copy.purgeConfirmBody, + description: `${copy.purgeConfirmBody} ${copy.purgeSubtaskNote}`, confirmLabel: copy.purgeConfirmAction, cancelLabel: getSettingsSharedCopy(locale).cancel, destructive: true, @@ -140,6 +140,14 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { // dropping the other is how a count quietly stops adding up. const kept = outcome.restored.length > 0 ? copy.purgeKeptRestored(outcome.restored.length) : undefined; + // A bulk purge of parents archives their linked subtasks; say how many so + // the archived rows that appear next are not a surprise. + const moved = + outcome.archivedSubtasks > 0 ? copy.purgedSubtaskNote(outcome.archivedSubtasks) : undefined; + const detail = (...parts: Array) => { + const text = parts.filter(Boolean).join(' '); + return text.length > 0 ? text : undefined; + }; if (!outcome.verified || outcome.remaining.length > 0) { // A reason beats a count: a task refuses to retire while its turn is // still running, and "N still there" gives the reader nothing to do. @@ -150,14 +158,14 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { : copy.purgeFailedBody(outcome.remaining.length); toast.error( copy.purgeFailedTitle, - kept ? `${reason} ${kept}` : reason, + detail(reason, moved, kept), undefined, outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, ); } else { - toast.success(copy.purgedToast(outcome.removed), kept); + toast.success(copy.purgedToast(outcome.removed), detail(moved, kept)); } } finally { if (mountedRef.current) setPurging(false); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 7fcfa3af03..df32fa0951 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1341,6 +1341,7 @@ function useArchivedTasksStoryBridge(seed: readonly SessionSummary[]): ArchivedT drop(sessionIds); return { removed: sessionIds.length, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, 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 be4674ea93..affd668029 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -200,6 +200,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, @@ -207,7 +215,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' }); @@ -396,6 +406,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 f5854bdd6b..7e36892cd0 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 69a7b92fe7..d81039d8e6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,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 = 81 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 82 as const; +// 82: 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. // 81: SessionTodo replaces the Task Ledger protocol and continuity domain with // one bounded current-state snapshot. Older peers cannot decode the operation // or preserve the new invalidation vocabulary. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 61211f4147..fb497a1cd3 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -313,6 +313,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 a75524acb9..d16495822c 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -125,7 +125,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 SessionTodoOperationKey = Extract; diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 22233de377..72c6e9632f 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 { @@ -174,6 +175,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; @@ -358,7 +360,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(); @@ -373,11 +383,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, @@ -505,6 +554,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); @@ -531,9 +581,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); @@ -541,6 +590,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, }; } @@ -833,8 +886,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'> { @@ -847,3 +907,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 } }; +} From a6a6c9575e31835b6862d03fcc905f41da4b5f54 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Tue, 1 Sep 2026 00:02:06 +0800 Subject: [PATCH 2/2] fix(desktop): scope the kept-subtask notices to ordinary subtasks Addresses review on #3781: the delete/purge confirmations promised that every linked subtask survives, but the Host retires Agent Graph operator Sessions with the parent (they are in the removal family, not the archive family), so a graph-only parent deletes those children and archives zero. The bulk purgeSubtaskNote and the preview-failure deleteSubtaskNoteUncertain were the over-certain surfaces; deleteSubtaskNote shares the same wording, so all three now say "ordinary subtasks" rather than "linked subtasks". The exact-count case is unaffected (the Host preview already counts only ordinary archivable subtasks, and a graph-only parent previews zero, hiding the note). Generated-by: Claude Code --- apps/desktop/src/renderer/locales/settings-tasks-copy.ts | 4 ++-- apps/desktop/src/renderer/locales/shell-copy.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts index b0917f10bd..4bd7d41e33 100644 --- a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts @@ -70,7 +70,7 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { purgeAllConfirmTitle: (count: number) => `清空全部 ${count} 条已归档任务?`, purgeMatchesConfirmTitle: (count: number) => `删除搜索到的 ${count} 条任务?`, purgeConfirmBody: '这些任务及其全部消息会被永久删除,无法撤销。', - purgeSubtaskNote: '其关联的子任务不会被删除,将保留并移入归档。', + purgeSubtaskNote: '其中的普通子任务不会被删除,将保留并移入归档。', purgeConfirmAction: '永久删除', purgedToast: (count: number) => `已删除 ${count} 条任务`, purgedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, @@ -100,7 +100,7 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { count === 1 ? 'Delete the 1 task you searched for?' : `Delete the ${count} tasks you searched for?`, purgeConfirmBody: 'The tasks and all of their messages are removed permanently. This cannot be undone.', - purgeSubtaskNote: 'Any linked subtasks are kept and moved to Archived.', + purgeSubtaskNote: 'Any ordinary subtasks are kept and moved to Archived.', purgeConfirmAction: 'Delete permanently', purgedToast: (count: number) => (count === 1 ? 'Deleted 1 task' : `Deleted ${count} tasks`), purgedSubtaskNote: (count: number) => diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index fb622d5fe5..5bb79e8396 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -899,8 +899,8 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: '取消', deletedTitle: (name: string) => `已删除 ${name}`, deleteRestoredTitle: (name: string) => `${name} 已被恢复,未删除`, - deleteSubtaskNote: () => '其链接的子任务不会被删除,将保留并移入归档。', - deleteSubtaskNoteUncertain: () => '其链接的子任务(如有)不会被删除,将保留并移入归档。', + deleteSubtaskNote: () => '其普通子任务不会被删除,将保留并移入归档。', + deleteSubtaskNoteUncertain: () => '其普通子任务(如有)不会被删除,将保留并移入归档。', deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, }, skillActions: { @@ -1428,9 +1428,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.', + deleteSubtaskNote: () => 'Its ordinary subtasks will be kept and moved to Archived.', deleteSubtaskNoteUncertain: () => - 'Its linked subtasks, if any, will be kept and moved to Archived.', + 'Its ordinary subtasks, if any, will be kept and moved to Archived.', deletedSubtaskNote: (count: number) => count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, },