- five stories status: done; 00-story records the one-run landing - spec History: three implementation amendments (Signal record not scalar, caller-owned stdio fds, handler-latch instead of signalfd) - board NEXT PLAN entry with measured findings (zero transport code added; the tty-across-the-socket handover proven; the double-raw refusal restoring the terminal — the "bug" that was the design working); section rows flipped; graph nodes green - CODE-LOGIC.md: the runtime-v2 section - full belt quoted on the board: suites 0 fail both flavors (test_proc 193/0, test_term 60/0), woc 557/0, subprocess 12/0, site 23/0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit bc1b4f070693eb755ad6a9fd0c853fb3e2bda347)
12 KiB
runtime-v2 (iterations 1–5) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.Project rule (overrides the plan-skill template): plan docs carry concept, reason and actions in words — no code blocks. Steps name exact functions, ids, fields and expected outcomes; the implementer writes the code at the keyboard against the cited anchors.
Goal: land all five runtime-v2 iterations — streaming subprocess, PTY, signals-as-events, termios adoption, fd passing — as builtins on the existing park plane, per the track spec.
Architecture: acquisition verbs only, never transport: children and
received fds are ordinary fds the existing net.read_dl/write_dl/
close drive. The wo_child registry grows a streaming variant; a
per-shard termios table and shard-0 signalfd are the only new state.
Tech Stack: C (runtime), OCaml table rows (compiler), .wo +
bash (gate legs).
Spec: docs/superpowers/specs/2026-09-01-runtime-v2-design.md.
Global Constraints
- Ids 97–107, in this order: 97
PROC_SPAWN, 98PROC_WAIT_DL, 99PROC_SIGNAL, 100PROC_SPAWN_PTY, 101PROC_RESIZE, 102SIGNAL_ON, 103TERM_RAW, 104TERM_RESTORE, 105NET_SEND_FD, 106NET_RECV_FD, 107NET_CONNECT_UNIX.WO_B_MAX→ 107. No.wobbump. - Every id = FOUR registrations:
wob.henum,loader.cb_arity,builtin.cdispatch range (extend the<= WO_B_PROC_RUN_DLbound to<= WO_B_NET_CONNECT_UNIX),types.mlrow. The 42 lesson. - Spec amendment 1 (verified 2026-09-01): message payloads are
unconditionally
wo_drop_obj'd (vm.c:887,actor_die,actor_activate) — a scalar payload is a crash.signal.ontherefore delivers a fresh predeclaredSignal {sig Int}record per delivery, class id appended by the compiler (loader arity 3). Record the amendment in the spec's History when closing. - Spec amendment 2: a streaming slot does NOT own the caller-visible
stdio fds (fd-reuse hazard: the sweep could close a recycled number).
The caller owns Child.in/out/err and closes them with
net.close; the slot owns pid + pidfd + (PTY only) a privatedupof the master for resize. Sweep = kill, reap, close pidfd (+ master dup). - PTY via
posix_openpt/grantpt/unlockpt/ptsname_r— plain libc, NO-lutil, no Makefile change. - Raw syscalls where glibc 2.35 lacks wrappers (pidfd already handled);
signalfdhas a wrapper since 2.8 — use it. - SIGTERM/SIGINT registration refused by name; the stop latch stays the engine's.
- All work on
dev, commits prefixedfeat(rt2):/docs(rt2):; builds and tests only via just recipes; ASan+UBSan clean is a gate. - Suite home:
runtime/test/test_proc.cgrows spawn/wait/pty legs; newruntime/test/test_term.cfor termios + signals + fd-passing (auto- globbed).
Task 1: streaming spawn — proc.spawn, proc.wait_dl, proc.signal (rt2 iteration 1)
Files:
- Modify:
runtime/src/wob.h(ids 97–99),runtime/src/loader.c(arities: spawn 3 — cmd, argv, cls; wait_dl 2; signal 2),runtime/src/builtin.c(dispatch bound),runtime/src/sysio.c(three cases + slot changes),runtime/src/vm.h(wo_childgainsstreaming,waiter,master_dupfields),runtime/src/vm.c(actor_diesweeps children owned by the dying actor),compiler/src/types.ml(Childrecord — id/in/out/err, all Int — instdlib_records; rows for the three verbs). - Test:
runtime/test/test_proc.c.
Interfaces:
-
Produces:
proc.spawn(cmd, args) -> ?Child,proc.wait_dl(id, ms) -> ?Int,proc.signal(id, sig) -> 0; slot ownership fieldowner_actor(awo_actor*, NULL = program) that Tasks 2–5 reuse. -
Step 1 (red): legs in
test_proc.cdriving the new ids from bytecode: (a) spawncat,net.write_dla line toChild.in, read it back fromChild.outvianet.read_dl— the echo round trip; (b)wait_dlon a fast child answers its code, on asleep 10with ms=100 answers nil and the child is STILL alive (thenproc.signalSIGKILL, wait again, code observed, ECHILD after vm destroy); (c) second concurrentwait_dlon one id refuses by name (two fibers); (d) spawn-loop fd-flat leg with the caller closing all three fds each round. Runjust wovm-test— all four fail (unknown builtin). -
Step 2 (green): implement. Spawn: three pipes (stdin write end stays parent-side as Child.in), parent ends
O_NONBLOCK, fork/execvp (argv rules identical to 42's), pidfd, claim slot (streaming = 1, no epoll bundle, no buffers), owner =vm->cur->actor(NULL when none), build the Child record viarecord_of(4 fields).wait_dl: slot lookup by id (id = slot index + a generation counter to refuse a stale id by name), waiter-claim refusal, park on the pidfd withdl_active/dl_at, on exit reap + release slot + answer code, nil at deadline (child untouched).signal:pidfd_send_signalthrough the slot.actor_diecalls a newwo_proc_abandon_actor(vm, a)killing every slot whose owner isa. Sweeps close pidfd only (amendment 2). -
Step 3:
just wovm-testgreen both flavors; commitfeat(rt2): proc.spawn/wait_dl/signal — the streaming childwith the compiler row in the same commit (just woc-build && just woc-testfirst).
Task 2: PTY — proc.spawn_pty, proc.resize (rt2 iteration 2)
Files: same four registration files (ids 100–101; spawn_pty arity 5
— cmd, argv, cols, rows, cls; resize 3), runtime/src/sysio.c,
runtime/test/test_proc.c.
Interfaces:
-
Consumes: Task 1's slot, Child record, ownership.
-
Produces:
proc.spawn_pty(cmd, args, cols, rows) -> ?Child(in==out= master, err nil),proc.resize(id, cols, rows) -> 0. -
Step 1 (red): legs: (a) spawn_pty
sh -c 'test -t 0 && echo yes-tty'— read "yes-tty" back (isatty proof); (b) spawn_pty with 24x80 then a child runningstty size— read "24 80"; resize to 40x120, re-ask via a second child? No — one child that sleeps then prints size after a marker write; simpler: child =sh -c 'read x; stty size'— resize between spawn and the marker write, expect "40 120"; (c) resize on a Task-1 pipe child refuses by name. Red run. -
Step 2 (green):
posix_openpt(O_RDWR|O_NOCTTY),grantpt,unlockpt,ptsname_r; child:setsid, open slave (becomes controlling tty), dup2 onto 0/1/2,TIOCSWINSZinitial size, exec. Parent: masterO_NONBLOCK, slot stores a privatedupof the master (master_dup) for resize; Child.in == Child.out == master, err = 0 (nil). Resize:ioctl(master_dup, TIOCSWINSZ)+ refusal by name when the slot is not a PTY child. Sweep closesmaster_dup. -
Step 3: suites green; commit
feat(rt2): spawn_pty + resize — a child that believes it owns a terminal.
Task 3: signals as events — signal.on (rt2 iteration 3)
Files: registrations (id 102, arity 3 — sig, addr, cls),
runtime/src/sysio.c or builtin.c for the case, runtime/src/park.c
(signalfd on shard 0's plane beside the wake eventfd, sentinel
user_data), runtime/src/vm.h (per-vm subscription list {sig, actor}),
compiler/src/types.ml (Signal {sig Int} record + row),
runtime/test/test_term.c (new).
Interfaces:
-
Consumes:
runtime_notify(vm.c:1326, the timer delivery path) for handing a fresh payload to an actor. -
Produces:
signal.on(sig, addr) -> 0; delivery = freshSignal{sig}record per arrival (spec amendment 1). -
Step 1 (red): test_term.c: module with an actor whose receive pushes
msg.siginto a shared multi; main registerssignal.on(SIGUSR1, addr), thenproc.run("sh", ["-c", "kill -USR1 $PPID"]), then sleeps briefly; assert the multi holds SIGUSR1's number. Second leg:signal.on(SIGTERM, …)refuses naming the stop latch. Red. -
Step 2 (green): first registration on shard 0 creates the signalfd (mask grows per registration;
pthread_sigmaskblocks the sig process-wide first — document: registration must happen before worker shards spawn or the mask is per-thread incomplete; v1 rule: register from shard 0/main, refusal by name elsewhere). park.c: the signalfd is registered like the wake eventfd (oneshot POLL_ADD under uring, level under epoll) with its own sentinel; on readiness drainsignalfd_siginforecords, for each match allocateSignal{sig}viawo_obj_newandruntime_notifythe subscribed actor(s). -
Step 3: suites green (both WO_IO backends — the fibers gate pattern proves uring AND epoll); commit
feat(rt2): signal.on — signalfd delivers Signal records to actors.
Task 4: termios — term.raw, term.restore (rt2 iteration 4)
Files: registrations (ids 103–104, arity 1 each),
runtime/src/sysio.c (cases + the per-shard saved-termios table in
wo_vm — 8 entries {fd, termios, owner fiber}), runtime/src/vm.c
(restore sweep in fib_reap and wo_vm_destroy beside the proc
sweeps), runtime/test/test_term.c.
- Step 1 (red): legs using a PTY pair made in the TEST via
posix_openpt(C-side, no builtin): (a)term.raw(slave_fd)thentcgetattrshows ECHO/ICANON cleared;term.restore(slave_fd)brings the saved flags back bit-identically; (b) double-raw refuses by name; (c) raw then DELIBERATE trap in the fiber — after the trap the fd's termios are restored (the runtime obligation); (d) restore on an fd never raw'd refuses by name. Red. - Step 2 (green): table claim (full table refuses by name),
tcgetattrsave,cfmakeraw,tcsetattr; restore verb frees the entry;fib_reapandwo_vm_destroyrestore entries owned by the dying fiber / all, newest first. - Step 3: suites green; commit
feat(rt2): term.raw/restore — no wrecked tty, ever.
Task 5: fd passing — net.send_fd, net.recv_fd, net.connect_unix (rt2 iteration 5)
Files: registrations (ids 105–107; arities 2/1/1),
runtime/src/sysio.c, runtime/test/test_term.c.
- Step 1 (red): legs: (a)
net.listen_unix+net.connect_unixpair inside one vm (two fibers: acceptor and connector); (b) create a pipe in C,send_fdits read end across the socket,recv_fdit, write into the pipe's write end,net.read_dlfrom the RECEIVED fd answers the bytes; (c)send_fdon a TCP socket refuses by name; (d)recv_fdwhen the peer sent plain bytes answers nil; (e) a tty fd (test PTY slave) crosses andterm.rawworks on it — the wmux handover in miniature. Red. - Step 2 (green):
connect_unix: socket AF_UNIX, connect,O_NONBLOCKafter.send_fd:SO_DOMAINcheck (refusal),sendmsgwith oneSCM_RIGHTSfd in a fixedCMSG_SPACE(sizeof(int))buffer and one sentinel data byte; EAGAIN parks (POLLOUT, the write mould).recv_fd:recvmsgwith the same buffer; EAGAIN parks (POLLIN); a message without ancillary fd answers nil; received fd setO_NONBLOCK. - Step 3: suites green; commit
feat(rt2): send_fd/recv_fd/ connect_unix — an fd crosses the socket.
Task 6: close-out
Files: runtime/src/CODE-LOGIC.md (runtime-v2 section),
docs/superpowers/specs/2026-09-01-runtime-v2-design.md (History —
the two amendments), the five story files (status: done + Progress),
docs/stories/00-status.md (NEXT PLAN entry, section rows ✅),
docs/00-dependency-graph.md (nodes → done class).
- Step 1: full belt:
just wovm-test,just woc-test,just subprocess,just site— quote results, never assert. - Step 2: write the docs; commit
docs(rt2): close out runtime-v2 1–5.
Self-review (at write time)
- Spec coverage: every surface row has a task; both amendments carried into Tasks 1 and 3 and recorded for the spec's History in Task 6. Out-of-scope items appear in no task.
- Ids consistent 97–107 across tasks;
Child/Signalrecords named identically throughout. - Deliberate verify-first flags: the shard-0-only registration rule for
signals (mask is per-thread — confirm where worker threads inherit
the mask), and
runtime_notify's exact signature before reuse.