feat: stream internal subworker output through parent
This commit is contained in:
@@ -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)
|
||||
|
||||
+43
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user