HOMELAB-1558: feat(subagents): Phase 3 — full duplex attach (streaming subscribe, verbs, driver, interactive pane client) #26

Merged
aaron merged 6 commits from plane/HOMELAB-1558-phase3 into main 2026-09-19 06:23:47 +00:00
Owner

What

Phase 3 of HOMELAB-1555 (ticket HOMELAB-1558): full duplex attach — the epic's headline feature.

  • Slice 5 (6ef2223): activity bus tapped at the manager's passthrough sites (covers Agent-tool + scheduler + RPC spawns) + streaming subscribe on the 0600 attach socket — bounded coalescing fan-out, backpressure drops text_delta never status, desync frame → snapshot re-sync.
  • Slice 6 (ba325d7): prompt/answer verbs (answer reuses AgentManager.resume()), steer mode:followUp, advisory driver indicator (agent|lead|human, last-steer-wins, announced per Phase 2 contract), subagents:rpc:watch/unwatch bus twin, PROTOCOL_VERSION 3→4 advertised per-connection + in the manifest.
  • Slice 7 (c774e43): pi-tui interactive pane client (src/watch/pane.js, owner decision O1) — live scrollback, streaming line, input box; type-to-steer mid-run; Ctrl-C twice leaves and can never abort; pane close = unsubscribe never abort; reconnect re-subscribes with bounded snapshot; compaction boundary + monotonic usage. Degrades to the drift-tested tail renderer when the socket is absent (O2 fallback).
  • Slice 8 (1bd625d): docs/attach-protocol.md reference (drift-tested against the frame module), field-allowlist security test, crash matrix (mid-fan-out / mid-flush / mid-teardown / dead-parent sweep), README.

Owner decisions O1–O5 implemented (pi-tui client · interactive default · advisory announced ownership · one pane per agent · terminal answer fails clean).

⚠️ Explicit open point for review (owner-intent reading): O2 was implemented as "the interactive client is the default once a pane opens; tail is the socket-absent fallback." The stricter reading — background spawns with panes enabled default to the interactive client (i.e., watchPane: "interactive" as the default when opting in) — is NOT implemented: watchPane still defaults off, and split|tab open the tail renderer. If the owner intended the strict reading, it's a one-line default change; reviewer to adjudicate.

Verify

  • tsc --noEmit clean
  • vitest run twice: 76 files / 784 tests green both runs
  • 30-min soak + live herdr end-to-end acceptance (manual, after epic-end 0.9.1 upgrade)

Refs HOMELAB-1558, epic HOMELAB-1555. Design: interactive-subagent-sessions-phase3.md.

## What Phase 3 of HOMELAB-1555 (ticket HOMELAB-1558): full duplex attach — the epic's headline feature. - **Slice 5** (`6ef2223`): activity bus tapped at the manager's passthrough sites (covers Agent-tool + scheduler + RPC spawns) + streaming `subscribe` on the 0600 attach socket — bounded coalescing fan-out, backpressure drops `text_delta` never status, `desync` frame → snapshot re-sync. - **Slice 6** (`ba325d7`): `prompt`/`answer` verbs (`answer` reuses `AgentManager.resume()`), `steer mode:followUp`, advisory `driver` indicator (`agent|lead|human`, last-steer-wins, announced per Phase 2 contract), `subagents:rpc:watch/unwatch` bus twin, `PROTOCOL_VERSION 3→4` advertised per-connection + in the manifest. - **Slice 7** (`c774e43`): **pi-tui interactive pane client** (`src/watch/pane.js`, owner decision O1) — live scrollback, streaming line, input box; type-to-steer mid-run; Ctrl-C twice leaves and can never abort; pane close = unsubscribe never abort; reconnect re-subscribes with bounded snapshot; compaction boundary + monotonic usage. Degrades to the drift-tested tail renderer when the socket is absent (O2 fallback). - **Slice 8** (`1bd625d`): `docs/attach-protocol.md` reference (drift-tested against the frame module), field-allowlist security test, crash matrix (mid-fan-out / mid-flush / mid-teardown / dead-parent sweep), README. **Owner decisions O1–O5 implemented** (pi-tui client · interactive default · advisory announced ownership · one pane per agent · terminal answer fails clean). **⚠️ Explicit open point for review (owner-intent reading):** O2 was implemented as "the interactive client is the default *once a pane opens*; tail is the socket-absent fallback." The stricter reading — *background spawns with panes enabled default to the interactive client* (i.e., `watchPane: "interactive"` as the default when opting in) — is NOT implemented: `watchPane` still defaults `off`, and `split|tab` open the tail renderer. If the owner intended the strict reading, it's a one-line default change; reviewer to adjudicate. ## Verify - [x] `tsc --noEmit` clean - [x] `vitest run` **twice**: 76 files / **784 tests green both runs** - [ ] 30-min soak + live herdr end-to-end acceptance (manual, after epic-end 0.9.1 upgrade) Refs HOMELAB-1558, epic HOMELAB-1555. Design: `interactive-subagent-sessions-phase3.md`.
Phase 3a — the wire half of full-duplex attach. The parent's request/reply
control socket grows a streaming method, fed from callbacks the parent ALREADY
receives, so nothing new is emitted by the runner and no pi-core change is
needed.

* activity-bus.ts (new): the manager-level `onActivity()` feed + typed
  `ActivityEvent`s. Subscriber faults are isolated (a bad listener can never
  break a run).
* agent-manager.ts: one fan-out at each existing run-passthrough site
  (onTextDelta / onToolActivity / onAssistantUsage / onCompaction /
  onSessionCreated, in BOTH startAgent and resume), a status tap on the roster
  funnel (one event per STATUS CHANGE, after the terminal pane hook), the
  advisory driver map (getDriver / setDriver + decay), and resume re-arm
  (clear paneTerminalNotified + onResumed) per design D10.
* agent-runner.ts: resumeAgent gains onTextDelta — suite-side only, so resumed
  runs stream text too.
* attach/stream-frames.ts (new): the one definition of the streaming vocabulary
  (frame types, limits, channels, driver state) shared by the socket and the
  in-process watch twin. PROTOCOL_VERSION 3 → 4 (D6).
* attach/subscriptions.ts (new): the bounded, coalescing fan-out — 50 ms delta
  coalescing, 256-frame/256 KiB per-connection queues, drop-oldest-droppable
  (never status/usage/compaction/driver), exactly one desync frame per overflow
  episode, writes that never block a producer.
