fix: finalize stopped subworker sessions

This commit is contained in:
2026-08-20 11:19:02 +09:00
parent 9194f0a1ba
commit 3e833b5295
14 changed files with 816 additions and 37 deletions
+1 -1
View File
@@ -178,4 +178,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
@@ -1287,7 +1287,10 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
}]);
assertEquals(projection.lines, []);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output");
assertEquals(
projection.internalWorkers[0].console.lines[0].body,
"child output",
);
projection = projector.append([{
eventId: "2",
@@ -1364,15 +1367,112 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
},
}]);
const projection = projector.append([{ eventId: "snapshot", event }]);
assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [
"replacement",
]);
assertEquals(
projection.internalWorkers.map((worker) => worker.worker.session_id),
[
"replacement",
],
);
const childLines = projection.internalWorkers[0].console.lines;
assertEquals(childLines.length, 1);
assertEquals(new Set(childLines.map((line) => line.id)).size, 1);
assertEquals(childLines[0].kind, "tool");
});
Deno.test("terminal Internal Worker removal drops descendants and fences late events", () => {
const worker = {
session_id: "child-session",
name: "child",
parent_session_id: "parent-session",
kind: "sub_worker" as const,
};
const nestedWorker = {
session_id: "grandchild-session",
name: "grandchild",
parent_session_id: "child-session",
kind: "sub_worker" as const,
};
const projector = createConsoleProjector();
let projection = projector.append([{
eventId: "child",
event: {
event: "internal_worker",
data: {
worker,
revision: 2,
event: {
event: "internal_worker",
data: {
worker: nestedWorker,
revision: 1,
event: { event: "text_done", data: { text: "nested" } },
},
},
},
},
}]);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(
projection.internalWorkers[0].console.internalWorkers.length,
1,
);
projection = projector.append([{
eventId: "removed",
event: {
event: "internal_worker_removed",
data: { worker, revision: 3 },
},
}, {
eventId: "late",
event: {
event: "internal_worker",
data: {
worker,
revision: 4,
event: { event: "text_done", data: { text: "must stay removed" } },
},
},
}]);
assertEquals(projection.internalWorkers, []);
const snapshot = snapshotEvent("/repo");
projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
assertEquals(projection.internalWorkers, []);
assertEquals(projection.removedInternalWorkers, {});
});
Deno.test("stale Internal Worker removal cannot discard a newer projection", () => {
const worker = {
session_id: "child-session",
name: "child",
parent_session_id: "parent-session",
kind: "sub_worker" as const,
};
const projector = createConsoleProjector();
projector.append([{
eventId: "current",
event: {
event: "internal_worker",
data: {
worker,
revision: 4,
event: { event: "text_done", data: { text: "current" } },
},
},
}]);
const projection = projector.append([{
eventId: "stale-removal",
event: {
event: "internal_worker_removed",
data: { worker, revision: 3 },
},
}]);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(projection.internalWorkers[0].revision, 4);
});
Deno.test("snapshot restores TaskStore state from system history", () => {
const taskSnapshot =
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
@@ -94,6 +94,8 @@ export type ConsoleProjection = {
cwd: string | null;
lastEventId: string | null;
internalWorkers: InternalWorkerProjection[];
/** Terminal child-session fences, reset only by an authoritative snapshot. */
removedInternalWorkers: Record<string, number>;
};
export type ConsoleTimelineLineSelection = {
@@ -179,6 +181,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
cwd: null,
lastEventId: null,
internalWorkers: [],
removedInternalWorkers: {},
};
}
@@ -286,6 +289,7 @@ export function applyProtocolEvent(
cwd: projection.cwd,
lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers],
removedInternalWorkers: { ...projection.removedInternalWorkers },
};
const event = envelope.event;
@@ -406,9 +410,16 @@ export function applyProtocolEvent(
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
);
next.removedInternalWorkers = {};
break;
}
case "internal_worker": {
if (
Object.hasOwn(
next.removedInternalWorkers,
event.data.worker.session_id,
)
) break;
const existingIndex = next.internalWorkers.findIndex((worker) =>
worker.worker.session_id === event.data.worker.session_id
);
@@ -433,6 +444,19 @@ export function applyProtocolEvent(
else next.internalWorkers.push(updated);
break;
}
case "internal_worker_removed": {
const existingIndex = next.internalWorkers.findIndex((worker) =>
worker.worker.session_id === event.data.worker.session_id
);
const existingRevision = existingIndex >= 0
? next.internalWorkers[existingIndex].revision
: 0;
if (event.data.revision <= existingRevision) break;
next.removedInternalWorkers[event.data.worker.session_id] =
event.data.revision;
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
break;
}
case "status":
next.status = event.data.status;
break;
@@ -1294,6 +1318,7 @@ function snapshotProjectionFromEntries(
cwd,
lastEventId: eventId,
internalWorkers: [],
removedInternalWorkers: {},
};
entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)