feat(tuval): one process table, so a graph-launched process answers the process spells - #9230
Conversation
…he spells `SpawnedProcesses` held a private `live` map written only by its own `spawn`, and `src/launch/` spawned each graph node through `Processes.spawn` with a pump of its own. A process the config graph launched therefore had no entry: `process send`, `process read`, the agent MCP tools and — since #9221 — a compiled command's own- program `send` all answered `UnknownProcess` for it, which made an in-port on a planned program dead surface. `SpawnedProcesses.adopt` folds the two paths. It opens the out-port latches, builds the `ProcessPorts` that records an emit before handing it on, runs the caller's own spawn, pumps the caller's in-port queues and enrols the handle in the same table `send`/`ask`/`read` read. `launch` calls it per node and carries no pump any more; `Wiring.inbox` hands over the whole queue, because a graph in-port is a route's target and a spell's target at once. `UnknownProcess` now says only that no live process carries the id. `:cron run` reaches the cron the desk boots, and the cron and command docblocks say so instead of citing the gap. The MCP tool descriptions no longer restrict the id to "a process you spawned". Fixes #8944 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Preview deployed
|
|
governance: PASS @ c5422b8 content:07ca73497527 — no contradiction, no weakening Scope: Questions the change decides:
Sweep (
Remaining staleness in 0372, not a contradiction: the consequence bullet " Gate half ( Routed, not judged here (review's lane): |
|
review-code: PASS @ c5422b8 content:07ca73497527 — merge-ready review-code — PASS at c5422b8Graded against #8944's 7 acceptance criteria over the code-class slice (15 files). CI at head: Per-criterion
Standing checks
Fan-out (silent-failure / type-design / test-gap)
Deviations
deviation-disclosure: PASS — nothing undisclosed this gate could see. Verdict-written: 2026-09-15T17:00:06Z |
|
review-doc: PASS @ c5422b8 content:07ca73497527 — merge-ready review-doc — PASS at c5422b8Doc-class slice: Hygiene checklist
CI at head: deviation-disclosure: PASS — nothing undisclosed in the doc slice. Verdict-written: 2026-09-15T17:00:22Z |
|
routed-elsewhere: review-ui @ c5422b8 — no rendered delta: process-table wiring, tool-description strings and comments only The
Remaining 9 files are |
SpawnedProcessesheld a privatelivemap written only by its ownspawn, andsrc/launch/spawned each graph node through
Processes.spawnwith an in-port pump of its own. A process theconfig graph launched therefore had no entry in the table
send,askandreadanswer from, soprocess send,process read, the agent MCP tools and — since #9221 — a compiled command's own-program
sendall refused it withUnknownProcess. An in-port on a planned program was deadsurface, which is most of what
defineProgramis for.SpawnedProcesses.adoptfolds the two paths, the way #8718 folded thetitle@1/status@1latches.It opens the out-port latches, builds a
ProcessPortsthat records an emit on its port's latchbefore handing it to the caller's own
emit, runs the caller's spawn on those ports, pumps thecaller's in-port queues and enrols the handle in the same
Entryan ad-hoc spawn produces. Thespawn rides in as a
startcallback rather than happening before the call, so there is no orderingthat leaves a live, unreachable process behind.
launchcalls it per node and carries no pump anymore; the one pump left in the kernel is the one in
commands/core/process.ts.Wiring.inboxhandsover the whole queue rather than the take half, because a graph in-port is a route's target and a
spell's target at once and two queues would be two halves of one port.
enrolis the single place aprocess becomes addressable and the single place it stops being — the finalizer that deletes the
entry and sweeps its correlations is now written once instead of twice.
UnknownProcessno longer claims the process must have come through the process spells; it saysonly that no live process carries the id.
bootaddsSpawnedProcessesto the contextlaunchiscalled under.
Consumers, in this PR:
apps/tuval/src/commands/core/process.unit.test.ts— a new block launches a three-node graph onthe real kernel and drives the spells through the executor:
sendlands on a planned node'sin-port,
readanswers its out-port, the route out of that same out-port still carries thepayload to its target, the in-port's own
acceptsstill refuses a bad payload, aslidingboundstill reports
evicted: 1, and a node that stopped leaves no entry behind.apps/tuval/src/cron/cron-run.unit.test.ts— the compiled-command path end to end: a cron plannedas a graph node,
:cron runexecuted as the spell, and a job started on the planned process. Thecron.tsmodule docblock and theruncommand docblock said this was impossible and cited process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944;they now say what is true.
apps/tuval/src/claude/tools/server.ts—mcp__tuval__send/mcp__tuval__readno longerdescribe their
processparam as "a process you spawned".src/pi/tools.tscarried the identicalclaim and is corrected with it.
.decisions/0372-a-commands-cell-cannot-emit.md— its "one reach remains narrower than theresolution" consequence is annotated as closed. The decision itself is untouched.
Live proof,
pnpm devfromapps/tuvalon this head,:cron runcalled headlessly over thetransport against the cron the boot graph launched:
The run finished
ok: truewith a real brief off a live Claude session. Onorigin/mainthat samecall answers
tuval/commands/UnknownProcess.Gates at this head, from
apps/tuval:pnpm typecheck—0 errors, 0 warningson all threeprojects.
npx biome check src/commands src/launch src/claude src/cron src/authoring—Checked 319 files … Found 0 errors.pnpm vitest run --project unit—Test Files 1 failed | 322 passed (323),Tests 1 failed | 3381 passed (3382), the one failure named below.pnpm vitest run --project integration—Test Files 18 passed | 1 skipped (19),Tests 85 passed | 1 skipped (86).Reviewer's first stop:
adoptinapps/tuval/src/commands/core/process.ts, then the node loop inapps/tuval/src/launch/launch.ts.Fixes #8944
Deviations
ready-for:human,and its "Suggested shape" is marked non-binding. Did: built that shape —
adoptonSpawnedProcesses,launchregistering into it, the duplicated pump collapsed — rather thanwaiting for a ruling or taking the alternative the issue names (a second lookup inside
send/readfalling back to a launch-side table). Why: the triage note names the directionand cites the kernel keeps every process's latest title@1 and status@1, and the wire row carries them #8718 as the precedent for folding rather than keeping two sources of truth; the
alternative is the one the issue itself calls out as leaving two. Disposition: stated here so
the reviewer can route it if he wants a say. Nothing about the shape is load-bearing on the tests:
a fallback-lookup version would pass the same block.
### Acceptance criteriablock to process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944 before opening the PR, because it has none. Did: left the issue body alone.
Why: the enriched body already carries a level-3
### Acceptance criteriablock with sevenboxes, and this PR was built against those seven. Disposition: no edit made; writing a second
block would have given the gate two lists to judge against.
src/claude/tools/server.ts. Did: fixedsrc/pi/tools.tsas well. Why: it is the sameagent-facing
send/readsurface for the other host and carried the identical "a process youspawned" claim, which this PR makes false there too. Disposition: three strings; leaving them
would have shipped a known-false description.
.fabrika/edits. Did: appended one parentheticalto a Consequences bullet in
.decisions/0372-a-commands-cell-cannot-emit.md. Why: that bulletasserts the gap this PR closes, and an unannotated corpus would contradict the code. Why it is
not a decision change: the decision — a command may only
send— is untouched; only theconsequence's factual status moved. Disposition: flagged for the governance gate.
src/page/attached-desk.unit.test.tsx › "closes on Escape from inside the overlay"failing.Why: it fails identically on clean
origin/mainand touches nothing in this diff.Disposition: not this PR's to fix; named so the gate reads it as known.
can/9227-child-exit-reaches-spawner, touching thelivefinalizer and cron'sstoppedcell.Did: moved that finalizer's body verbatim into
enrolso both paths share it, and left thestoppedcell alone. Why: two finalizers would have been the same duplication this PR existsto remove, and the move is byte-identical inside the closure. Disposition: the rebase is one
hunk — whatever An authored spawner never hears an unsolicited child exit: stopped is only ever the answer to its own stop effect #9227 adds to the finalizer lands in
enroland then covers adopted processestoo, which is the behaviour that issue wants anyway.
Base is
main.