Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/e2e/parent-session-deletion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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),
[
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ function summary(id: string, overrides: Partial<SessionSummary> = {}): 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 }) => {
Expand All @@ -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;
},
};
}
Expand Down Expand Up @@ -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',
Expand All @@ -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<string>() },
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 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
} = {},
): 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,
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -166,6 +171,7 @@ describe('purgeSessions', () => {
assert.deepEqual(h.removed, ['a-v2', 'b']);
assert.deepEqual(outcome, {
removed: 2,
archivedSubtasks: 0,
remaining: [],
restored: [],
verified: true,
Expand All @@ -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
Expand Down
32 changes: 29 additions & 3 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ export type DesktopSessionConfigurationPatch = Partial<SessionConfiguration>;
*/
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"
Expand Down Expand Up @@ -957,19 +968,34 @@ export class DesktopRuntimeHostClient {
async removeSession(
sessionId: string,
options: { requireArchived?: boolean } = {},
): Promise<SessionRemoveDisposition> {
): Promise<SessionRemoveOutcome> {
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<number> {
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);
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type RuntimeHostSessionCatalogClient = Pick<
DesktopRuntimeHostClient,
| 'createSession'
| 'listSessions'
| 'previewSessionRemoval'
| 'removeSession'
| 'setSessionLifecycle'
| 'updateSessionConfiguration'
Expand Down Expand Up @@ -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);
});
}

Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,12 +961,20 @@ export interface MakaBridge {
setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise<DesktopSessionSummary>;
/**
* `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<number>;
cleanupSessionCopy(sessionId: string): Promise<void>;
abandonSessionCopy(sourceSessionId: string, copyId: string): Promise<void>;
};
Expand Down
Loading