Skip to content

ctl C1: opt-in control plane — channel + spawn/list, spawn tree, caps - #2

Merged
JSBtechnologies merged 1 commit into
mainfrom
feat/ctl-c1
Aug 30, 2026
Merged

ctl C1: opt-in control plane — channel + spawn/list, spawn tree, caps#2
JSBtechnologies merged 1 commit into
mainfrom
feat/ctl-c1

Conversation

@JSBtechnologies

Copy link
Copy Markdown
Contributor

What & why

First milestone of the amux ctl control plane (designroadmap/amux-ctl-control-plane.md): turn amux from a viewer of agents into a runtime for a controllable, observable hierarchy. An agent (or the human) in one pane can spawn and list other agent panes over a tiny local control channel — every worker a normal, visible, killable pane.

Severe/critical per charter §3 (a hosted agent gains process-spawn power) → PR + review. The C0 spike already proved the two load-bearing unknowns (zero-dep named-pipe IPC + non-blocking drain); this builds the real thing on that.

Surface (all behind --allow-ctl, off by default)

amux --allow-ctl [--max-depth <N>] [command…]     # bind the channel
amux ctl spawn [--role R] -- <cmd>                 # open a visible worker → {ok,pane,session,role}
amux ctl list                                      # the org chart → {ok,tree:[{id,parent,role,depth,status}]}

How it's built

  • ipc.rs — zero-dep channel: Windows named pipe (PIPE_NOWAIT, the C0-verified design, in the same kernel32-FFI house style as pty/cred) / unix socket. Non-blocking Listener::poll/respond, drained from the run loop with no thread.
  • ctl.rs — the amux ctl client + protocol. evaluate_spawn() is the pure safety guard (allowlist + depth), unit-tested in isolation.
  • main.rs--allow-ctl/--max-depth; every pane gets a global agent_id and is born with AMUX_CTL/AMUX_PANE in its env (merged with identity secrets, which stay per-spawn and unlogged); apply_ctl applies requests as pane ops.

Safety model (design §5) — the heart of the review

  • Opt-in, off by default. No --allow-ctl ⇒ no pipe, amux ctl refuses, behavior byte-identical to 0.6.1.
  • Agents-only allowlist ({claude}), extensible per session by the human via AMUX_CTL_ALLOW — never weakens a session that didn't ask.
  • --max-depth (default 6, 0=unlimited) bounds recursion, not width — a fork-bomb circuit-breaker; a designed fleet of any size still runs.
  • Visibility = safety. Every ctl-spawned agent is a normal pane: in the bar, in ctl list, killable.
  • Identity secrets are merged into the child env for one spawn only, never cached/logged; only names surface.

Verification

python dev.py check green — 112 lib + 43 integration tests, dep-guard OK, fmt + clippy clean. New coverage:

  • pure guard (allowlist / depth / AMUX_CTL_ALLOW), request build↔parse roundtrip, flag parsing;
  • the ipc roundtrip (C0 spike promoted to a permanent Windows test);
  • three live e2e tests driving a real amux over the pipe: list roundtrip, spawn opens a visible worker window, off-allowlist refusal returns a clean JSON error.

Not in C1 (C2–C3)

ctl send / status / kill, credential delegation scoping, --here splits. --here currently returns a clear "lands in C2" error rather than silently opening a new window.

…tree, caps

amux can now host a *controllable, observable* hierarchy of agents. This is the
first milestone of the amux-ctl-control-plane design (severe/critical: a hosted
agent gains process-spawn power, so the safety model is the heart of it).

What's added (all behind --allow-ctl; off by default):
- ipc.rs: a zero-dep local control channel — Windows named pipe / unix socket,
  with a non-blocking Listener::poll/respond and a client request(). The Windows
  path is the C0-verified PIPE_NOWAIT design; the roundtrip is a permanent test.
- ctl.rs: the `amux ctl` client + protocol. `spawn` (open a visible worker pane)
  and `list` (the spawn tree as JSON). evaluate_spawn() is the pure safety guard
  (agent allowlist + --max-depth recursion cap), unit-tested in isolation.
- main.rs: --allow-ctl / --max-depth flags; each pane is stamped with a global
  agent_id and born with AMUX_CTL/AMUX_PANE in its env (merged with identity
  secrets, which stay per-spawn and unlogged); the run loop drains the channel
  each tick and applies requests as pane ops (apply_ctl).

Safety (design §5): opt-in off by default; agents-only allowlist ({claude},
extensible per-session via AMUX_CTL_ALLOW); --max-depth (default 6, 0=unlimited)
bounds recursion, never width; every worker is a normal visible/killable pane in
the org chart. No --allow-ctl ⇒ no pipe, `amux ctl` refuses, behavior identical.

Verified: `python dev.py check` green — 112 lib + 43 integration tests. New
coverage: pure guard (allowlist/depth/extra-allow), request build/parse
roundtrip, flag parsing, the ipc roundtrip (C0 promoted), and three live e2e
tests driving a real amux over the pipe (list roundtrip, spawn opens a visible
worker window, off-allowlist refusal). fmt + clippy clean.

