feat: expose compaction as an internal worker

This commit is contained in:
2026-08-26 10:39:43 +09:00
parent 8396d09891
commit 65de94bad3
17 changed files with 1416 additions and 563 deletions
+10 -2
View File
@@ -32,6 +32,14 @@ export type CommandSnapshot = { command_id: string, tool_call_id: string | null,
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
export type CompactionLifecycleState = "running" | "done" | "failed" | "interrupted";
export type CompactionLifecycle = { schema_version: number, compaction_id: string, revision: number, internal_worker?: InternalWorkerRef | null, state: CompactionLifecycleState,
/**
* Milliseconds since the Unix epoch.
*/
started_at_ms: number, ended_at_ms?: number | null, summary?: string | null, error?: string | null, new_segment_id?: string | null, };
export type ScopeRule = {
/**
* Target path. Must be absolute by the time a `Scope` is built from
@@ -63,7 +71,7 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
export type InternalWorkerKind = "sub_worker";
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
@@ -193,4 +201,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": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "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": "command", "data": { event: CommandEvent, } } | { "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", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -8,6 +8,33 @@
};
let { item }: Props = $props();
let nowMs = $state(Date.now());
$effect(() => {
if (item.compaction?.state !== 'running') return;
nowMs = Date.now();
const timer = window.setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => window.clearInterval(timer);
});
function compactionElapsedMs(line: ConsoleLine): number {
const compaction = line.compaction;
if (!compaction) return 0;
return Math.max(0, (compaction.endedAtMs ?? nowMs) - compaction.startedAtMs);
}
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
function compactionState(line: ConsoleLine): string {
return line.compaction?.state.replace('_', ' ') ?? '';
}
function lineClass(line: ConsoleLine): string {
return line.error ? 'error' : line.kind;
@@ -52,7 +79,29 @@
class:error-line={item.error}
data-console-line-id={item.id}
>
{#if shouldRenderHeading(item)}
{#if item.compaction}
<div class="compaction-heading">
<span>Compaction · {compactionState(item)}</span>
<span>{formatElapsed(compactionElapsedMs(item))}</span>
</div>
{#if item.compaction.activity.length > 0}
<ul class="compaction-activity">
{#each item.compaction.activity as activity}
<li>{activity}</li>
{/each}
</ul>
{/if}
{#if item.compaction.summary}
<div class="compaction-summary">{item.compaction.summary}</div>
{:else if item.compaction.candidate}
<div class="compaction-candidate">
<span class="compaction-candidate-label">candidate</span>
{item.compaction.candidate}
</div>
{:else if item.compaction.error}
<div class="compaction-error">{item.compaction.error}</div>
{/if}
{:else if shouldRenderHeading(item)}
<div class="message-heading">
<span>{item.title}</span>
</div>
@@ -63,7 +112,9 @@
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
</div>
{/if}
{#if item.kind === 'tool'}
{#if item.compaction}
<!-- rendered as one lifecycle item above -->
{:else if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)}
<p class="console-plain-text">
{#if isBashTool(item)}
@@ -141,6 +192,43 @@
white-space: pre-line;
}
.compaction-heading {
display: flex;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
font-weight: 750;
}
.compaction-activity {
margin: var(--space-1) 0;
padding-left: 1.25rem;
color: var(--tui-dark-gray);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.compaction-summary,
.compaction-candidate,
.compaction-error {
margin-top: var(--space-1);
white-space: pre-wrap;
}
.compaction-candidate-label {
display: block;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.7rem;
}
.compaction-error {
color: var(--tui-error);
}
.activity-summary {
font-size: 14px;
}
@@ -647,42 +647,173 @@ Deno.test("projectConsole renders alert events", () => {
assertEquals(projection.lines[1].error, true);
});
Deno.test("projectConsole shows compact progress as a status block", () => {
Deno.test("projectConsole upserts compaction lifecycle by stable id", () => {
const running = {
schema_version: 2,
compaction_id: "compaction-1",
revision: 1,
internal_worker: null,
state: "running",
started_at_ms: 1_000,
ended_at_ms: null,
summary: null,
error: null,
new_segment_id: null,
} as const;
const projection = projectConsole([
{
eventId: "compact-1",
event: { event: "compact_start" } satisfies Event,
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
},
]);
assertEquals(projection.lines.length, 1);
assertEquals(projection.lines[0].id, "status-compact");
assertEquals(projection.lines[0].kind, "status");
assertEquals(projection.lines[0].body, "Compacting…");
assertEquals(projection.lines[0].id, "compaction-compaction-1");
assertEquals(projection.lines[0].compaction?.state, "running");
assertEquals(projection.lines[0].streaming, true);
const completed = projectConsole([
{
eventId: "compact-1",
event: { event: "compact_start" } satisfies Event,
event: { event: "compact_start", data: { lifecycle: running } } satisfies Event,
},
{
eventId: "compact-2",
event: {
event: "compact_done",
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
data: {
lifecycle: {
...running,
revision: 2,
state: "done",
ended_at_ms: 4_000,
summary: "accepted summary",
new_segment_id: "00000000-0000-0000-0000-000000000001",
},
},
} satisfies Event,
},
]);
assertEquals(completed.lines.length, 1);
assertEquals(completed.lines[0].id, "status-compact");
assertEquals(completed.lines[0].body, "Compacted.");
assertEquals(completed.lines[0].id, "compaction-compaction-1");
assertEquals(completed.lines[0].compaction?.state, "done");
assertEquals(completed.lines[0].compaction?.summary, "accepted summary");
assertEquals(completed.lines[0].streaming, false);
});
Deno.test("createConsoleProjector updates only compact status block", () => {
Deno.test("compaction service activity stays nested in one lifecycle item", () => {
const worker = {
session_id: "compactor-session",
name: "Compaction",
parent_session_id: "parent-session",
kind: { service: { kind: "compaction" } },
} as const;
const lifecycle = {
schema_version: 2,
compaction_id: "compaction-nested",
revision: 2,
internal_worker: worker,
state: "running",
started_at_ms: 1_000,
ended_at_ms: null,
summary: null,
error: null,
new_segment_id: null,
} as const;
const projection = projectConsole([
{
eventId: "compaction-start",
event: { event: "compact_start", data: { lifecycle } } satisfies Event,
},
{
eventId: "compaction-tool",
event: {
event: "internal_worker",
data: {
worker,
revision: 1,
event: {
event: "tool_call_start",
data: { id: "call-1", name: "write_summary" },
},
},
} satisfies Event,
},
{
eventId: "compaction-tool-done",
event: {
event: "internal_worker",
data: {
worker,
revision: 2,
event: {
event: "tool_call_done",
data: {
id: "call-1",
name: "write_summary",
arguments: JSON.stringify({ text: "draft candidate" }),
},
},
},
} satisfies Event,
},
]);
assertEquals(projection.lines.length, 1);
assertEquals(projection.lines[0].id, "compaction-compaction-nested");
assertEquals(projection.lines[0].compaction?.activity, ["write_summary — running"]);
assertEquals(projection.lines[0].compaction?.candidate, "draft candidate");
assert(
consoleWorkerViews(projection).length === 1,
"service is not a selectable SubWorker pane",
);
});
Deno.test("snapshot normalizes orphaned running compaction to interrupted", () => {
const projection = projectConsole([{
eventId: "snapshot",
observedAtMs: 9_000,
event: snapshotEvent("/repo", [{
kind: "extension",
ts: 1,
domain: "yoi.compaction",
payload: {
schema_version: 2,
compaction_id: "compaction-orphaned",
revision: 2,
internal_worker: {
session_id: "missing-service",
name: "Compaction",
parent_session_id: "parent",
kind: { service: { kind: "compaction" } },
},
state: "running",
started_at_ms: 1_000,
},
}]),
}]);
assertEquals(projection.lines.length, 1);
assertEquals(projection.lines[0].compaction?.state, "interrupted");
assertEquals(projection.lines[0].compaction?.endedAtMs, 9_000);
assertEquals(projection.lines[0].streaming, false);
});
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
const projector = createConsoleProjector();
const base = {
schema_version: 2,
compaction_id: "compaction-identity",
revision: 1,
internal_worker: null,
state: "running",
started_at_ms: 1_000,
ended_at_ms: null,
summary: null,
error: null,
new_segment_id: null,
} as const;
let projection = projector.append([
{
eventId: "compact-identity-1",
@@ -693,31 +824,37 @@ Deno.test("createConsoleProjector updates only compact status block", () => {
},
{
eventId: "compact-identity-2",
event: { event: "compact_start" } satisfies Event,
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
},
]);
const userLine = projection.lines[0];
const compactLine = projection.lines[1];
projection = projector.append([
{
eventId: "compact-identity-3",
event: {
event: "compact_done",
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
data: {
lifecycle: {
...base,
revision: 2,
state: "done",
ended_at_ms: 2_000,
summary: "accepted",
new_segment_id: "00000000-0000-0000-0000-000000000001",
},
},
} satisfies Event,
},
{
eventId: "compact-identity-stale",
event: { event: "compact_start", data: { lifecycle: base } } satisfies Event,
},
]);
assert(
projection.lines[0] === userLine,
"unrelated message line should keep object identity",
);
assert(
projection.lines[1] !== compactLine,
"compact status line should update object identity",
);
assertEquals(projection.lines[1].body, "Compacted.");
assert(projection.lines[0] === userLine, "unrelated line retains identity");
assertEquals(projection.lines[1].compaction?.state, "done");
assertEquals(projection.lines[1].compaction?.summary, "accepted");
});
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
+161 -18
View File
@@ -3,6 +3,7 @@ import type {
CommandEvent,
CommandSnapshot,
CommandStreamSlice,
CompactionLifecycle,
Event as ProtocolEvent,
InFlightBlock,
InFlightToolCallState,
@@ -67,12 +68,26 @@ export type ConsoleDiffLine = {
export type ConsoleViewMode = "overview" | "normal";
export type ConsoleCompaction = {
id: string;
revision: number;
state: "running" | "done" | "failed" | "interrupted";
startedAtMs: number;
endedAtMs?: number;
summary?: string;
candidate?: string;
error?: string;
internalWorkerSessionId?: string;
activity: string[];
};
export type ConsoleLine = {
id: string;
kind: ConsoleLineKind;
title: string;
body: string;
detail?: string;
compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[];
eventId?: string | null;
source: "event";
@@ -114,16 +129,17 @@ export type ConsoleWorkerView = {
export function consoleWorkerViews(
projection: ConsoleProjection,
): ConsoleWorkerView[] {
const labels = projection.internalWorkers.map((worker) =>
worker.worker.name || "subworker"
const children = projection.internalWorkers.filter((worker) =>
worker.worker.kind === "sub_worker"
);
const labels = children.map((worker) => worker.worker.name || "subworker");
const labelCounts = new Map<string, number>();
for (const label of labels) {
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
return [
{ sessionId: null, label: "main", console: projection },
...projection.internalWorkers.map((worker, index) => {
...children.map((worker, index) => {
const label = labels[index] ?? "subworker";
return {
sessionId: worker.worker.session_id,
@@ -664,6 +680,105 @@ function projectInternalWorkerSnapshot(
return { worker: snapshot.worker, revision: snapshot.revision, console };
}
function compactionCandidate(
projection: ConsoleProjection,
sessionId: string | undefined,
): string | undefined {
if (!sessionId) return undefined;
const worker = projection.internalWorkers.find(
(candidate) => candidate.worker.session_id === sessionId,
);
const calls = worker?.console.lines
.map((line) => line.toolCall)
.filter((call): call is ToolCallView => call?.name === "write_summary") ?? [];
const latest = calls.at(-1);
const raw = latest?.arguments ?? latest?.argsStream;
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw) as { text?: unknown };
return typeof parsed.text === "string" ? parsed.text : undefined;
} catch {
return undefined;
}
}
function compactionActivity(
projection: ConsoleProjection,
sessionId: string | undefined
): string[] {
if (!sessionId) return [];
const worker = projection.internalWorkers.find(
(candidate) => candidate.worker.session_id === sessionId
);
if (!worker) return [];
return worker.console.lines
.filter((line) => line.kind === "tool" || line.kind === "status" || line.kind === "error")
.slice(-12)
.map((line) =>
line.toolCall?.name === "write_summary"
? `write_summary — ${line.toolCall.state}`
: line.body || line.title
)
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
}
function applyCompactionLifecycle(
projection: ConsoleProjection,
lifecycle: CompactionLifecycle
): ConsoleProjection {
const lineId = `compaction-${lifecycle.compaction_id}`;
const existing = projection.lines.find((line) => line.id === lineId)?.compaction;
if (existing && existing.revision >= lifecycle.revision) return projection;
const internalWorkerSessionId = lifecycle.internal_worker?.session_id;
const compaction: ConsoleCompaction = {
id: lifecycle.compaction_id,
revision: lifecycle.revision,
state: lifecycle.state,
startedAtMs: lifecycle.started_at_ms,
endedAtMs: lifecycle.ended_at_ms ?? undefined,
summary: lifecycle.summary ?? undefined,
candidate: compactionCandidate(projection, internalWorkerSessionId),
error: lifecycle.error ?? undefined,
internalWorkerSessionId,
activity: compactionActivity(projection, internalWorkerSessionId)
};
const line: ConsoleLine = {
id: lineId,
source: "event",
kind: compaction.state === "failed" ? "error" : "status",
title: "Compaction",
body: compaction.summary ?? compaction.error ?? "",
streaming: compaction.state === "running",
error: compaction.state === "failed",
compaction
};
const index = projection.lines.findIndex((candidate) => candidate.id === lineId);
const lines = [...projection.lines];
if (index >= 0) lines[index] = line;
else lines.push(line);
return { ...projection, lines };
}
function refreshCompactionActivity(
projection: ConsoleProjection,
sessionId: string
): ConsoleProjection {
let changed = false;
const lines = projection.lines.map((line) => {
if (line.compaction?.internalWorkerSessionId !== sessionId) return line;
changed = true;
return {
...line,
compaction: {
...line.compaction,
candidate: compactionCandidate(projection, sessionId),
activity: compactionActivity(projection, sessionId)
}
};
});
return changed ? { ...projection, lines } : projection;
}
export function applyProtocolEvent(
projection: ConsoleProjection,
envelope: ConsoleEventInput,
@@ -809,6 +924,31 @@ export function applyProtocolEvent(
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
);
next.removedInternalWorkers = {};
for (const line of next.lines) {
const compaction = line.compaction;
if (!compaction) continue;
const sessionId = compaction.internalWorkerSessionId;
if (sessionId) {
line.compaction = {
...compaction,
candidate: compactionCandidate(next, sessionId),
activity: compactionActivity(next, sessionId),
};
}
if (
compaction.state === "running" &&
(!sessionId || !next.internalWorkers.some((worker) =>
worker.worker.session_id === sessionId
))
) {
line.streaming = false;
line.compaction = {
...line.compaction!,
state: "interrupted",
endedAtMs: envelope.observedAtMs ?? Date.now(),
};
}
}
break;
}
case "internal_worker": {
@@ -841,7 +981,7 @@ export function applyProtocolEvent(
};
if (existingIndex >= 0) next.internalWorkers[existingIndex] = updated;
else next.internalWorkers.push(updated);
break;
return refreshCompactionActivity(next, event.data.worker.session_id);
}
case "internal_worker_removed": {
const existingIndex = next.internalWorkers.findIndex((worker) =>
@@ -905,21 +1045,9 @@ export function applyProtocolEvent(
// them to the conversation surface; browser Console should not either.
break;
case "compact_start":
upsertStatusLine(next, "compact", envelope.eventId, "Compacting…", true);
break;
case "compact_done":
upsertStatusLine(next, "compact", envelope.eventId, "Compacted.", false);
break;
case "compact_failed":
upsertStatusLine(
next,
"compact",
envelope.eventId,
`Compact failed: ${event.data.error}`,
false,
true,
);
break;
return applyCompactionLifecycle(next, event.data.lifecycle);
case "shutdown":
next.status = "shutdown";
break;
@@ -1880,7 +2008,22 @@ function applyExtensionEntry(
return;
}
const payload = entry["payload"];
if (!isRecord(payload) || payload["kind"] !== "compaction_block") {
if (!isRecord(payload)) return;
if (
typeof payload["compaction_id"] === "string" &&
typeof payload["revision"] === "number" &&
typeof payload["state"] === "string" &&
typeof payload["started_at_ms"] === "number"
) {
const updated = applyCompactionLifecycle(
projection,
payload as unknown as CompactionLifecycle,
);
projection.lines = updated.lines;
return;
}
// Schema v1 remains readable historical evidence.
if (payload["kind"] !== "compaction_block") {
return;
}
const blockId = stringField(payload, "block_id") || "compact";
@@ -926,8 +926,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
);
assert(
consoleModel.includes("consoleWorkerViews") &&
consoleModel.includes("projection.internalWorkers.map") &&
consoleModel.includes('worker.worker.kind === "sub_worker"') &&
consoleModel.includes("children.map") &&
consoleModel.includes("resolveConsoleWorkerView"),
"Worker view selection should use direct Internal Worker session identities with main fallback",
"Worker view selection should expose only direct SubWorker session identities with main fallback",
);
});