From c50fbfda1f72ab79a050cf4dd7ed7d910b2a4c45 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Wed, 26 Aug 2026 04:11:35 +0800 Subject: [PATCH 1/3] feat(desktop): warn that deleting a parent keeps and archives its subtasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: - `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, which the confirm warns off. Graph operators (retired with the parent), already-archived children, copies mid-preparation, and absent targets all preview zero. A preview failure drops the note without blocking the delete. The renderer never re-derives the Host's removal plan — its catalog projection carries linked children as `subagent: { parentSessionId }` with no operator marker or copy state, so an estimate would over-promise. Copy lives in `shell-copy.ts` (zh + en). Deletion semantics are unchanged. Bumps RUNTIME_HOST_COMPATIBILITY_EPOCH to 51 (new removed-result field and new query; older peers reject both). 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 | 124 +++++++++++++++++- .../session-navigation-session-purge.test.ts | 17 ++- 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 | 47 +++++-- .../features/session-navigation/ports.ts | 18 ++- .../features/session-navigation/testing.ts | 3 +- .../src/renderer/locales/shell-copy.ts | 9 ++ .../create-session-navigation-services.ts | 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 +++++++++++- 21 files changed, 514 insertions(+), 49 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 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 f2aa6d8817..2001f1446e 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 7fc166f717..68e148e17d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1966,9 +1966,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 } }; +} From a3f535d933e8ac8524d9e56bf3859068fd2551fe Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Wed, 26 Aug 2026 14:15:17 +0800 Subject: [PATCH 2/3] fix(desktop): warn on preview failure and account for bulk-purged subtasks Addresses the [P2] on be094cb043: - Preview fail-open: the delete confirm swallowed a `previewRemoval` error as count 0 and showed no warning, hiding that subtasks may be archived. It now falls back to an uncertain note ("its linked subtasks, if any, will be kept and moved to Archived") so the confirm never silently omits the warning; the delete still proceeds and the toast reports the executed count. - Bulk purge bypass: purgeSessions now sums the Host's executed archivedSubtaskCount across the sweep into SessionPurgeOutcome.archivedSubtasks, the archived-tasks purge confirm warns that linked subtasks are kept and archived, and the result toast reports how many moved. Generated-by: Claude Code --- ...n-navigation-row-actions-revisions.test.ts | 6 ++- .../session-navigation-session-purge.test.ts | 19 ++++++++- .../controller/session-row-actions.ts | 41 ++++++++++++++----- .../renderer/locales/settings-tasks-copy.ts | 9 ++++ .../src/renderer/locales/shell-copy.ts | 5 +++ .../renderer/settings/tasks-settings-page.tsx | 14 +++++-- .../settings/settings-pages.stories.tsx | 1 + 7 files changed, 79 insertions(+), 16 deletions(-) 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 9fcef73f00..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 @@ -200,7 +200,7 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun assert.deepEqual(successes, [{ title: 'Deleted hi', description: undefined }]); }); - it('still deletes and drops the note when the preview call fails', async () => { + it('warns with uncertainty and still deletes when the preview call fails', async () => { const { actions, calls, confirms, successes } = deleteHarness( [summary('parent', { name: 'hi' })], 'removed', @@ -210,9 +210,11 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun 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.doesNotMatch(confirms[0].description, /subtask/); assert.deepEqual(successes, [{ title: 'Deleted hi', 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 870c9ce9a8..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,6 +78,8 @@ 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 { @@ -102,7 +104,7 @@ function installService( } harness.removed.push(id); options.onRemove?.(id); - return { disposition: 'removed', archivedSubtaskCount: 0 }; + return { disposition: 'removed', archivedSubtaskCount: options.archivedByRemoval?.[id] ?? 0 }; }, previewRemoval: async () => 0, }; @@ -169,6 +171,7 @@ describe('purgeSessions', () => { assert.deepEqual(h.removed, ['a-v2', 'b']); assert.deepEqual(outcome, { removed: 2, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, @@ -187,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/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index b960385e9a..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 @@ -62,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[]; /** @@ -178,20 +184,26 @@ export function createSessionNavigationRowActions(deps: { // 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; + // 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 = 0; + previewSubtaskCount = undefined; } + const subtaskNote = + previewSubtaskCount === undefined + ? copy.deleteSubtaskNoteUncertain() + : previewSubtaskCount > 0 + ? copy.deleteSubtaskNote() + : undefined; const ok = await toastApi.confirm({ title: copy.deleteTitle(name), - description: - previewSubtaskCount > 0 - ? `${copy.deleteDescription} ${copy.deleteSubtaskNote()}` - : copy.deleteDescription, + description: subtaskNote + ? `${copy.deleteDescription} ${subtaskNote}` + : copy.deleteDescription, confirmLabel: copy.deleteLabel, cancelLabel: copy.cancelLabel, destructive: true, @@ -270,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 ( @@ -282,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 }; @@ -296,6 +314,7 @@ export function createSessionNavigationRowActions(deps: { await refreshSessions(); return { removed, + archivedSubtasks, remaining: [], restored, verified: true, @@ -312,6 +331,7 @@ export function createSessionNavigationRowActions(deps: { if (!listed) { return { removed, + archivedSubtasks, remaining: [], restored, verified: false, @@ -322,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/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 9f8f8513c7..cbcc12e180 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -270,6 +270,8 @@ type ShellCopy = { 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; }; @@ -897,6 +899,7 @@ const SHELL_COPY_BY_LOCALE = { deletedTitle: (name: string) => `已删除 ${name}`, deleteRestoredTitle: (name: string) => `${name} 已被恢复,未删除`, deleteSubtaskNote: () => '其链接的子任务不会被删除,将保留并移入归档。', + deleteSubtaskNoteUncertain: () => '其链接的子任务(如有)不会被删除,将保留并移入归档。', deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, }, skillActions: { @@ -1424,6 +1427,8 @@ const SHELL_COPY_BY_LOCALE = { 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`, }, diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 32fe6a9e70..6bf387adf3 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -124,7 +124,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, @@ -139,6 +139,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. @@ -149,14 +157,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 ae7910e74b..b45abad9f4 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1306,6 +1306,7 @@ function useArchivedTasksStoryBridge(seed: readonly SessionSummary[]): ArchivedT drop(sessionIds); return { removed: sessionIds.length, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, From 129b6a2c5423970306a53e683e9a59fe5fae4825 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Wed, 26 Aug 2026 19:12:50 +0800 Subject: [PATCH 3/3] ci: re-trigger (flaky two-client recap UDS test, unrelated)