feat: stream internal subworker output through parent

This commit is contained in:
2026-08-19 09:24:14 +09:00
parent fe74d7c4b8
commit ce62e09919
18 changed files with 990 additions and 19 deletions
+12 -1
View File
@@ -53,6 +53,12 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
export type InternalWorkerKind = "sub_worker";
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, entries: Array<unknown>, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
/**
* Model context window in tokens. Always filled by the Worker greeting.
@@ -167,4 +173,9 @@ output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { i
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
*/
in_flight?: InFlightSnapshot, } } | { "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" };
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" };
@@ -1266,6 +1266,82 @@ Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
}]);
});
Deno.test("Internal Worker output stays separate and revision-fenced", () => {
const worker = {
session_id: "child-session",
name: "research",
parent_session_id: "parent-session",
kind: "sub_worker" as const,
};
const projector = createConsoleProjector();
let projection = projector.append([{
eventId: "1",
event: {
event: "internal_worker",
data: {
worker,
revision: 2,
event: { event: "text_done", data: { text: "child output" } },
},
},
}]);
assertEquals(projection.lines, []);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output");
projection = projector.append([{
eventId: "2",
event: {
event: "internal_worker",
data: {
worker,
revision: 1,
event: { event: "text_done", data: { text: "stale" } },
},
},
}]);
assertEquals(projection.internalWorkers[0].console.lines.length, 1);
});
Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => {
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.internal_workers = [{
worker: {
session_id: "replacement",
name: "replacement",
parent_session_id: "parent-session",
kind: "sub_worker",
},
revision: 4,
entries: [],
status: "idle",
in_flight: { blocks: [] },
internal_workers: [],
}];
const projector = createConsoleProjector();
projector.append([{
eventId: "old",
event: {
event: "internal_worker",
data: {
worker: {
session_id: "old",
name: "old",
parent_session_id: "parent-session",
kind: "sub_worker",
},
revision: 1,
event: { event: "status", data: { status: "running" } },
},
},
}]);
const projection = projector.append([{ eventId: "snapshot", event }]);
assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [
"replacement",
]);
});
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\`\`\``;
@@ -3,6 +3,8 @@ import type {
Event as ProtocolEvent,
InFlightBlock,
InFlightToolCallState,
InternalWorkerRef,
InternalWorkerSnapshot,
Segment,
} from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http";
@@ -63,6 +65,26 @@ export type ConsoleLine = {
toolCall?: ToolCallView;
};
export type InternalWorkerProjection = {
worker: InternalWorkerRef;
revision: number;
console: ConsoleProjection;
};
export type FlattenedInternalWorkerProjection = InternalWorkerProjection & {
depth: number;
};
export function flattenInternalWorkers(
workers: InternalWorkerProjection[],
depth = 0,
): FlattenedInternalWorkerProjection[] {
return workers.flatMap((worker) => [
{ ...worker, depth },
...flattenInternalWorkers(worker.console.internalWorkers, depth + 1),
]);
}
export type ConsoleProjection = {
lines: ConsoleLine[];
tasks: ConsoleTask[];
@@ -71,6 +93,7 @@ export type ConsoleProjection = {
usage: string | null;
cwd: string | null;
lastEventId: string | null;
internalWorkers: InternalWorkerProjection[];
};
export type ConsoleTimelineLineSelection = {
@@ -155,6 +178,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
usage: null,
cwd: null,
lastEventId: null,
internalWorkers: [],
};
}
@@ -193,9 +217,50 @@ function projectVisibleConsole(
return {
...projection,
lines: aggregateReadToolLines(projection.lines),
internalWorkers: projection.internalWorkers.map((worker) => ({
...worker,
console: projectVisibleConsole(worker.console),
})),
};
}
function projectInternalWorkerSnapshot(
snapshot: InternalWorkerSnapshot,
eventId: string,
cwd: string | null,
): InternalWorkerProjection {
const console = snapshotProjectionFromEntries(
`${eventId}:internal:${snapshot.worker.session_id}:snapshot`,
snapshot.entries,
cwd,
);
console.status = snapshot.status;
for (const block of snapshot.in_flight?.blocks ?? []) {
console.lines.push(
inFlightLine(
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
block,
cwd,
),
);
}
if (snapshot.error) {
console.lines.push({
id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
kind: "error",
title: "Error",
body: snapshot.error,
eventId,
source: "event",
error: true,
});
}
console.internalWorkers = (snapshot.internal_workers ?? []).map((child) =>
projectInternalWorkerSnapshot(child, eventId, cwd)
);
return { worker: snapshot.worker, revision: snapshot.revision, console };
}
export function applyProtocolEvent(
projection: ConsoleProjection,
envelope: { eventId: string; event: ProtocolEvent },
@@ -208,6 +273,7 @@ export function applyProtocolEvent(
usage: projection.usage,
cwd: projection.cwd,
lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers],
};
const event = envelope.event;
@@ -322,6 +388,34 @@ export function applyProtocolEvent(
for (const block of event.data.in_flight?.blocks ?? []) {
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
}
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
);
break;
}
case "internal_worker": {
const existingIndex = next.internalWorkers.findIndex((worker) =>
worker.worker.session_id === event.data.worker.session_id
);
const existing = existingIndex >= 0
? next.internalWorkers[existingIndex]
: {
worker: event.data.worker,
revision: 0,
console: emptyConsoleProjection(),
};
if (event.data.revision <= existing.revision) break;
const updated: InternalWorkerProjection = {
worker: event.data.worker,
revision: event.data.revision,
console: applyProtocolEvent(existing.console, {
eventId:
`${envelope.eventId}:internal:${event.data.worker.session_id}:${event.data.revision}`,
event: event.data.event,
}),
};
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
else next.internalWorkers.push(updated);
break;
}
case "status":
@@ -1184,6 +1278,7 @@ function snapshotProjectionFromEntries(
usage: null,
cwd,
lastEventId: eventId,
internalWorkers: [],
};
entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
@@ -18,6 +18,7 @@
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import {
createConsoleProjector,
flattenInternalWorkers,
isConsoleProjectionEvent,
selectConsoleTimelineLines,
type ConsoleEventInput,
@@ -152,6 +153,9 @@
const lines = $derived(consoleProjection.lines);
const tasks = $derived(consoleProjection.tasks);
const internalWorkers = $derived(
flattenInternalWorkers(consoleProjection.internalWorkers),
);
const timelineLayout = $derived(
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
);
@@ -1245,6 +1249,28 @@
</ol>
{/if}
</article>
{#each internalWorkers as internal (internal.worker.session_id)}
<section
class="card internal-worker-pane"
style={`--internal-worker-depth: ${internal.depth}`}
aria-label={`SubWorker ${internal.worker.name}`}
>
<header class="internal-worker-header">
<strong>{internal.worker.name}</strong>
<span>{internal.console.status ?? "unknown"}</span>
</header>
{#if internal.console.lines.length === 0}
<p>No output yet.</p>
{:else}
<ol class="console-log">
{#each internal.console.lines as item (item.id)}
<ConsoleLineItem {item} />
{/each}
</ol>
{/if}
</section>
{/each}
</div>
<ConsoleTimeline
@@ -1771,6 +1797,23 @@
margin-right: auto;
}
.internal-worker-pane {
margin: 0.75rem 0 0 calc((var(--internal-worker-depth) + 1) * 1rem);
border-left: 3px solid var(--color-border-strong, currentColor);
}
.internal-worker-header {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
font-family: var(--font-mono);
}
.internal-worker-header span {
color: var(--color-text-muted);
}
@media (max-width: 960px) {
.console-history.with-task-pane {
grid-template-columns: minmax(0, 1fr);