* attach/snapshot.ts (new): bounded snapshot from the LIVE record.session
  (reuses view/output-tail's sessionToEntries), with a prompt+result fallback.
* attach/attach-server.ts: subscribe/unsubscribe dispatch (reply first, then
  frames from seq 1), per-connection seq + heartbeat, connection/subscription
  caps, per-subscription isolation, and disconnect semantics that are
  structurally incapable of touching an agent.
* attach/roster-json.ts: additive streaming / pendingMessages / driver fields —
  one definition, three consumers (list, rpc:list, attach.json).
* attach/attach-manifest.ts: protocolVersion + capabilities (additive; schema
  version stays 1).
* attach/human-announce.ts: additive verb / driver / pending payload fields.
* core/index.ts: wire the one case the parent closes a pane (D13,
  closeOnTerminal).

Tests: tests/core/activity-bus.test.ts (7), attach-stream-registry.test.ts (18),
attach-stream.test.ts (19); the Phase-2 "subscribe is a reserved stub" test is
rewritten (not deleted) into the Phase-3 handshake assertion. Full suite:
71 files / 708 tests green, tsc clean.
Phase 3b — the verbs a pane needs, the honest "who is driving" label, and the
in-process twin of `subscribe`.

* agent-manager.ts: `answer(id, text)` — the delivery channel for a live child
  (pi's own `session.followUp`, which lands when the agent would otherwise stop).
* attach/activity-frames.ts (new): the ONE activity→frame translation, shared by
  the socket and the bus twin so they cannot drift.
* attach-server.ts: `steer` gains `mode: "steer"|"followUp"` (default = Phase-2
  byte for byte) and an optional `client` label; `prompt`/`answer` verb with the
  curated failures and the extended announcement. A request in flight holds the
  transport pump back so a verb's OWN driver frames can never precede its reply.
* attach-driver: `driver`/`streaming`/`pendingMessages` on `list`, the manifest,
  `rpc:list`, every snapshot and a `driver` frame; the label decays back to
  "agent" when the driven turn ends (manager status tap).
* cross-extension-rpc.ts: `subagents:rpc:watch` / `unwatch` streaming the same
  frames on `subagents⌚<subId>`, each registration its own pseudo-
  connection; `rpc:list` carries the driver fields; `rpc:steer` sets
  `who:"human"` and announces `driver`/`pending`. `RpcHandle` gains
  unsubWatch/unsubUnwatch/unwatchAll, wired into disposeSession.
* panes/pane-tracker.ts: `onResumed` clears the one-shot latches (fresh sentinel
  + fresh `watching` metadata on the next run) — the manager already clears
  `paneTerminalNotified` before flipping the status back to running (§D10).
* core/index.ts: the lead's `steer_subagent` tool sets `who:"lead"`.
* bin/swarm-attach.js: `subscribe <id> [--events …]` (persistent JSONL stream,
  exits on a terminal status, Ctrl-C = disconnect, never an abort) and
  `answer <id> <text>`.

OWNER DECISIONS / DEVIATIONS (documented, not silent):
- O5 is binding: `answer` on a TERMINAL agent fails clean with a curated status
  message. That narrows D4's target to a RUNNING child, where `resume()` is the
  wrong mechanism (it re-terminalizes a run that is still in flight), so the
  delivery uses `session.followUp()` instead. `answer` and `steer
  mode:"followUp"` therefore share one channel and differ only in the announced
  verb. D4's Slice-6 manual item ("answer an agent that finished 1 minute ago")
  is consequently NOT implementable and is superseded by O5.
- D5's "attach-server-owned driver map" is hosted on the AgentManager instead:
  `list`, `attach.json` and `rpc:list` all need it, and the RPC layer must not
  depend on the attach server. It is still NOT an AgentRecord field.

Tests: attach-verbs (14), attach-driver (6), rpc-watch (9), pane-tracker +3,
swarm-cli +6. Full suite: 74 files / 746 tests green, tsc clean.
Phase 3c — the pane becomes a client you can type into.

* src/watch/{parse,render}.js (new, EXTRACTED from renderer.js): the Phase-1
  parser and ANSI scrollback renderer, now shared verbatim by the tail renderer
  and the interactive client. renderer.js imports them; its behavior and its
  re-exported symbols are unchanged, so the Phase-1 parser drift test
  (tests/core/watch-renderer.test.ts) still guards BOTH programs.
* src/watch/view-model.js (new): the "thin adapter" R-3.5 — frames →
  ConversationViewer-shaped {header, entries, streamingLine, footer}. The model
  is PLAIN TEXT by design (see the deviation note) and every mutation the pane
  performs on a frame lives in applyFrame(), so the reducer is unit-testable.
* src/watch/pane-keys.js (new): the key decisions as a PURE function, because
  this is the only place a UI mistake can stop an agent — `x` twice (armed, 2 s)
  is the single abort path, Ctrl-C twice exits and can never abort, and
  single-letter commands only arm while the input box is empty.
* src/watch/pane.js (new): the pane client. pi-tui based (OWNER DECISION O1),
  with the tail renderer as the documented fallback (O2): it discovers the
  attach socket, subscribes, renders live tokens/tools/usage/driver, and when
  the socket is absent or dies it tails the agent's .output through parse.js +
  render.js while retrying the socket (1s→2s→5s→10s). Disconnect is an
  unsubscribe and never an abort; a terminal status does NOT exit the client
  (the record stays answerable).
* pane-tracker: `watchPane: "interactive"` opens a split pane running pane.js,
  advertises `interactive` in the pane metadata, re-opens an attach-mode pane on
  resume whose pane id was cleared, and clears `record.paneId` when the socket
  reports zero subscribers (D8.3/D13 — liveness rides the socket, no second
  herdr protocol).
* attach server/registry: `onSubscriberChange` hook (the only notification a
  disconnect produces).
* settings + suite-settings: `watchPane` gains `"interactive"`; off|split|tab
  keep their exact Phase-1 meanings.
* bin/swarm-attach.js: `watch <agentId>` resolves the agent and execs the pane
  client in the current pane with inherited stdio.

DEVIATION (documented, not silent): O1 makes the pane pi-tui based, so D7's
RSS/dependency analysis is revised rather than merely overridden — a pi-tui pane
is a node process carrying the pi-tui module graph (~45–60 MB), not the ~35 MB
zero-dep renderer, and it is still 5–25x cheaper than a pi process. The model is
emitted as plain text (the TUI must be able to MEASURE every line it draws;
ANSI in the model breaks the width arithmetic) — the Phase-1 tail renderer keeps
its ANSI output.

Tests: watch-pane (19: view-model drift/compaction/desync/reconnect, the key
guards, headless render, orphan exit, socket→tail fallback, arg parsing),
pane-tracker +5, suite-settings-watchpane +1, swarm-cli +2. Full suite:
75 files / 773 tests green, tsc clean.
HOMELAB-1558: feat(subagents): hardening + attach protocol docs (Phase 3 Slice 8)
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 2m25s
CI / pi-otel-telemetry (pull_request) Successful in 32s
1bd625d81a
Phase 3d — the limits, the security posture and the crash matrix become
assertions, and the wire contract gets a reference that cannot drift.

* docs/attach-protocol.md (new): the Phase 2+3 reference — framing (reply
  precedes frames, per-connection seq, desync semantics), the method table
  (list/steer/prompt(alias answer)/abort/result/subscribe/unsubscribe with their
  curated failures), every stream event type and its droppability, the limit
  table, the backpressure policy, the advisory ownership model, the security
  posture (0600, same-uid, per-subscription isolation, no new trust) and the
  in-process bus twin.
* tests/core/attach-stream-limits.test.ts (new): the doc test (every
  StreamEventType, every limit constant AND its value, every ATTACH_METHOD
  must appear in the reference, PROTOCOL_VERSION == STREAM_PROTOCOL_VERSION);
  a field allowlist over EVERY emitted event type plus the snapshot's
  roster/driver/usage/entry shapes, with a forbidden-key regex
  (secret|password|credential|api-key|settings|promptTemplate|token$); the
  0600 + manifest-shape + no-TCP assertions; per-subscription isolation on the
  wire; and the crash matrix — kill -9 mid-fan-out, mid-flush, mid-teardown
  (no orphan writer, no leaked subscription, abort never called), plus a dead
  parent's artifacts swept by the next boot while a LIVE parent's tree is
  refused.
* README: the Phase-3 section (subscribe/frames/backpressure, the pane client
  and its key contract, advisory driver, the bus twin + protocol 4), the
  `watchPane: "interactive"` setting row, the CLI example list, and the Phase-2
  "subscribe is a reserved stub" text replaced by the shipped contract.
* tests/core/attach-wiring.test.ts: the core/index cold import moves into
  beforeAll so the extension graph is paid once instead of inside the first
  test's 5 s budget on a loaded box.

Tests: full suite run twice — 76 files / 784 tests green both runs; tsc clean.
Remaining Slice 8 item is the manual 30-minute soak + herdr end-to-end
acceptance (documented, not automatable here).
Author
Owner

Landing-worker review — @ 1bd625d (SI-8) — DO NOT MERGE

Fresh-context independent review (reviewer subprocess, primary sources only: the diff, the repo at 1bd625d, the spec's D1–D13 + O1–O5, the Slice 5–8 Automated Verification checklists). Reviewer ≠ implementer. Nothing in the worktree was modified.

Verdict: 2 BLOCKERS · 8 CONCERNS · 5 SUGGESTIONS.
All six frozen invariants hold (single-writer parent-brokered · Phase-2 compat · no pi-core changes · disconnect-never-aborts · 0600 socket+manifest · socket-path backpressure never blocks a producer). The blockers are not invariant breaks — they are a client-integration defect and a merge conflict.

BLOCKER 1 — the interactive pane never exits (pi-tui render loop never stopped); SIGTERM ignored; terminal left in raw mode

shutdown() (src/watch/pane.js:380-392) never calls tui.stop() / terminal.stop() — neither appears anywhere in the repo. pi-tui's ProcessTerminal.start() does process.stdin.setRawMode(true) + resume() + process.stdin.on("data", …) (@earendil-works/pi-tui/dist/terminal.js:113-183), and only terminal.stop() (:349-390) removes that listener, pauses stdin and restores raw mode. shutdown() merely sets process.exitCode, so the listen-mode stdin handle keeps the event loop alive and the process never exits. Every exit path funnels into the same shutdown (q/Esc/Ctrl-C×2 → pane.js:460-465, 372; SIGINT/SIGTERM → pane.js:434-435), so the pane is immune to SIGTERM too, and bracketed paste / hidden cursor are never reset.

Reproduced independently in a real pty at 1bd625d (unmodified repo file, cwd pi-swarm-suite, TUI confirmed up by [?2004h in the pane output):

TUI_UP(bracket): True | bytes: 516 | boot_state: RUNNING
AFTER_SIGTERM: RUNNING | AFTER_Q: RUNNING          # waitpid(WNOHANG) — not a zombie

Counter-check proving the root cause and the fix: a /tmp copy with one added tui.stop() in shutdown → AFTER_SIGTERM: EXITED(status=0) and AFTER_Q: EXITED(status=0). Repro harness: /tmp/pty-test4.py (python3 /tmp/pty-test4.py <pane.js> <cwd> {sigterm|q}).

Why a green suite missed it: the Slice-7 proof "Ctrl-C twice exits the client and sends NO abort" (tests/core/watch-pane.test.ts:182-196) calls the pure handleKey twice — no client, no transport, no server. The test file itself states the pi-tui path is "covered by construction" (watch-pane.test.ts:20-23). So the Slice-7 automated-verification item Ctrl-C twice / parent death → exit 0 is not actually exercised on the shipped client.

Secondary (same subsystem, include in the fix): buildPaneCommand (src/core/panes/pane-tracker.ts:155-166) never passes --close-on-terminal, so D13's "finished panes do not accumulate" mitigation is unreachable for the shipped pane — the pane can neither exit itself nor be reaped by the parent, and swarm-attach watch's child.on("close") wrapper (bin/swarm-attach.js:270) hangs with it.

Fix scope (small): hold the TUI handle from startTui() and call tui.stop() in shutdown() (before/around transport.destroy()), then assert the real path — a pty or --headless-independent subprocess test that spawns pane.js with a tty, sends q/Ctrl-C×2, and asserts process exit + process.stdin.isRaw === false. Optionally wire --close-on-terminal in buildPaneCommand.

BLOCKER 2 — PR is no longer mergeable: conflict with main in pi-swarm-suite/README.md

main advanced to 12342f31 (PR #25, Phase 4, merged 05:37–05:39Z) after this branch was cut. Local dry-run: git merge-tree --write-tree origin/main 1bd625d → CONFLICT (content): Merge conflict in pi-swarm-suite/README.md (all other changed files auto-merge). The PR reported mergeable: true when opened and is mergeable: false now. Fix scope: merge/rebase origin/main into plane/HOMELAB-1558-phase3, resolve the README section (combine the Phase 3 and Phase 4 sections; keep both), push, let CI re-run — the current green CI (tasks 8753/8754/8755, head 1bd625d: pr-title + pi-swarm-suite + pi-otel-telemetry all success) was computed against the pre-Phase-4 base and must be re-earned on the merged SHA.

CONCERNS (may merge; posted for the record)

  1. enforceBounds has no ceiling for non-droppable frames (subscriptions.ts:527, if (index < 0) break): status|usage|compaction|driver|desync|heartbeat accumulate past both caps O(turns). Invariant 6 is not violated (they are never dropped), but the D2.2 "rare by construction" memory claim is not enforced by the code. Consider coalescing usage/heartbeat to latest-per-agent.
  2. desync episode under-reports loss (verified with the shipped registry): once the desync frame has been written but the queue is still above half the caps, a later drop takes rewriteDesync (subscriptions.ts:537-539, 550) and mutates an item that is no longer in the queue → no new frame is emitted (probe: client told droppedFrames: 5 when 125 were dropped) plus pendingBytes drift. endEpisodeIfDrained (:579-587) is the only reset. Suggested fix: clear desyncItem when the item is shifted off the queue in drain (:422-433). Reachable in the design's own SIGSTOP-then-resume manual scenario; view-model.js ignores seq, so desync is the client's only gap signal (see S5).
  3. desync attribution (subscriptions.ts:570-573) names the connection's first subscription's agent, while the dropped frames may belong to another agent on that connection (up to 8 subs/conn) → a client watching B can be told about A's gap.
  4. x/a/q are consumed while the input box is empty (pane.js:459-465), so the pane-keys.js:14-15 claim "typing a message that starts with x or contains q is never hijacked" is false: the first x is eaten and arms, two quick x presses abort the agent, and a message cannot begin with x/q/a. The test that "proves" safety (watch-pane.test.ts:218-224) feeds inputEmpty:false. Still the armed double-key path the invariant permits → concern, not blocker.
  5. Bus-twin backpressure claim is untrue as written (cross-extension-rpc.ts:296-326): the RPC twin does JSON.parse(line) + synchronous pi.events.emit(...) from the agent's tap (node:events), so a slow synchronous listener stalls the run loop, and the registry drop path is unreachable (return true). In-process only.
  6. O2 not implemented in the binding (strict) reading — see adjudication below.
  7. SteerOutcome reply gained mode:"steer" (attach-server.ts, steer reply) which D4 did not sanction (D4 sanctioned the additive param only) — additive for tolerant clients; document it or drop it.
  8. closeOnTerminal is honoured for any subscriber of that agent, not just the pane that owns record.paneId (attach-server.ts:376-386) — a third-party same-uid client can have the user's pane closed at terminalization.

O2 adjudication (requested)

O2's row is labelled "Default pane mode" and reads "Interactive default — pane opens type-to-steer when attach socket is live; .output tail is the explicit fallback when it isn't" (spec :1427), under a heading that overrides designer recommendations. Read against its own label, the mandate is that the default mode of a watch pane is the interactive client, with tail only as the socket-absent fallback. The code implements the weaker deliberate reading: watchPane still defaults "off" (src/core/settings.ts:324, src/suite-settings.ts:59) and "split"/"tab" — which open a pane while the socket is live — still run the Phase-1 tail renderer (pane-tracker.ts:30-38, 301-317); only the explicit "interactive" value reaches pane.js. Owner intent check: the shipped client's own header asserts the strict reading — src/watch/pane.js:15-16 "O2 — INTERACTIVE IS THE DEFAULT for a background spawn while the attach socket is live" — which the wiring contradicts, while the design's Slice-7 checklist (:1217) encodes the deliberate reading. So the spec and the owner decision disagree and the implementation follows the doc. Adjudicated as a CONCERN (not a blocker): no frozen invariant, and the pane.js tail-on-no-socket half of O2 is implemented. Aaron to confirm which reading is binding; if strict, it is a small wiring change (watchPane default / mode dispatch) plus its tests.
Related: O5 supersedes D4's "answer = resume a retained finished session" — implementation deliberately refuses non-running agents (attach-server.ts:676-702), so Slice 6/7's checklists still describe removed behavior; the design doc should be corrected.

Crash-matrix honesty (Slice 8 checklist item)

test what it really asserts overstates?
crash matrix: kill -9 leaves no orphan writer… (attach-stream-limits.test.ts:232) nothing — a describe title only yes
mid-fan-out: the subscriber dies between a publish and a flush (:245) graceful client.close(), then counts → 0, abortCalls == [] yes — no kill
mid-flush: the connection dies while the frame writer is draining (:263) graceful close + 2 flushes, counts → 0; no "no dead write" / no unhandled-rejection assertion yes
mid-teardown: close() with a live subscriber… (:279) honest (real existsSync on socket/manifest, post-close flush inert) no
a killed parent's artifacts are swept by the next boot (:295) real process.kill(pid, 9) of a real node -e sleeper + real unlinks + fresh bind — but artifacts were written after a graceful close mild

So "kill -9 at each of {mid-fan-out, mid-flush, mid-teardown}" is not exercised; two of three use a graceful close. Rename or add a real case.

SUGGESTIONS

S1 the mode field on the steer reply (Concern 7). S2 notice is a documented wire event type with no server-side emitter — drop it from the protocol table or mark reserved. S3 bound/strip params.client before it reaches the manifest/roster/frames (attach-server.ts:716-724). S4 view-model.js never checks seq (:185-193) — a cheap continuity check would restore D1's client-side drop detection. S5 several test names overstate their bodies ("every emitted frame's keys…" limits:121 requires 7 of 10 captured types; "a stalled reader drops oldest deltas" registry:190 never asserts "oldest"; "the registry holds no manager surface at all" registry:307 checks one own key). Also: no test drives a real socket with a stalled reader — every backpressure test uses a fake WriteLine counter, so production's socket.writableLength < 1 MiB + 'drain' resume path (attach-server.ts:419-440) is untested.

Authorization (focus 3) — clean

agentId is validated against the live roster (attach-server.ts:620-621; "*" refused by normalizeAgentId, stream-frames.ts:118-124; same on the bus twin cross-extension-rpc.ts:305-308). subId is connection-scoped (attach-server.ts:658; subs live in a per-connection map, subscriptions.ts:243-256) → no cross-connection unsubscribe or cross-talk. Frame spoofing is impossible: the server generates seq/agentId per frame (subscriptions.ts:505-509) and client params are never echoed into frames; publishTo gives real per-subscription isolation. The no-secret allowlist is enforced by construction (explicit object literals + a hand-copied allowlist in roster-json.ts:105-141). Residual (D12, not new): any same-uid client may subscribe to any of that root's agents.

Abort-safety (focus 2) — clean apart from the hang

Enumerated every key path: no branch produces abort/steer/prompt/answer except the armed x x (pane-keys.js:56-99, aborted latch → one abort ever). Nothing sends a verb on socket close/reconnect (pane.js:331-337), watchdog reconnect (:422-429), ppid orphan, SIGINT/SIGTERM or teardown; swarm-attach's SIGINT is finish(0) → socket.destroy(). Disconnect is registry.detach only and the registry holds no manager reference (attach-server.ts:443 → subscriptions.ts:222-240); tests assert manager.abortCalls == [] on real socket close (attach-stream.test.ts:359-369).

Fix worker: both blockers are mechanical and localized (pane.js shutdown + README merge). No invariant is broken, so nothing here invalidates the architecture — but the epic's headline feature does not currently exit, and a stale-base green CI is not a merge signal, so this PR does not pass the gate.

## Landing-worker review — @ 1bd625d (SI-8) — **DO NOT MERGE** Fresh-context independent review (reviewer subprocess, primary sources only: the diff, the repo at `1bd625d`, the spec's D1–D13 + O1–O5, the Slice 5–8 Automated Verification checklists). Reviewer ≠ implementer. Nothing in the worktree was modified. **Verdict: 2 BLOCKERS · 8 CONCERNS · 5 SUGGESTIONS.** All six frozen invariants hold (single-writer parent-brokered · Phase-2 compat · no pi-core changes · disconnect-never-aborts · 0600 socket+manifest · socket-path backpressure never blocks a producer). The blockers are not invariant breaks — they are a client-integration defect and a merge conflict. ### BLOCKER 1 — the interactive pane never exits (pi-tui render loop never stopped); SIGTERM ignored; terminal left in raw mode `shutdown()` (`src/watch/pane.js:380-392`) never calls `tui.stop()` / `terminal.stop()` — neither appears anywhere in the repo. pi-tui's `ProcessTerminal.start()` does `process.stdin.setRawMode(true)` + `resume()` + `process.stdin.on("data", …)` (`@earendil-works/pi-tui/dist/terminal.js:113-183`), and only `terminal.stop()` (`:349-390`) removes that listener, pauses stdin and restores raw mode. `shutdown()` merely sets `process.exitCode`, so the listen-mode stdin handle keeps the event loop alive and the process never exits. Every exit path funnels into the same `shutdown` (`q`/`Esc`/`Ctrl-C`×2 → `pane.js:460-465, 372`; `SIGINT`/`SIGTERM` → `pane.js:434-435`), so the pane is immune to SIGTERM too, and bracketed paste / hidden cursor are never reset. Reproduced independently in a real pty at `1bd625d` (unmodified repo file, cwd `pi-swarm-suite`, TUI confirmed up by `[?2004h` in the pane output): ``` TUI_UP(bracket): True | bytes: 516 | boot_state: RUNNING AFTER_SIGTERM: RUNNING | AFTER_Q: RUNNING # waitpid(WNOHANG) — not a zombie ``` Counter-check proving the root cause and the fix: a `/tmp` copy with **one added `tui.stop()` in `shutdown`** → `AFTER_SIGTERM: EXITED(status=0)` and `AFTER_Q: EXITED(status=0)`. Repro harness: `/tmp/pty-test4.py` (`python3 /tmp/pty-test4.py <pane.js> <cwd> {sigterm|q}`). Why a green suite missed it: the Slice-7 proof "Ctrl-C twice exits the client and sends NO abort" (`tests/core/watch-pane.test.ts:182-196`) calls the pure `handleKey` twice — no client, no transport, no server. The test file itself states the pi-tui path is "covered by construction" (`watch-pane.test.ts:20-23`). So the Slice-7 automated-verification item *Ctrl-C twice* / *parent death → exit 0* is not actually exercised on the shipped client. Secondary (same subsystem, include in the fix): `buildPaneCommand` (`src/core/panes/pane-tracker.ts:155-166`) never passes `--close-on-terminal`, so D13's "finished panes do not accumulate" mitigation is unreachable for the shipped pane — the pane can neither exit itself nor be reaped by the parent, and `swarm-attach watch`'s `child.on("close")` wrapper (`bin/swarm-attach.js:270`) hangs with it. **Fix scope (small):** hold the TUI handle from `startTui()` and call `tui.stop()` in `shutdown()` (before/around `transport.destroy()`), then assert the real path — a pty or `--headless`-independent subprocess test that spawns `pane.js` with a tty, sends `q`/`Ctrl-C`×2, and asserts process exit + `process.stdin.isRaw === false`. Optionally wire `--close-on-terminal` in `buildPaneCommand`. ### BLOCKER 2 — PR is no longer mergeable: conflict with `main` in `pi-swarm-suite/README.md` `main` advanced to `12342f31` (PR #25, Phase 4, merged 05:37–05:39Z) after this branch was cut. Local dry-run: `git merge-tree --write-tree origin/main 1bd625d` → `CONFLICT (content): Merge conflict in pi-swarm-suite/README.md` (all other changed files auto-merge). The PR reported `mergeable: true` when opened and is `mergeable: false` now. **Fix scope:** merge/rebase `origin/main` into `plane/HOMELAB-1558-phase3`, resolve the README section (combine the Phase 3 and Phase 4 sections; keep both), push, let CI re-run — the current green CI (tasks 8753/8754/8755, head `1bd625d`: pr-title + pi-swarm-suite + pi-otel-telemetry all `success`) was computed against the pre-Phase-4 base and must be re-earned on the merged SHA. ### CONCERNS (may merge; posted for the record) 1. **`enforceBounds` has no ceiling for non-droppable frames** (`subscriptions.ts:527`, `if (index < 0) break`): `status|usage|compaction|driver|desync|heartbeat` accumulate past both caps O(turns). Invariant 6 is not violated (they are never dropped), but the D2.2 "rare by construction" memory claim is not enforced by the code. Consider coalescing `usage`/`heartbeat` to latest-per-agent. 2. **desync episode under-reports loss** (verified with the shipped registry): once the desync frame has been written but the queue is still above half the caps, a later drop takes `rewriteDesync` (`subscriptions.ts:537-539, 550`) and mutates an item that is no longer in the queue → **no new frame is emitted** (probe: client told `droppedFrames: 5` when 125 were dropped) plus `pendingBytes` drift. `endEpisodeIfDrained` (`:579-587`) is the only reset. Suggested fix: clear `desyncItem` when the item is shifted off the queue in `drain` (`:422-433`). Reachable in the design's own SIGSTOP-then-resume manual scenario; `view-model.js` ignores `seq`, so desync is the client's only gap signal (see S5). 3. **desync attribution** (`subscriptions.ts:570-573`) names the connection's *first* subscription's agent, while the dropped frames may belong to another agent on that connection (up to 8 subs/conn) → a client watching B can be told about A's gap. 4. **`x`/`a`/`q` are consumed while the input box is empty** (`pane.js:459-465`), so the `pane-keys.js:14-15` claim "typing a message that starts with `x` or contains `q` is never hijacked" is false: the first `x` is eaten and arms, two quick `x` presses abort the agent, and a message cannot begin with `x`/`q`/`a`. The test that "proves" safety (`watch-pane.test.ts:218-224`) feeds `inputEmpty:false`. Still the armed double-key path the invariant permits → concern, not blocker. 5. **Bus-twin backpressure claim is untrue as written** (`cross-extension-rpc.ts:296-326`): the RPC twin does `JSON.parse(line)` + synchronous `pi.events.emit(...)` from the agent's tap (`node:events`), so a slow synchronous listener stalls the run loop, and the registry drop path is unreachable (`return true`). In-process only. 6. **O2 not implemented in the binding (strict) reading** — see adjudication below. 7. **`SteerOutcome` reply gained `mode:"steer"`** (attach-server.ts, steer reply) which D4 did not sanction (D4 sanctioned the additive *param* only) — additive for tolerant clients; document it or drop it. 8. **`closeOnTerminal` is honoured for any subscriber of that agent**, not just the pane that owns `record.paneId` (`attach-server.ts:376-386`) — a third-party same-uid client can have the user's pane closed at terminalization. ### O2 adjudication (requested) O2's row is labelled **"Default pane mode"** and reads "Interactive default — pane opens type-to-steer when attach socket is live; `.output` tail is the explicit fallback when it isn't" (spec `:1427`), under a heading that overrides designer recommendations. Read against its own label, the mandate is that **the default mode of a watch pane is the interactive client**, with tail only as the socket-absent fallback. The code implements the weaker *deliberate* reading: `watchPane` still defaults `"off"` (`src/core/settings.ts:324`, `src/suite-settings.ts:59`) and `"split"`/`"tab"` — which open a pane *while the socket is live* — still run the Phase-1 tail renderer (`pane-tracker.ts:30-38, 301-317`); only the explicit `"interactive"` value reaches `pane.js`. **Owner intent check:** the shipped client's own header asserts the strict reading — `src/watch/pane.js:15-16` "O2 — INTERACTIVE IS THE DEFAULT for a background spawn while the attach socket is live" — which the wiring contradicts, while the design's Slice-7 checklist (`:1217`) encodes the deliberate reading. So the spec and the owner decision disagree and the implementation follows the doc. **Adjudicated as a CONCERN (not a blocker)**: no frozen invariant, and the `pane.js` tail-on-no-socket half of O2 *is* implemented. Aaron to confirm which reading is binding; if strict, it is a small wiring change (`watchPane` default / mode dispatch) plus its tests. Related: O5 supersedes D4's "answer = resume a retained finished session" — implementation deliberately refuses non-running agents (`attach-server.ts:676-702`), so Slice 6/7's checklists still describe removed behavior; the design doc should be corrected. ### Crash-matrix honesty (Slice 8 checklist item) | test | what it really asserts | overstates? | |---|---|---| | `crash matrix: kill -9 leaves no orphan writer…` (`attach-stream-limits.test.ts:232`) | nothing — a describe title only | **yes** | | `mid-fan-out: the subscriber dies between a publish and a flush` (`:245`) | graceful `client.close()`, then counts → 0, `abortCalls == []` | **yes** — no kill | | `mid-flush: the connection dies while the frame writer is draining` (`:263`) | graceful close + 2 flushes, counts → 0; no "no dead write" / no unhandled-rejection assertion | **yes** | | `mid-teardown: close() with a live subscriber…` (`:279`) | honest (real `existsSync` on socket/manifest, post-close flush inert) | no | | `a killed parent's artifacts are swept by the next boot` (`:295`) | real `process.kill(pid, 9)` of a real `node -e` sleeper + real unlinks + fresh bind — but artifacts were written after a *graceful* close | mild | So "kill -9 at each of {mid-fan-out, mid-flush, mid-teardown}" is **not** exercised; two of three use a graceful close. Rename or add a real case. ### SUGGESTIONS S1 the `mode` field on the steer reply (Concern 7). S2 `notice` is a documented wire event type with **no server-side emitter** — drop it from the protocol table or mark reserved. S3 bound/strip `params.client` before it reaches the manifest/roster/frames (`attach-server.ts:716-724`). S4 `view-model.js` never checks `seq` (`:185-193`) — a cheap continuity check would restore D1's client-side drop detection. S5 several test names overstate their bodies (`"every emitted frame's keys…"` limits:121 requires 7 of 10 captured types; `"a stalled reader drops oldest deltas"` registry:190 never asserts "oldest"; `"the registry holds no manager surface at all"` registry:307 checks one own key). Also: no test drives a *real* socket with a stalled reader — every backpressure test uses a fake `WriteLine` counter, so production's `socket.writableLength < 1 MiB` + `'drain'` resume path (`attach-server.ts:419-440`) is untested. ### Authorization (focus 3) — clean `agentId` is validated against the live roster (`attach-server.ts:620-621`; `"*"` refused by `normalizeAgentId`, `stream-frames.ts:118-124`; same on the bus twin `cross-extension-rpc.ts:305-308`). `subId` is connection-scoped (`attach-server.ts:658`; subs live in a per-connection map, `subscriptions.ts:243-256`) → no cross-connection unsubscribe or cross-talk. Frame spoofing is impossible: the server generates `seq`/`agentId` per frame (`subscriptions.ts:505-509`) and client params are never echoed into frames; `publishTo` gives real per-subscription isolation. The no-secret allowlist is enforced by construction (explicit object literals + a hand-copied allowlist in `roster-json.ts:105-141`). Residual (D12, not new): any same-uid client may subscribe to any of that root's agents. ### Abort-safety (focus 2) — clean apart from the hang Enumerated every key path: no branch produces `abort`/`steer`/`prompt`/`answer` except the armed `x x` (`pane-keys.js:56-99`, `aborted` latch → one abort ever). Nothing sends a verb on socket close/reconnect (`pane.js:331-337`), watchdog reconnect (`:422-429`), ppid orphan, SIGINT/SIGTERM or teardown; `swarm-attach`'s SIGINT is `finish(0)` → `socket.destroy()`. Disconnect is `registry.detach` only and the registry holds no manager reference (`attach-server.ts:443` → `subscriptions.ts:222-240`); tests assert `manager.abortCalls == []` on real socket close (`attach-stream.test.ts:359-369`). Fix worker: both blockers are mechanical and localized (pane.js `shutdown` + README merge). No invariant is broken, so nothing here invalidates the architecture — but the epic's headline feature does not currently exit, and a stale-base green CI is not a merge signal, so this PR does not pass the gate.
Author
Owner

Line-anchor corrections to the review above (verified against 1bd625d; the findings and verdict are unchanged):

  • unknown agent validation: attach-server.ts:621-622 (not :620-621)
  • closeOnTerminal honoured for any subscriber of the agent: attach-server.ts:372-379
  • write pulse / backpressure threshold: attach-server.ts:417, resume on drain :447
  • "*" refused: stream-frames.ts:129-134 (not :118-124)
  • crash-matrix describe: attach-stream-limits.test.ts:231
  • confirmed exact as cited: subscriptions.ts:527 (if (index < 0) break), :537-539/:550 (desync rewrite), pane.js:380/:388/:433, settings.ts:324, suite-settings.ts:59, pane-tracker.ts:155, watch-pane.test.ts:20-23,182-196,218-224
**Line-anchor corrections** to the review above (verified against `1bd625d`; the findings and verdict are unchanged): - `unknown agent` validation: `attach-server.ts:621-622` (not `:620-621`) - `closeOnTerminal` honoured for any subscriber of the agent: `attach-server.ts:372-379` - write pulse / backpressure threshold: `attach-server.ts:417`, resume on drain `:447` - `"*"` refused: `stream-frames.ts:129-134` (not `:118-124`) - crash-matrix `describe`: `attach-stream-limits.test.ts:231` - confirmed exact as cited: `subscriptions.ts:527` (`if (index < 0) break`), `:537-539`/`:550` (desync rewrite), `pane.js:380`/`:388`/`:433`, `settings.ts:324`, `suite-settings.ts:59`, `pane-tracker.ts:155`, `watch-pane.test.ts:20-23,182-196,218-224`
# Conflicts:
#	pi-swarm-suite/README.md
HOMELAB-1558: fix(subagents): pane exit teardown, strict O2 pane client, D13 close-on-terminal
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 2m34s
CI / pi-otel-telemetry (pull_request) Successful in 27s
ce0726ccc9
Three review blockers on PR #26.

1. The pane client never exited. `shutdown()` cleared the pane's timers and the
socket but never stopped the pi-tui render loop, so raw-mode stdin (a live libuv
handle) kept the event loop alive: q, Esc, Ctrl-C x2 AND SIGTERM all returned
from their handler and then hung, with the terminal stuck in raw mode.
`shutdown()` now stops the TUI (terminal restored, stdin paused, listeners
removed), the TUI object is shared with it, a signal that lands while pi-tui is
still loading no longer starts a loop nobody wants, and the input listener no
longer touches the instance after an exit action (that turned a clean exit 0
into an uncaught TypeError exit 1).

The same pty work exposed three more defects on that path, all fixed here
because they are reachable from the same command line: every frame, notice and
log line was written straight to stdout under an active pi-tui (corrupting its
differential render), pi-tui THROWS on an over-wide line and the unfitted notice
rule killed the pane (exit 1), and the transcript poll only ran in headless mode
— so the documented "socket dies mid-run → tail the .output file" degradation
was dead in the TUI, the pane just froze on its last frame.

Coverage: a real-pty harness (python3 pty.fork, skipped when python3 is absent)
spawns src/watch/pane.js with a real TTY and asserts its REAL exit status: q,
Esc, Ctrl-C x2 and SIGTERM each exit 0, and a socket that dies mid-run falls back
to the transcript inside a TUI frame. All four fail (zombie, deadline) against
the pre-fix file.

2. Owner decision O2, strict reading: the socket — not the setting — picks the
pane client. `split`/`tab` now run the interactive client while this parent's
attach server is bound and listening, and the Phase-1 tail renderer when it is
not; `interactive` names the client explicitly; the gate is read at every spawn
(never cached) and `off` still means no pane at all. New
`paneClientKind(mode, attachSocketLive)` + `attachSocketLive` tracker option
wired to `attachServer?.listening`.

3. D13 was unreachable: buildPaneCommand now passes `--close-on-terminal`, so a
tracker-spawned pane asks to be closed when its agent terminalizes. A hand-run
`swarm-attach watch` deliberately does not ask.

Docs realigned (README knob + watch-pane + interactive-panes sections,
docs/attach-protocol.md gains the closeOnTerminal contract and a "pane clients"
section, settings/pane.js/tracker comments).

Refs HOMELAB-1558
Author
Owner

Landing-worker review — round 2 @ ce0726c — MERGE

Fresh-context independent reviewer (separate pi -p --no-session process, primary sources only: the 8d026d6..ce0726c delta, the repo at ce0726c, the design's D1–D13 + O1–O5). Reviewer ≠ implementer. The worktree was not modified; scratch copies were deleted.

Verdict: 0 BLOCKERS · 3 CONCERNS · 3 SUGGESTIONS. Both round-1 blockers are genuinely fixed; the three open items are pre-existing latent defects in the client, not regressions of this fix. Follow-ups filed as HOMELAB-1566.

Round-1 BLOCKER 1 — pane never exits — FIXED

Same repro shape as round 1 (real pty.fork, node src/watch/pane.js, TUI confirmed up by [?2004h before the action):

action round 1 @ 1bd625d round 2 @ ce0726c
q RUNNING, no raw teardown EXITED(code=0), [?2004l+[?25h
Esc RUNNING EXITED(code=0)
Ctrl-C ×2 RUNNING EXITED(code=0)
SIGTERM RUNNING EXITED(code=0)

Positive control (proves the test can fail): a scratch copy of src/watch/pane.js with the single tui?.stop?.() line removed goes back to tui_up=True RUNNING raw_teardown=False for both q and SIGTERM, and the 5 new pty tests go red (5 failed | 19 skipped). Reviewer added adversarial cases, all clean: SIGINT, q typed in the first 100 ms, no --socket and no --file, stale socket file, parent pid killed (orphan frame → exit 0), and a signal-delay sweep (before the handler is installed at ~40–60 ms the default disposition applies with the TUI never started, so the terminal is never left raw).

Why the round-1 report was fair: the old evidence was a pure-function handleKey test with no client, no transport, no server. That is now replaced with real-pty coverage (tests/core/watch-pane.test.ts:570-606), and --close-on-terminal is passed (core/panes/pane-tracker.ts:221), so D13 is reachable — swarm-attach watch deliberately does not pass it.

Round-1 BLOCKER 2 — base sync — FIXED

git merge-tree --write-tree origin/main ce0726c → 0a40abaf, no conflict rows (round 1 was CONFLICT). No markers in the resolution. No content dropped from either side: 1bd625d→8d026d6 keeps all of Phase 4's README additions (the persistChildSessions row and the whole "Persisted child sessions" section), and the 9 lines where main differs are Phase 3's own supersessions (the watchPane row, the obsolete "subscribe is a reserved stub … ships in Phase 3" paragraph, the rpc:list/rpc:steer paragraph rendered as the protocol-4 version). The only information lost in the whole merge is the history token "PROTOCOL_VERSION bumped 2 → 3" — wording, not operational. Phase 4's code is present and green.

O2 strict — FULLY implemented

paneClientKind (core/panes/pane-tracker.ts:68-72): interactive→client always; off→tail (unreachable, shouldOpenPane gates first); split|tab→client while the socket is live, Phase-1 renderer otherwise. Wired at pane-tracker.ts:366-367 — this.opts.attachSocketLive?.() is read inside openPane, per spawn — over the closure at core/index.ts:646 on the mutable let attachServer (:541), assigned only in the post-bind .then (:720) and cleared at :782; listening: true is a literal on the object built only after await listen() succeeds (attach-server.ts:476,787). So the gate is live, never cached, and can only be true when a socket exists. --close-on-terminal flows to subscribe (pane.js:186,387) and swarm-attach watch does not pass it. Mid-run socket death still degrades inside the TUI (notice + transcript + tail (disconnected) all drawn in a real TUI frame).

Adjacent defects the fix exposed: raw-stdout-under-TUI fixed except one path (S1); transcript-poll-in-TUI-mode fixed (pane.js:486-497, verified inside a TUI frame); the pi-tui over-wide-line throw only partially fixed — see C1.

Invariants 1–6: HOLD (verified, not assumed — single writer: no write/append/mkdir/unlink in src/watch/*.js; no pi-core files touched, git diff --name-only 12342f3 ce0726c is 42 files all under pi-swarm-suite/; disconnect sends no verb; 0600 asserted by tests; backpressure untouched by the delta). Authorization and abort-safety: HOLD in the changed files ("*" refused, agentId validated against the live roster, subId connection-scoped; the new if (stopped) return { consume: true } short-circuits after teardown and introduces no verb).

CONCERNS

C1 — fit() is not a display-width truncator, so pi-tui still throws and kills the pane. src/watch/view-model.js:225-229 truncates by text.length; pi-tui measures visibleWidth() (tab = 3 cols, East-Asian Wide = 2). Every model line goes through it (:287-292) and so does the notice (src/watch/pane.js:556). Reproduced by both the reviewer and me on the shipped file, real pty, 80×24: appending a mid-run line of "漢"*200 → Rendered line … exceeds terminal width (159 > 80) → EXITED(code=1); "\t"*60+"end" → (183 > 80) → EXITED(code=1); a 200-char ASCII line stays RUNNING (control). nodeText() is a passthrough, so nothing normalises tabs upstream. fit()'s own doc comment (view-model.js:17) claims it truncates by visible length — it does not. Terminal is restored (this.stop() runs before the throw) and the agent is untouched, so this fails safe — but note that O2-strict makes pane.js the default program for split/tab panes whenever the socket is live, so this is now the default experience for transcripts containing tabs or wide glyphs. → HOMELAB-1566 (not a blocker: pre-existing in the client, no invariant, fail-safe, opt-in — watchPane still defaults off).

C2 — D13's close never fires for a subscriber that arrives after terminalization. attach-server.ts:376-385 (maybeCloseOnTerminal) is invoked only from the live status event (:394); subscribe (:614-641) has no terminal-status gate and no post-reply re-check. Reviewer reproduced (scratch test, real server + real pane): terminalize before the pane can subscribe → subscriberCount("a1") > 0 while closePane calls stay []. The happy path is green (terminalize after subscribe → closePane("wC:pD13"), and a non-asking subscriber is never closed). docs/attach-protocol.md:47-55 overstates this. → HOMELAB-1566.

C3 — the pty tests could silently skip in CI. They are it.skipIf(!PYTHON3) (watch-pane.test.ts:502,570-606); with a PATH containing only node the file reports 19 passed | 5 skipped, exit 0 — a green suite with zero pane-exit coverage. Premise disproven: the k8s runner image installs python3 (infra-core/core/manifests/apps/forgejo-runner/Dockerfile:12, image pinned by digest in deployment.yaml:30), so the 5 pty tests do execute in CI (reviewer ran them locally too: 52 passed, 5 executing). Downgraded to S2 (defence in depth), not a coverage gap.

SUGGESTIONS

S1 pane.js:509 writes raw stdout under the active pi-tui loop (orphan notice) — the one path the fix's own say()/repaintTui() discipline missed. S2 make the pty tests fail closed (drop skipIf, or assert python3 --version in CI). S3 fit() degenerate branches: :227 returns the line untruncated when width <= 0 (guaranteed throw if a terminal reports 0 columns) and :228 can emit a lone surrogate.

Merge decision

Round-2 acceptance is met (blocker 1 reproduced-fixed against the round-1 repro shape, blocker 2 resolved, O2 strict complete); the open items fail safe, break no invariant, and are filed as HOMELAB-1566. CI is green on ce0726c (run 13: pr-title + pi-swarm-suite + pi-otel-telemetry, all success; tsc --noEmit clean, 79 files / 845 tests locally). Merging. NOTE: one PR-checklist item remains unchecked by design — the 30-min soak + live herdr end-to-end acceptance is manual, after the epic-end 0.9.1 upgrade.

## Landing-worker review — round 2 @ `ce0726c` — **MERGE** Fresh-context independent reviewer (separate `pi -p --no-session` process, primary sources only: the `8d026d6..ce0726c` delta, the repo at `ce0726c`, the design's D1–D13 + O1–O5). Reviewer ≠ implementer. The worktree was not modified; scratch copies were deleted. **Verdict: 0 BLOCKERS · 3 CONCERNS · 3 SUGGESTIONS.** Both round-1 blockers are genuinely fixed; the three open items are pre-existing latent defects in the client, not regressions of this fix. Follow-ups filed as **HOMELAB-1566**. ### Round-1 BLOCKER 1 — pane never exits — **FIXED** Same repro shape as round 1 (real `pty.fork`, `node src/watch/pane.js`, TUI confirmed up by `[?2004h` before the action): | action | round 1 @ `1bd625d` | round 2 @ `ce0726c` | |---|---|---| | `q` | `RUNNING`, no raw teardown | `EXITED(code=0)`, `[?2004l`+`[?25h` | | `Esc` | `RUNNING` | `EXITED(code=0)` | | Ctrl-C ×2 | `RUNNING` | `EXITED(code=0)` | | `SIGTERM` | `RUNNING` | `EXITED(code=0)` | Positive control (proves the test can fail): a scratch copy of `src/watch/pane.js` with the single `tui?.stop?.()` line removed goes back to `tui_up=True RUNNING raw_teardown=False` for both `q` and `SIGTERM`, and the 5 new pty tests go red (`5 failed | 19 skipped`). Reviewer added adversarial cases, all clean: `SIGINT`, `q` typed in the first 100 ms, no `--socket` **and** no `--file`, stale socket file, parent pid killed (orphan frame → exit 0), and a signal-delay sweep (before the handler is installed at ~40–60 ms the default disposition applies with the TUI never started, so the terminal is never left raw). Why the round-1 report was fair: the old evidence was a pure-function `handleKey` test with no client, no transport, no server. That is now replaced with real-pty coverage (`tests/core/watch-pane.test.ts:570-606`), and `--close-on-terminal` **is** passed (`core/panes/pane-tracker.ts:221`), so D13 is reachable — `swarm-attach watch` deliberately does not pass it. ### Round-1 BLOCKER 2 — base sync — **FIXED** `git merge-tree --write-tree origin/main ce0726c` → `0a40abaf`, no conflict rows (round 1 was `CONFLICT`). No markers in the resolution. No content dropped from either side: `1bd625d`→`8d026d6` keeps all of Phase 4's README additions (the `persistChildSessions` row and the whole "Persisted child sessions" section), and the 9 lines where main differs are Phase 3's own supersessions (the `watchPane` row, the obsolete "subscribe is a reserved stub … ships in Phase 3" paragraph, the `rpc:list`/`rpc:steer` paragraph rendered as the protocol-4 version). The only information lost in the whole merge is the history token "`PROTOCOL_VERSION` bumped 2 → 3" — wording, not operational. Phase 4's code is present and green. ### O2 strict — **FULLY implemented** `paneClientKind` (`core/panes/pane-tracker.ts:68-72`): `interactive`→client always; `off`→tail (unreachable, `shouldOpenPane` gates first); `split|tab`→client while the socket is live, Phase-1 renderer otherwise. Wired at `pane-tracker.ts:366-367` — `this.opts.attachSocketLive?.()` is read **inside `openPane`, per spawn** — over the closure at `core/index.ts:646` on the mutable `let attachServer` (`:541`), assigned only in the post-bind `.then` (`:720`) and cleared at `:782`; `listening: true` is a literal on the object built only after `await listen()` succeeds (`attach-server.ts:476,787`). So the gate is live, never cached, and can only be true when a socket exists. `--close-on-terminal` flows to `subscribe` (`pane.js:186,387`) and `swarm-attach watch` does not pass it. Mid-run socket death still degrades inside the TUI (notice + transcript + `tail (disconnected)` all drawn in a real TUI frame). **Adjacent defects the fix exposed:** raw-stdout-under-TUI **fixed** except one path (S1); transcript-poll-in-TUI-mode **fixed** (`pane.js:486-497`, verified inside a TUI frame); the pi-tui over-wide-line throw **only partially fixed** — see C1. **Invariants 1–6: HOLD** (verified, not assumed — single writer: no write/append/mkdir/unlink in `src/watch/*.js`; no pi-core files touched, `git diff --name-only 12342f3 ce0726c` is 42 files all under `pi-swarm-suite/`; disconnect sends no verb; 0600 asserted by tests; backpressure untouched by the delta). **Authorization and abort-safety: HOLD** in the changed files (`"*"` refused, `agentId` validated against the live roster, `subId` connection-scoped; the new `if (stopped) return { consume: true }` short-circuits after teardown and introduces no verb). ### CONCERNS **C1 — `fit()` is not a display-width truncator, so pi-tui still throws and kills the pane.** `src/watch/view-model.js:225-229` truncates by `text.length`; pi-tui measures `visibleWidth()` (tab = 3 cols, East-Asian Wide = 2). Every model line goes through it (`:287-292`) and so does the notice (`src/watch/pane.js:556`). Reproduced by both the reviewer and me on the shipped file, real pty, 80×24: appending a mid-run line of `"漢"*200` → `Rendered line … exceeds terminal width (159 > 80)` → `EXITED(code=1)`; `"\t"*60+"end"` → `(183 > 80)` → `EXITED(code=1)`; a 200-char ASCII line stays `RUNNING` (control). `nodeText()` is a passthrough, so nothing normalises tabs upstream. `fit()`'s own doc comment (`view-model.js:17`) claims it truncates by visible length — it does not. Terminal **is** restored (`this.stop()` runs before the throw) and the agent is untouched, so this fails safe — but note that O2-strict makes `pane.js` the default program for `split`/`tab` panes whenever the socket is live, so this is now the default experience for transcripts containing tabs or wide glyphs. → **HOMELAB-1566** (not a blocker: pre-existing in the client, no invariant, fail-safe, opt-in — `watchPane` still defaults `off`). **C2 — D13's close never fires for a subscriber that arrives after terminalization.** `attach-server.ts:376-385` (`maybeCloseOnTerminal`) is invoked only from the live `status` event (`:394`); `subscribe` (`:614-641`) has no terminal-status gate and no post-reply re-check. Reviewer reproduced (scratch test, real server + real pane): terminalize before the pane can subscribe → `subscriberCount("a1") > 0` while `closePane` calls stay `[]`. The happy path is green (terminalize after subscribe → `closePane("wC:pD13")`, and a non-asking subscriber is never closed). `docs/attach-protocol.md:47-55` overstates this. → **HOMELAB-1566**. **C3 — the pty tests could silently skip in CI.** They are `it.skipIf(!PYTHON3)` (`watch-pane.test.ts:502,570-606`); with a PATH containing only `node` the file reports `19 passed | 5 skipped`, exit 0 — a green suite with zero pane-exit coverage. **Premise disproven:** the `k8s` runner image installs `python3` (`infra-core/core/manifests/apps/forgejo-runner/Dockerfile:12`, image pinned by digest in `deployment.yaml:30`), so the 5 pty tests do execute in CI (reviewer ran them locally too: 52 passed, 5 executing). Downgraded to **S2** (defence in depth), not a coverage gap. ### SUGGESTIONS S1 `pane.js:509` writes raw stdout under the active pi-tui loop (orphan notice) — the one path the fix's own `say()`/`repaintTui()` discipline missed. S2 make the pty tests fail closed (drop `skipIf`, or assert `python3 --version` in CI). S3 `fit()` degenerate branches: `:227` returns the line untruncated when `width <= 0` (guaranteed throw if a terminal reports 0 columns) and `:228` can emit a lone surrogate. ### Merge decision Round-2 acceptance is met (blocker 1 reproduced-fixed against the round-1 repro shape, blocker 2 resolved, O2 strict complete); the open items fail safe, break no invariant, and are filed as HOMELAB-1566. CI is green on `ce0726c` (run 13: `pr-title` + `pi-swarm-suite` + `pi-otel-telemetry`, all `success`; `tsc --noEmit` clean, 79 files / 845 tests locally). Merging. NOTE: one PR-checklist item remains unchecked by design — the 30-min soak + live herdr end-to-end acceptance is manual, after the epic-end 0.9.1 upgrade.
aaron merged commit 8fe5a283b2 into main 2026-09-19 06:23:47 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!26
No description provided.