HOMELAB-1557: feat(subagents): attach socket + steer — swarm-attach CLI, manifest, human-intervention announcements #24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1557-phase2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Phase 2 of HOMELAB-1555 (ticket HOMELAB-1557 Phase 2, design Slices 3+4): the human interaction channel into running subagents.
Slice 3 — attach socket + manifest:
src/core/attach/attach-server.ts— 0600 same-uid unix socketattach.sockbeside the task store; newline-JSON RPC (list | steer | abort | result);subscribereserved stub → Phase 3 error; malformed lines get an error reply and the connection survives; root-only (depth 1 binds nothing); stale-sweep of dead-pid sockets at startup; bind-generation counter closes superseded binds on session switch/shutdown (race found by tests).attach-manifest.ts— 0600attach.jsonkeyed rootSessionId+pid with kill(pid,0) liveness, debounced atomic writes (tmp+rename), removed on parent exit. Steer/abort/result delegate to existingAgentManager.steer()/abort(); steers to finished agents fail cleanly with curated status messages; queued aborts fail their store task.Slice 4 — CLI + RPC + announcements:
bin/swarm-attach.js(plain-JS CommonJS via nestedbin/package.json— the package istype:module) withswarm-list/steer/abortsymlink dispatch; manifest discovery across parents (live-pid choice,--swarm-root/--socketoverrides); abort prompts without--yes.human-announce.ts— every successful human steer announced once (pi.appendEntry("subagents:human_intervention")+subagents:steered {source:"human"}); announcement failure never fails the steer; failed steers never announce.cross-extension-rpc.ts—subagents:rpc:list|steer,PROTOCOL_VERSION → 3, and caller provenance onrpc:spawn(caller:{extension,token}fromsubagents:ready/rpc:handshake, fail-closed).Reviewer focus (implementer-flagged)
--escape, symlink dispatch).Known flake (orchestrator-observed)
One timing-sensitive test failed once in 2 local runs (662/663 → 663/663 on rerun, tree clean). If CI/review sees a single transient failure, re-run before treating as blocker; worth identifying the timing assumption.
Verify
tsc --noEmitcleanvitest run: 68 files / 663 tests pass (84 new) — every design Slice 3+4 Automated Verification item is a named testRefs HOMELAB-1557 (Phase 2), epic HOMELAB-1555.
Phase 2 Slices 3+4. The ROOT pi session now hosts a same-uid 0600 unix socket beside the task store, so any local client (a second herdr pane, a shell, a tail companion) can list, steer, abort, and read the result of live subagents — while the parent process stays the only writer of child sessions. The socket is a thin RPC over AgentManager's existing control surfaces; no pi-core changes. What lands: - attach/attach-manifest.ts: attach.json beside attach.sock, 0600 past umask, written atomically (tmp+rename). Carries rootSessionId+pid+socketPath+ herdrPaneId+agents[{id,type,status,outputFile,paneId}] — paths and statuses only. kill(pid,0) is the liveness oracle; discovery scans <agentDir>/swarm/<enc>/<session>/attach.json and picks the newest LIVE one. - attach/attach-server.ts: newline-JSON RPC (list | steer <id> <text> | abort <id> | result <id>; subscribe reserved with a curated Phase-3 error). Malformed lines get an error reply and the connection survives; unknown methods are refused with the supported list; a 1 MiB line cap bounds memory. Startup sweeps a dead-pid socket+manifest (never steals a LIVE parent's tree). Steers to finished/queued/unknown agents fail cleanly with the current status; queued abort delegates to abort() and fails the task cancelled-before-start in the durable store; result falls back to recoverStoredResult(). Root-only: depth != 0 binds nothing at all. - attach/human-announce.ts: every SUCCESSFUL steer/abort writes one durable subagents:human_intervention entry (kind, agentId, agentType, text, at, via) and a subagents:steered event with source:"human". Announcement failures never fail the steer; failed steers never announce. - bin/swarm-attach.js + swarm-list/steer/abort symlinks: plain CommonJS, zero deps, runnable from any pane. Discovery across manifests (newest live, --root pins one), 3 s connect / 10 s request timeouts, abort prompts unless --yes, non-zero exit on {ok:false}. bin/package.json pins CommonJS inside the ESM package (the inverse of src/watch/package.json). - cross-extension-rpc.ts: subagents:rpc:list + subagents:rpc:steer (same serializer, semantics, and announcement as the socket) and PROTOCOL_VERSION 2 -> 3. subagents:rpc:spawn is now caller-gated: caller:{extension,token} with the token from the subagents:ready broadcast {contractVersion, spawnToken} or subagents:rpc:handshake. Fail-closed — a missing/blank caller or a mismatched token refuses the spawn (provenance gate, documented as not a process-isolation boundary). - core/index.ts: root-only session_start wiring after attachTaskStore, closed in disposeSession and on session_before_switch. A bind-generation counter closes a bind that a switch/shutdown raced (listen() is async; the socket file appears before the server object does). - README: attach socket + CLI section. Invariants held: socket + manifest 0600 same-uid; single writer (the socket is an advisory second input — every intervention is announced into the parent transcript, so the orchestrator sees it); root-only; fail-open everywhere else. Tests: 84 new across 6 files (manifest modes/atomicity/liveness/discovery, per-method round-trips, clean failures, queued abort against a REAL AgentManager+TaskStore, subscribe stub, unknown method, malformed-line survival, oversize line, stale sweep, manifest lifecycle, root-only, CLI subprocess round-trips incl. the abort prompt, discovery, announcement once + failure-tolerant + absent-on-failure, JSONL visibility, RPC list/steer/version 3/spawn caller auth, index wiring + teardown). Full suite 663 green, typecheck clean.Landing-worker review — verdict: CONCERNS (no blockers) → merging
Fresh-context review (
pi -p --no-session --model deepseek/deepseek-flash, primary sources only: PR diff vsorigin/mainmerge-base, the design artifact, the invariants, the implementer focus list). Reviewer reproduced claims against real code; landing worker independently re-verified the findings below marked [verified].Findings
Concerns
src/core/attach/attach-server.ts:252-259— the socket is group/world-accessible betweenlisten()andchmodSync: the OS creates it 0777 & ~umask, then it is chmod'd 0600. [verified] live probe:umask 002 → 775at the listen callback,600after chmod. Underumask 022the file is 0755 (no write bit →connect()denied), so the window only matters for group-writable umasks; steady state satisfies invariant #1. Design explicitly chose "chmod after bind, past umask", so this is a design-level trade-off, not an implementation deviation. Cheap hardening:const prev = process.umask(0o077); await listen(...); process.umask(prev);(net.Server.listenhas nomodeoption).tests/core/attach-wiring.test.ts:84-96— the orchestrator-observed flake, root-caused: the coldawait import("../../src/core/index.js")sits inside the first test body (~4.4 s standalone, ~4.1 s alongside its sibling attach test files) against vitest's default 5 stestTimeout, so CPU load pushes it over. [verified] reproduced locally: full-suite run with a concurrenttsc→ this test +watch-renderer.test.ts:286failed; clean full-suite rerun → 68 files / 663 pass. Fix: hoist the import to module scope/beforeAll.bin/swarm-attach.js:81-82— flags after steer text are silently absorbed into the message (steer a1 "do X" --jsonsends"do X --json"), andUSAGEclaims flags "may appear anywhere" while the code comment says the opposite.--escape itself is correct (traced).src/core/agent-manager.ts:929-939—{ok:true}is not proof of delivery:record.session.steer(msg).catch(() => {})swallows a rejected delivery while the CLI prints "lands after the current tool call". Pre-existing manager semantics (no new state), so the ack wording over-claims rather than the code being wrong.src/core/attach/*— the socket sees only the root manager's own children.team-registry.allRecords()aggregates across in-process managers, so depth-≥2 (teamlead-spawned) agents are absent fromlistand unsteerable viasteer. Not a regression (design says "thin RPC over AgentManager"), but undocumented; lands on HOMELAB-1559 teams.docs/architecture.md:252still says the RPC surface is "kept byte-identical" and lists onlysubagents:rpc:ping|spawn|stop. The PR adds handshake/list/steer, bumpsPROTOCOL_VERSION2→3, changes thesubagents:readypayload to{contractVersion, spawnToken}, and makesrpc:spawnfail-closed. [verified] OnlyREADME.mdwas updated.Suggestions (non-blocking; follow-up eligible)
attach-server.ts:414-418—close()can hang: a connection accepted after thesocket.destroy()loop is never destroyed, soserver.close(cb)may not call back and the socket/manifest survive teardown.server.closeAllConnections?.()fixes it.bin/swarm-attach.js:314-322— the abort confirm is skipped when thelistreply is notok:true(destructive op proceeds unconfirmed); fail closed instead.attach-server.ts:180— a second pi on the same session tree can unlink a live socket in the window betweenlisten()and the first manifest write ("socket without a manifest" branch).binmap inpi-swarm-suite/package.jsonand nothing on PATH, yet README/USAGEshow bareswarm-list/swarm-steer.attach-server.ts:235—buffer.lengthcompares UTF-16 code units against a byte cap (ATTACH_MAX_REQUEST_BYTES).tests/core/attach-server.test.ts:332—expect(JSON.parse(readdirSync(...) ? "{}" : "{}")).toEqual({})is a tautology [verified]; the.tmpfilter on the next line is the real assertion.attach-manifest.ts:22,24— two separatenode:pathimports.bin/swarm-attach.js:158-169duplicatesroster-json.ts:111-121formatAgewith no drift test, unlike the renderer precedent.Focus-area verdicts
index.ts:612-630: in-flight bind self-closes whenteardownDone || generation !== attachGeneration); depth ≠ 0 returns before any fs/socket op (attach-server.ts:209); sweep refuses to steal a live-owner tree. TheattachStarting-blocks-rebind path is unreachable because pi re-instantiates the extension per session.manager.steer()/abort(); queued abort fails its task in the real store (tested against realAgentManager+TaskStore). Caveat = concern 4.rpc:spawnprovenance — OK to ship fail-closed; no deprecation window required (see below).announceHumanInterventionwraps each surface and never throws; announce is called only after a truesteer()/abort(); a throwingappendEntrystill yields{ok:true}with the roster event landing (tested).--escape correct; symlink dispatch onbasename(argv[1])works via real subprocess tests; discovery keeps onlypidAlivemanifests (EPERM = alive), newestupdatedAtwins, newest-dead loses,--root/--swarm-root/--socketverified; abort confirm[y/N]gated ony|yesunless--yes.Compatibility judgment (
rpc:spawnfail-closed)No deprecation window needed before merge. Machine-wide grep found no live caller of
subagents:rpc:spawn: the in-repoagent_swarmspawns viadeps.manager.spawndirectly, and the only client found (pi-swarm-viewRpcClient) is a disabled extension. An un-upgraded external caller (pi-tasks) would receiveunauthorized caller: subagents:rpc:spawn requires caller provenance { extension, token } (protocol 3)— loud and actionable. Operational note for future consumers: the spawn token is minted per extension instance and pi re-instantiates extensions per session, so a caller caching the token across/new//resumemust re-handshake (subagents:rpc:handshake {caller:{extension}}).Gate evidence
ci.yaml): run 4875 → success. Per job:pr-title✅ (#8717),pi-swarm-suite✅ (#8718),pi-otel-telemetry✅ (#8719). Green first try — no flake in CI.fb414db):npx tsc --noEmit→ exit 0;npx vitest run→ 68 files / 663 tests pass. The one earlier failure set was load-induced (see concern 2) and did not repeat.Merging now (plain merge, branch retained — same convention as #22/#23). Concerns 1, 2, 5 and 6 are worth a small follow-up ticket (umask hardening + import hoist + teams-scope doc note +
architecture.mdcontract refresh).