Not in C1 (C2–C3): ctl send/status/kill, identity delegation, --here splits.
@JSBtechnologies
JSBtechnologies merged commit 0997bd4 into main Aug 30, 2026
1 check failed
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
From the fleet review of 133a1ca. `install()` discarded `signal()`'s return, so
a SIG_ERR left the disposition at SIG_DFL: the process would still die where it
stood and orphan every agent, silently reintroducing the exact bug this module
was written to fix. A cleanup path that can fail to arm without saying so is
worse than none, because it reads as covered.

Now checks against SIG_ERR and warns on stderr naming the signal. install()
runs at the top of main, before raw mode, so the warning is readable.

The reviewer was right and this was mine — the original commit asserted the
handler was installed rather than verifying it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
`pty.kill()` is SIGKILL to the direct child alone. Everything that child
spawned — for an agent CLI, its MCP servers, language servers and node helpers
— survived and was reparented onto init. This was not a signal-handling edge
case: it leaked on a clean `Ctrl+A q`, and has since day one. Demonstrated by
spawning a grandchild in a pane, quitting normally, and watching it live on. A
fleet review measured 23 leaked agent processes holding 4.6 GB.

Unix has no "kill this tree" primitive, but the machinery was already here and
merely unreachable: `pty` runs `setsid()` in `pre_exec`, so every pane child is
a session leader whose process-group id equals its pid. One `killpg` reaches
the whole subtree. `pty-rs` e8da3c2 exposes the pid; Cargo.lock moves with it.

Two policy choices live in `amux::reap` rather than in `pty`:

  * SIGTERM before SIGKILL. The old bare SIGKILL is uncatchable, so it
    *guaranteed* orphans — the agent never got the chance to reap its own
    children. Terming first lets a well-behaved one tear its tree down itself.
  * Signal every group, then wait once. A per-pane grace would make teardown
    O(panes) in wall clock: ten panes at 750 ms is a seven-second quit.

Teardown also stops claiming success it did not achieve: any group still alive
afterwards is named on stderr. A cleanup path that fails silently is how 4.6 GB
went unnoticed.

Windows is unchanged and says so in code rather than pretending: it has no
POSIX process groups, and the real answer there is a Job Object created at
spawn with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — a kernel guarantee stronger
than anything this achieves on unix. Tracked next.

Stated limit: a grandchild that calls setsid() itself leaves the group and this
cannot reach it. macOS offers no container to catch that (no cgroups, no Job
Objects); the durable registry sweep is the intended backstop.

The regression test spawns its grandchild through a NON-interactive shell on
purpose — `sh -i` runs job control, which puts each background job in its own
process group, the one arrangement killpg cannot follow and not how an agent
forks helpers. The grandchild reports its pid to a file keyed by the test
process, because scanning the process table is racy under the parallel suite.

macOS 26.6.2: ./dev.py check green — 304 tests, 0 failures, fmt and dependency
guard clean. Type-checks for x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
…fied on real Windows]

Fills the src/reap.rs cfg(not(unix)) stub with the durable Windows teardown layer
from the design spec: one Job Object per session with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
Every pane is assigned to it; amux holds the sole non-inheritable handle for its
whole life; the kernel terminates every process in the job — children inherit it,
so grandchildren too — the instant that handle closes, however amux exits,
TerminateProcess included. That is the one death mode no unix layer can match.

- SessionJob (src/reap.rs): no-op on unix; on Windows creates the job (kill-on-close,
  non-inheritable handle per R2), assign() adds a pane, Drop closes the handle.
  Win32 externs + constants + repr(C) structs (IoCounters / JobBasic / JobExtended)
  transcribed from winnt.h; usize for SIZE_T/ULONG_PTR (correct on 32- and 64-bit).
- main.rs: session_job created once in run(), held for the session, each new pane
  assigned in the registry loop. spawn_watchdog + its var are now cfg(unix) — on
  Windows the job replaces the watchdog, which there could not signal a group and
  would block on its pipe forever (R5). amux reap stays as the near-inert manual path.
- R1: create/assign failures degrade to a debug warning and a null-handle no-op; a
  pane is never refused. GetLastError captured immediately after each failing call
  (before CloseHandle) per review.

VERIFIED ON REAL WINDOWS (what the spec's macOS author could not do): acceptance
corruption), #2 clean-quit kills the pane's grandchild, #3 taskkill /F of amux
still takes the tree down via kill-on-close, #6 full dev.py check green (237 tests).
R4 confirmed: hard-killing amux leaves no conhost residue. Adversarially reviewed
(rust-reviewer): verdict ship, layout exact on both bitwidths, handle inheritance
correctly prevented, every failure path a safe no-op, no double-close.

Also (pre-existing, distinct): fixed closing_an_overlay_repaints_the_pane, which
shipped in the unix-teardown work typing a bash-only marker into cmd.exe and could
never pass on Windows (confirmed failing at clean 43e7a6e). Branched the marker for
cmd. Exactly the cargo-check-is-not-a-run gap the spec warned about.
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.

1 participant