Skip to content

feat(tuval): one process table, so a graph-launched process answers the process spells - #9230

Merged
cansirin merged 1 commit into
mainfrom
can/8944-graph-processes-addressable
Sep 15, 2026
Merged

cansirin merged 1 commit into
mainfrom
can/8944-graph-processes-addressable

Conversation

@cansirin

Copy link
Copy Markdown
Contributor

SpawnedProcesses held a private live map written only by its own spawn, and src/launch/
spawned each graph node through Processes.spawn with an in-port pump of its own. A process the
config graph launched therefore had no entry in the table send, ask and read answer from, so
process send, process read, the agent MCP tools and — since #9221 — a compiled command's own-
program send all refused it with UnknownProcess. An in-port on a planned program was dead
surface, which is most of what defineProgram is for.

SpawnedProcesses.adopt folds the two paths, the way #8718 folded the title@1/status@1 latches.
It opens the out-port latches, builds a ProcessPorts that records an emit on its port's latch
before handing it to the caller's own emit, runs the caller's spawn on those ports, pumps the
caller's in-port queues and enrols the handle in the same Entry an ad-hoc spawn produces. The
spawn rides in as a start callback rather than happening before the call, so there is no ordering
that leaves a live, unreachable process behind. launch calls it per node and carries no pump any
more; the one pump left in the kernel is the one in commands/core/process.ts. Wiring.inbox hands
over 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. enrol is the single place a
process 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.

UnknownProcess no longer claims the process must have come through the process spells; it says
only that no live process carries the id. boot adds SpawnedProcesses to the context launch is
called under.

Consumers, in this PR:

  • apps/tuval/src/commands/core/process.unit.test.ts — a new block launches a three-node graph on
    the real kernel and drives the spells through the executor: send lands on a planned node's
    in-port, read answers its out-port, the route out of that same out-port still carries the
    payload to its target, the in-port's own accepts still refuses a bad payload, a sliding bound
    still 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 planned
    as a graph node, :cron run executed as the spell, and a job started on the planned process. The
    cron.ts module docblock and the run command 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__read no longer
    describe their process param as "a process you spawned". src/pi/tools.ts carried the identical
    claim and is corrected with it.
  • .decisions/0372-a-commands-cell-cannot-emit.md — its "one reach remains narrower than the
    resolution" consequence is annotated as closed. The decision itself is untouched.

Live proof, pnpm dev from apps/tuval on this head, :cron run called headlessly over the
transport against the cron the boot graph launched:

{"kind":"tuval/transport/spell-reply/v1","reply":{"type":"spell.reply","version":1,"id":"c-8944","ok":true}}
{"kind":"tuval/transport/table/v1","event":"spawned","row":{"id":"bcea2ec5-…","programId":"claude-session","parentId":"cron",…}}
{"kind":"tuval/transport/table/v1","event":"state-changed","row":{"id":"cron",…,"status":"running since 09:47:44"}}

The run finished ok: true with a real brief off a live Claude session. On origin/main that same
call answers tuval/commands/UnknownProcess.

Gates at this head, from apps/tuval: pnpm typecheck — 0 errors, 0 warnings on all three
projects. 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: adopt in apps/tuval/src/commands/core/process.ts, then the node loop in
apps/tuval/src/launch/launch.ts.

Fixes #8944

