From 3dd4e1665e8817a04cc4538fcfc0217ba6951ee3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 21 Aug 2026 04:39:29 -0700 Subject: [PATCH] fix(kernel): refuse an executor backend nothing implements snapshotExecutorConfig switched over the seven ExecutorConfig arms with no default. The backend is data a profile, an experiment config, or a replay journal can carry, so a name outside the union reaches the factory untyped: the switch returned undefined, createExecutor handed back a working-looking factory, and the failure landed one call later as a TypeError naming nothing. It now refuses by name, listing the supported backends read off the trace-propagation table, which is the one copy the compiler already forces to hold every arm. Three prose sites named a stale backend set and are corrected: CLAUDE.md listed five of seven, docs/architecture.md paraphrased four and cited a line number 3218 lines away from createExecutor, and the ExecutorConfig doc comment listed six. The supervisor's own header claimed a teardown failure is journaled as a `cancelled` event; it is journaled per node as `teardown-unconfirmed`. --- CLAUDE.md | 2 +- docs/api/runtime.md | 2 +- docs/architecture.md | 7 ++++--- src/runtime/supervise/runtime.ts | 18 ++++++++++++++++-- src/runtime/supervise/supervisor.ts | 4 ++-- tests/runtime/executor-config-snapshot.test.ts | 10 ++++++++++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01403dfd..45077995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Types that stay in THIS repo because they're runtime-shaped (coupled to a runnin ## Code map — the loop kernel & the recursive atom (src/runtime/) - `run-loop.ts` — `runAgentRounds`, the round-synchronous leaf kernel. Per round: `driver.plan()`→N tasks→one sandbox/iteration (bounded by `maxConcurrency`, round-robin `agentRuns`)→`streamPrompt`→`output.parse`→`validator.validate`→`driver.decide`. Owns iteration accounting, concurrency, abort, cost+token aggregation, trace emission, box teardown. Exports `defaultSelectWinner` (best-valid-score, ties→earliest) — the single-sourced selection the personify combinators reuse. -- `supervise/` — the recursive execution atom (keystone): `Scope` + `Supervisor` over the open `Executor` port, spawn/settle on a **conserved budget pool** so equal-compute holds by construction; the journal replays completed settlements, but live supervised-tree recovery after coordinator restart is not implemented. `runtime.ts` also holds `createExecutor({backend})` — the ONE built-in executor (backend-as-data: `router`/`router-tools`/`bridge`/`cli`/`sandbox`; `router-tools` is the off-box tool-using agentic loop — chat→tool_calls→`executeToolCall`→repeat — over the router's tool-calling, no sandbox); the per-backend bodies are internal case-arms, BYO agents implement `Executor` directly. +- `supervise/` — the recursive execution atom (keystone): `Scope` + `Supervisor` over the open `Executor` port, spawn/settle on a **conserved budget pool** so equal-compute holds by construction; the journal replays completed settlements, but live supervised-tree recovery after coordinator restart is not implemented. `runtime.ts` also holds `createExecutor({backend})` — the ONE built-in executor (backend-as-data: `router`/`router-tools`/`bridge`/`cli`/`cli-worktree`/`provider`/`sandbox`; `router-tools` is the off-box tool-using agentic loop — chat→tool_calls→`executeToolCall`→repeat — over the router's tool-calling, no sandbox); the per-backend bodies are internal case-arms, BYO agents implement `Executor` directly. - `personify/` — the content-free generic combinators (`fanout`/`loopUntil`/`widen`/`panel`/`verify`/`pipeline`) + `definePersona`/`runPersonified` + the cross-run `Corpus` + `createScopeAnalyst` (the analyst-on-scope steer firewall). - the **agent-driver** is the canonical "drive an agent" path: an `AgentProfile` driving another `AgentProfile` via the coordination toolbox (`createCoordinationTools`, `src/mcp/tools/coordination.ts`) over the `Scope`/`Supervisor`, plus `runAgentic`/`defineStrategy`/`runPersonified` (`strategy.ts`/`personify/persona.ts`) on the Supervisor. Child→parent messages ride ONE typed pipe — `createEventBus` (`supervise/event-bus.ts`): settled outputs, `ask_parent` questions, and analyst findings are all `CoordinationEvent` kinds, delivered pass-through (`subscribe`/`onEvent`, immediate) AND queued for the driver to pull (`await_event({kinds?})` — the ONE wait verb; `kinds:['settled']` = next finished worker, omit = also questions/findings). The pull queue is **priority-ordered** — a blocking question (urgency→priority: `blocks-run`=20/`blocks-step`=10) is bumped ahead of queued settles/findings; ties FIFO by `seq`. The bus is **bidirectional**: UP (settled/question/finding) is queued+pullable; DOWN (`steer_agent` for any live worker — instruction/correction/continuation; `answer_question` routes an answer down) goes to the child inbox via `scope.send`→`deliver` AND records a `queue:false` event (history + subscribers, never pulled back). The receive end is `createInbox` (`supervise/inbox.ts`), which the owned tool-loop executor (`routerToolsInlineExecutor`) exposes as `Executor.deliver`: QUEUED messages flush at each step boundary AND before the worker may settle (it can't finish with an unread steer); a FORCEFUL `steer_agent({interrupt:true})` aborts the in-flight turn so the worker re-plans immediately. Black-box CLI harnesses can't be interrupted mid-step, so there the down-leg degrades to the next spawn. Observability is first-class: every event both ways is stamped (`seq`/`at`/`priority`), the full `history()` is an audit/replay trail, `stats()` counts throughput (both surfaced on `CoordinationTools` and the MCP handle). `analyzeOnSettle` auto-fires trace analysts when a worker settles `done`, re-entering each result as a `finding` on the same bus (cost-governed opt-in; the firewall stays in the analyst registry). Trace analysis is **substrate- AND harness-agnostic** via `TraceSource` (`supervise/trace-source.ts`) — a worker's tool calls as agent-eval `ToolSpan`s from EITHER an owned loop (`createPushTraceSource`; `routerToolsInlineExecutor`'s `onToolStep` feeds `record`) OR a sandbox/fleet box (`sandboxSessionTraceSource(box, sessionId, {harness})` reads `box.messages()` session parts). Harness wire-shapes are decoded by a **per-harness adapter registry** (`toolPartDecoders`): `decodeOpencodePart` decodes against the **canonical `ToolPart`/`ToolState` published by `@tangle-network/agent-interface`** (the type every adc sdk-provider normalizes into — single source of truth, so a status adc adds/renames is a compile error here, not a silent miss; terminal-state + callId-dedup; also live-box-validated), `decodeAnthropicPart` (claude-code/kimi `{type:'tool_use', id|tool_use_id, name|tool, input}` confirmed vs `cli-bridge/src/backends/claude.ts`+`kimi.ts`), `decodeOpenAiPart` (router/kimi top-level `function`). **codex emits NO structured tool calls** (bridge `codex.ts` never yields `tool_calls` — text+shell only) → per-tool detection unavailable for codex from any path (harness property, not a gap). `decodeToolPart(part, harness?)` picks the adapter or tries all. Add a harness = add a decoder + one entry, validated against the cli-bridge backend. Two consumers ride a source: ONLINE `watchTrace` (`detector-monitor.ts`) folds live spans through agent-eval's published streaming kernel (`repeatedActionDetector`/`errorStreakDetector`, the SAME kernel `control-runtime` folds) → `onSignal` → a `finding`; SETTLE `analyzeTrace` (`trajectory-recorder.ts`) collects the spans and runs the published BATCH analyzers (`buildTrajectory`/`stuckLoopView`/`toolWasteView`). `ToolSpan` is the common currency; detection logic + the failure taxonomy live in agent-eval — never reimplement here. Production target = sandbox/fleet; the owned-loop push path is for local/router/cli-bridge. The in-process queue and a future cross-box durable mailbox share this one interface. `assertTraceDerivedFindings` (`personify/analyst.ts`) is the steer-firewall (selector≠judge). `types.ts` holds `Driver`/`AgentRunSpec`/`OutputAdapter`/`Validator`/`Iteration`/`LoopResult`/`SandboxClient` + the `LoopTraceEvent` union. `sandbox-run.ts` is `openSandboxRun` — the one run/stream/resume sandbox seam; `inline-sandbox-client.ts` is `inlineSandboxClient` — the one adapter presenting any non-box `Executor` as a `SandboxClient` for `runAgentRounds`. `loop-dispatch.ts` adapts `runAgentRounds`→agent-eval campaigns; `report-usage.ts` forwards token usage so the integrity guard sees a real backend. diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 20a2b765..ad744066 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -21737,7 +21737,7 @@ The stores a supervised run needs, in-memory or file-backed. `InMemoryRunContext Config for [createExecutor](#createexecutor): the backend is DATA — the cost dial a profile, an experiment config, or a replay journal can name — not an import choice. Each -variant carries its backend's seam (router/router-tools/bridge/cli/cli-worktree/sandbox). +variant carries its backend's seam. *** diff --git a/docs/architecture.md b/docs/architecture.md index 62914b3a..4e0c1584 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -452,9 +452,10 @@ Salience filtering and the cross-box durable mailbox are not built; see **§13.6 composed as one leaf execution backend. Everything above it is the same `act`/`Scope` atom, observable as one lifecycle stream (`scope.spawn`/settle → `agent.spawn`/`agent.child`). -- **REAL** — every node materializes in its backend (sandbox / cli-bridge / router / - worktree-cli) via the one backend-as-data factory `createExecutor({ backend })` - (`src/runtime/supervise/runtime.ts:1517`). The profile says what it is; the executor +- **REAL** — every node materializes in its backend (`router`, `router-tools`, `bridge`, + `cli`, `cli-worktree`, `provider`, `sandbox`) via the one backend-as-data factory + `createExecutor({ backend })` (`src/runtime/supervise/runtime.ts:4735`). A name outside + that set is refused there by `ValidationError`. The profile says what it is; the executor says where it runs. - **REAL** — the supervisor **authoring** child profiles is the AgentProfile law (§1, and `canonical-api.md` §1.5): a supervisor's intelligence is *writing full diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 1244f080..d3cac22a 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -161,7 +161,7 @@ import type { UsageEvent, WorkerInteractiveSession, } from './types' -import { workerTraceEnv, workerTraceHeaders } from './worker-trace' +import { WORKER_TRACE_PROPAGATION, workerTraceEnv, workerTraceHeaders } from './worker-trace' import { createWorktreeCliExecutor } from './worktree-cli-executor' // ── Seam contracts (read off ExecutorContext.seams, narrowed per built-in) ───── @@ -4519,7 +4519,7 @@ export const cliWorktreeExecutor: ExecutorFactory = (spec, ctx) => { /** * Config for {@link createExecutor}: the backend is DATA — the cost dial a profile, * an experiment config, or a replay journal can name — not an import choice. Each - * variant carries its backend's seam (router/router-tools/bridge/cli/cli-worktree/sandbox). + * variant carries its backend's seam. */ export type ExecutorConfig = | ({ backend: 'router' } & RouterSeam) @@ -4670,6 +4670,20 @@ export function snapshotExecutorConfig(config: ExecutorConfig): ExecutorConfig { } case 'cli': return detachedSnapshot(config, `createExecutor ${config.backend} config`) + default: { + // The backend is DATA — a profile, an experiment config, or a replay journal names it — so + // a value outside the union reaches here untyped. Without this arm the switch returns + // `undefined`, `createExecutor` hands back a working-looking factory, and the failure lands + // one call later as a TypeError that never names the backend that caused it. + const named = (config as { backend?: unknown }).backend + // The supported list is read off the trace-propagation table rather than written out again: + // that table is `satisfies Record`, so it is the one + // copy the compiler already forces to hold every arm. + const supported = Object.keys(WORKER_TRACE_PROPAGATION).sort().join(', ') + throw new ValidationError( + `createExecutor: no backend named ${JSON.stringify(named)}; supported backends are ${supported}`, + ) + } } } diff --git a/src/runtime/supervise/supervisor.ts b/src/runtime/supervise/supervisor.ts index e8f31bed..c44147e6 100644 --- a/src/runtime/supervise/supervisor.ts +++ b/src/runtime/supervise/supervisor.ts @@ -12,8 +12,8 @@ * - Join barrier: when `act()` settles (resolve OR reject), every still-live child is * torn down before `run` returns — the generalization of the kernel's * `finally{ Promise.allSettled(destroy) }` barrier (run-loop.ts) from boxes to the - * whole sub-tree. A teardown failure is `allSettled`'d and journaled as a - * `cancelled` event; it NEVER masks act()'s own outcome. act()'s rejection is the + * whole sub-tree. A teardown failure is `allSettled`'d and journaled per node as a + * `teardown-unconfirmed` event; it NEVER masks act()'s own outcome. act()'s rejection is the * PRIMARY error (the kernel's firstError precedence), so a teardown throw during the * barrier can never overwrite the real failure. * - Abort cascade: a root abort (caller signal, `RootHandle.abort`, a tripped breaker, diff --git a/tests/runtime/executor-config-snapshot.test.ts b/tests/runtime/executor-config-snapshot.test.ts index b83bbd2e..cb485f70 100644 --- a/tests/runtime/executor-config-snapshot.test.ts +++ b/tests/runtime/executor-config-snapshot.test.ts @@ -286,4 +286,14 @@ describe('createExecutor config intake', () => { runId: 'execution-b', }) }) + + it('refuses a backend nothing implements, by name, at the call that names it', () => { + // The backend is data a profile, an experiment config, or a replay journal can carry, so a + // name outside the union reaches this factory untyped. Without a refusal here the switch + // returns undefined, createExecutor hands back a working-looking factory, and the failure + // lands one call later as a TypeError that never mentions the backend. + expect(() => createExecutor({ backend: 'bridge-worktree' } as never)).toThrow( + /no backend named "bridge-worktree"; supported backends are bridge, cli, cli-worktree, provider, router, router-tools, sandbox/, + ) + }) })