Deviations

  • Built the triaged direction with no founder ruling — Said: the issue is ready-for:human,
    and its "Suggested shape" is marked non-binding. Did: built that shape — adopt on
    SpawnedProcesses, launch registering into it, the duplicated pump collapsed — rather than
    waiting for a ruling or taking the alternative the issue names (a second lookup inside
    send/read falling back to a launch-side table). Why: the triage note names the direction
    and 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 criteria were already on the issue — Said: add an ### Acceptance criteria
    block 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 criteria block with seven
    boxes, 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.
  • Corrected a surface the issue did not name — Said: fix the tool descriptions in
    src/claude/tools/server.ts. Did: fixed src/pi/tools.ts as well. Why: it is the same
    agent-facing send/read surface for the other host and carried the identical "a process you
    spawned" claim, which this PR makes false there too. Disposition: three strings; leaving them
    would have shipped a known-false description.
  • Annotated a landed ADR — Said: no .fabrika/ edits. Did: appended one parenthetical
    to a Consequences bullet in .decisions/0372-a-commands-cell-cannot-emit.md. Why: that bullet
    asserts 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 the
    consequence's factual status moved. Disposition: flagged for the governance gate.
  • Pre-existing failure left red — Said: the unit project is green. Did: shipped with
    src/page/attached-desk.unit.test.tsx › "closes on Escape from inside the overlay" failing.
    Why: it fails identically on clean origin/main and touches nothing in this diff.
    Disposition: not this PR's to fix; named so the gate reads it as known.
  • Concurrent branch on the same file — Said: An authored spawner never hears an unsolicited child exit: stopped is only ever the answer to its own stop effect #9227 is being fixed in parallel on
    can/9227-child-exit-reaches-spawner, touching the live finalizer and cron's stopped cell.
    Did: moved that finalizer's body verbatim into enrol so both paths share it, and left the
    stopped cell alone. Why: two finalizers would have been the same duplication this PR exists
    to 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 enrol and then covers adopted processes
    too, which is the behaviour that issue wants anyway.

Base is main.

…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>
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Preview deployed

  • web — Stage pr-9230 torn down.

@cansirin

Copy link
Copy Markdown
Contributor Author

governance: PASS @ c5422b8 content:07ca73497527 — no contradiction, no weakening

Scope: governance required at c5422b8, one governed root (.decisions/, 1 file), self false — the §4 self fence did not apply. Record in diff: 0372 modified (a three-line parenthetical under the "one reach remains narrower than the resolution" consequence, noting #8944 landed and the decision is unchanged).

Questions the change decides:

  1. Does SpawnedProcesses hold every live process, or only the ones it spawned? — Now every live process: src/launch/ enrols each graph node through the new adopt, and UnknownProcess means "no live process carries that id".
  2. May a command still only send, resolving a bare port name to its own program? — Unchanged; the record says so explicitly and CommandEffect/COMMAND_HANDLERS are untouched by the diff.
  3. Is Kernel widened? — No. boot.ts at head names the same union as base (SpawnedProcesses was already in it); spawnerNeeds picks it from the kernel rather than adding a service. ProcessPorts and ProcessSelf stay out, as 0372 decision 4 and its amendment require.
  4. Does one queue now serve both a route's target and a spell's target? — Yes: Wiring.inbox widens Queue.Dequeue to Queue.Queue, with the queue's ownership and finalizer staying in the wiring's scope.

Sweep (--record 0372, bound to c5422b8): outcome shortlist, 318 uncited live-accepted records ranked. Top hits read by hand, all live accepted per adr resolve:

  • 0348 (command framework): R2.4 makes process spawn/send/read the agent tools over one registry; widening send/read reach to planned processes is in the direction R2.4 describes, and R2.2's "the page never names a process" is untouched (the scope stays kernel-resolved; the tool descriptions in pi/tools.ts and claude/tools/server.ts are the agent surface, not the page). No contradiction.
  • 0346 (Sub failure policy): its only process-table claim is that instances live in the table rather than in Context slots; this diff keeps that. No overlap in substance.
  • 0313, 0250, 0388, 0043, 0096, 0042: no shared domain beyond vocabulary.
    Also read 0353: its process-table statement is about the shell transport wire, not SpawnedProcesses. No contradiction.

Remaining staleness in 0372, not a contradiction: the consequence bullet "ProcessTable is the read ... SpawnedProcesses holds a subset" still reads as if the set differed; the amended bullet immediately below states the reach now matches, and the resolution stays over ProcessTable, so the rule it records is intact.

Gate half (guards --sha c5422b83): 0 anchored invariants in reach, 15 guard-bearing files compared block-by-block, no-anchors-in-reach. Hand read of the unanchored guards: NoReceiver at boot still fires before the first spawn in launch.ts; adopt runs the spawn inside the call so a handle cannot exist unenrolled, and Effect.onError(() => handle.stop) still tears down a half-wired process; the ad-hoc spawn path keeps its queue finalizer. No invariant removed or softened. No gate invariant is in this diff's reach.

Routed, not judged here (review's lane): wireInPorts in adopt iterates the row's declared in-ports while launch only collects queues for node.inPorts, so a row declaring an in-port the graph did not wire would die at adopt where launch previously skipped it.

@cansirin

Copy link
Copy Markdown
Contributor Author

review-code: PASS @ c5422b8 content:07ca73497527 — merge-ready

review-code — PASS at c5422b8

Graded against #8944's 7 acceptance criteria over the code-class slice (15 files). CI at head: settled / green (45 runs, 39 success, 6 skipped, 28 of 40 authored workflows produced a run).

Per-criterion

  • [PASS] process send <id> <port> <payload> reaches a graph-launched process with the spawned reply shape — apps/tuval/src/commands/core/process.ts adopt enrols the node as the same Entry spawn builds, so send runs the unchanged entryOf → accepts → offerCounting path; process.unit.test.ts "send lands on a planned node's in-port" asserts {delivered: true, evicted: 0}.
  • [PASS] process read <id> <port> answers a planned node's out-port with the pre-first-emit wait — adopt opens a latch per declared out-port via openOutPorts and wraps the caller's emit to publish on it before routing; read is untouched and still timeoutOption(latch.current, readTimeout). Test: same case reads {empty: false, value: "HI"}.
  • [PASS] UnknownProcess still answers an id no live process carries, message no longer names the spells — process.ts message is now no live process "<id>"; tests "an id no live process carries is still UnknownProcess, and says only that" and "a planned node that stopped leaves no entry behind" pin it.
  • [PASS] Planned in-port enforces accepts and bound like a spawned one — the adopted queue is the wiring's own (ports/wiring.ts open mints it at port.bound.capacity/overflow), and send checks inbox.port.accepts from the registry row. Tests: PortRefused on payload 42, evicted: 1 on the sliding node.
  • [PASS] mcp__tuval__send / mcp__tuval__read descriptions no longer say "a process you spawned" — claude/tools/server.ts SEND_DESCRIPTION, READ_DESCRIPTION and both process param descriptions; pi/tools.ts updated in step.
  • [PASS] launch.ts and process.ts do not each carry an in-port pump — launch.ts's private pump is deleted; launch spawns through spawned.adopt, and the one pump in process.ts serves both via wireInPorts.
  • [PASS] Unit test covers send/read on a graph-launched process and entry removal on stop — process.unit.test.ts "the process spells reach a graph-launched process (process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944)" block (6 cases) plus cron/cron-run.unit.test.ts proving :cron run reaches the planned cron. Removal: enrol's finalizer on handle.scope deletes the entry and sweeps pending; the stop case asserts UnknownProcess afterward.

Standing checks

  • Test honesty: no pre-existing assertion weakened; test-layer edits are additive (adopt: unreachable(...) stubs, SpawnedProcesses.layer provided where launch now requires it).
  • Release containment: none stated, none needed — the change widens reach of an existing spell to processes that were already live; no new default-on surface.
  • Comment discipline: the adopt/wireInPorts/Adoption comments carry ownership invariants (who owns the queue and its finalizer, why start rides inside the call) the types cannot express; cron.ts header and 0372 note are rewritten to present tense rather than left stale. Nothing narrates control flow.
  • Staleness: enrol binds removal to the process scope, so an adopted entry cannot outlive its process; the graph queue's finalizer stays with the wiring scope, as the comment states and wiring.ts:54 shows.
  • Governance's routed note (adopt iterates row.ports while launch hands queues for node.inPorts): checked ports/compile.ts:39-48 — node.inPorts is built from every direction: "in" entry of the same row, so the two sets are identical and the "handed no queue" die is unreachable from launch.

Fan-out (silent-failure / type-design / test-gap)

  • Silent-failure: adopt's wrapped emit records nothing on an undeclared port or a refused payload, but always delegates to the caller's emit, which answers PortNotWired/PayloadRejected — not silent.
  • Type-design: Adoption<E>.start runs inside adopt, so a live-but-unenrolled handle is unrepresentable; Wiring.inbox widened to Queue.Queue with the reason stated.
  • Test-gap (out of scope, not blocking): ask over an adopted process and adopt's onError → handle.stop path have no direct case. Neither traces to a process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944 criterion; noted for a follow-up, no criterion appended.

Deviations

entry matched against findings
issue is ready-for:human, suggested shape non-binding no finding depends on it
AC block added to #8944 before opening criteria 8944 serves 7 rows
tool descriptions fixed in claude/tools/server.ts covered by criterion 5
no .fabrika/ edits matches the file list
unit project green CI at head green
#9227 in flight on the live finalizer / cron stopped cell conflict risk disclosed; nothing here contradicts it

deviation-disclosure: PASS — nothing undisclosed this gate could see.

Verdict-written: 2026-09-15T17:00:06Z

@cansirin

Copy link
Copy Markdown
Contributor Author

review-doc: PASS @ c5422b8 content:07ca73497527 — merge-ready

review-doc — PASS at c5422b8

Doc-class slice: .decisions/0372-a-commands-cell-cannot-emit.md, a three-line parenthetical under the Consequences bullet that cited #8944 as a pre-existing gap, stating the gap has closed and the decision is unchanged.

Hygiene checklist

  • Right surface: a change to a consequence's truth value belongs on the decision record that stated it; no code-shape or findings content landed here.
  • One Diátaxis mode: the record stays explanation; the addendum is a dated status note on an existing consequence, no how-to or reference drift.
  • Supersession: nothing is replaced or contradicted — the note says so explicitly ("The decision above is unchanged — a command may still only send").
  • Status sanity: frontmatter status untouched and consistent with a live, unchanged decision.
  • Claims trace: the claim ("src/launch/ enrols every graph node in the same SpawnedProcesses table") is grounded in this PR's launch.ts (spawned.adopt) and process.ts (enrol).
  • Portability guard: N/A — no file under claude-plugins/fabrika/ or packages/fabrika-cli/src/.
  • Prose craft (writing-for-agents, applied inline): plain, one sentence per fact, nothing to re-read. PASS.

CI at head: settled / green, so link, path-leak and ADR-index gates hold.

deviation-disclosure: PASS — nothing undisclosed in the doc slice.

Verdict-written: 2026-09-15T17:00:22Z

@cansirin

Copy link
Copy Markdown
Contributor Author

routed-elsewhere: review-ui @ c5422b8 — no rendered delta: process-table wiring, tool-description strings and comments only

The ui class was raised on 7 files because uiSurfaces prefixes all of apps/tuval/src/; none of them renders anything.

  • apps/tuval/src/launch/launch.ts, apps/tuval/src/commands/core/process.ts, apps/tuval/src/boot.ts, apps/tuval/src/ports/wiring.ts — SpawnedProcesses table gains enrol/openOutPorts/wireInPorts; launch enrols every graph node; Wiring.inbox widens Dequeue to Queue; boot adds SpawnedProcesses to spawnerNeeds. Kernel plumbing, no component, style or page.
  • apps/tuval/src/claude/tools/server.ts, apps/tuval/src/pi/tools.ts — SEND_DESCRIPTION / READ_DESCRIPTION and the process parameter describe strings reworded to cover planned processes. Model-facing tool metadata, not user-visible pixels.
  • apps/tuval/src/cron/cron.ts — doc-comment updates only (process send cannot reach a graph-launched process: only ad-hoc spawns are addressable #8944 no longer cited as a blocker); no code change.

Remaining 9 files are .decisions/0372 prose and *.unit.test.ts. Text judgment is review's lane; review-code, review-doc and governance already stand PASS at this head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

process send cannot reach a graph-launched process: only ad-hoc spawns are addressable

1 participant