HOMELAB-1558: feat(subagents): persisted child sessions (flag-gated, single-writer enforced) #25

Merged
aaron merged 3 commits from plane/HOMELAB-1558-phase4 into main 2026-09-19 05:37:22 +00:00
Owner

What

Phase 4 of HOMELAB-1555 (ticket HOMELAB-1558): persisted child sessions — the option-B "actual sessions" ask. Every subagent gets a real pi v3 session file, flag-gated, without breaking Workflow Mode or the single-writer invariant.

  • Flag: persistChildSessions in subagents.json (+ settings page), default OFF. OFF = byte-identical spawn (SessionManager.inMemory(cwd) — arity-asserted in tests).
  • Namespaced dir: <agentDir>/subagent-sessions/<encodeCwd>/<treeId>/ — proven invisible to pi's picker (SessionManager.listAll/list scan <agentDir>/sessions/ only) and to rpiv-workflow's locateSessionFile (reads only dirname(ref.file); rungs are existsSync / *_<id>.jsonl / header-id — never up/sideways/recursive; child ids are sub-<agentId> so id-equality can't match a workflow id; replica-tested incl. child file copied into root dir). Workflow Mode can never adopt a child as stage driver.
  • Spawn site: one block in agent-runner.ts → createChildSessionManager({persist, cwd, agentId, treeId, parentSession}) → SessionManager.create(cwd, dir, {id, parentSession}); flushes on first assistant turn; replayable via SessionManager.open (tested) → post-mortem resume/fork + per-session cost attribution.
  • Tree-id anchor: the rootSessionId ?? getSessionId() split hazard is real — fixed with resolveTreeAnchor() at all three sites + pinRootSessionAnchor() at root session_start (re-pins on /new//resume/--fork); nested lead+worker land in one dir (tested).
  • Single-writer guard: parent writes <sessionFile>.live.json (0600) per live child, cleared on terminal/close/remove/dispose. A process opening a live foreign marker → loud warning + ctx.shutdown() + spawn-refusal latch; same-pid self and stale (dead-pid) exempt. Resume is post-mortem only, enforced not just documented.

Deviations

Session-file link in TUI detail row (not grid card); no retention sweep yet (documented); shutdown guard gated off under vitest.

Verify

  • tsc --noEmit clean
  • vitest run: 65 files / 621 tests pass (42 new) — flag-off identity, dir isolation (real listAll/list + locateSessionFile replica), replay via SessionManager.open, tree-anchor pinning, guard lifecycle (create/clear/stale/foreign/self)
  • Flag-on live soak (Aaron opts in via subagents.json)

Reviewer focus: guard semantics (shutdown hammer, fail-closed unreadable marker), the locateSessionFile isolation claim, tree-anchor pin across session switches, flag-off byte-identity.

Refs HOMELAB-1558, epic HOMELAB-1555.

## What Phase 4 of HOMELAB-1555 (ticket HOMELAB-1558): persisted child sessions — the option-B "actual sessions" ask. Every subagent gets a real pi v3 session file, flag-gated, without breaking Workflow Mode or the single-writer invariant. - **Flag:** `persistChildSessions` in `subagents.json` (+ settings page), **default OFF**. OFF = byte-identical spawn (`SessionManager.inMemory(cwd)` — arity-asserted in tests). - **Namespaced dir:** `<agentDir>/subagent-sessions/<encodeCwd>/<treeId>/` — proven invisible to pi's picker (`SessionManager.listAll/list` scan `<agentDir>/sessions/` only) and to rpiv-workflow's `locateSessionFile` (reads only `dirname(ref.file)`; rungs are existsSync / `*_<id>.jsonl` / header-id — never up/sideways/recursive; child ids are `sub-<agentId>` so id-equality can't match a workflow id; replica-tested incl. child file copied into root dir). Workflow Mode can never adopt a child as stage driver. - **Spawn site:** one block in `agent-runner.ts` → `createChildSessionManager({persist, cwd, agentId, treeId, parentSession})` → `SessionManager.create(cwd, dir, {id, parentSession})`; flushes on first assistant turn; replayable via `SessionManager.open` (tested) → post-mortem resume/fork + per-session cost attribution. - **Tree-id anchor:** the `rootSessionId ?? getSessionId()` split hazard is real — fixed with `resolveTreeAnchor()` at all three sites + `pinRootSessionAnchor()` at root `session_start` (re-pins on `/new`/`/resume`/`--fork`); nested lead+worker land in one dir (tested). - **Single-writer guard:** parent writes `<sessionFile>.live.json` (0600) per live child, cleared on terminal/close/remove/dispose. A process opening a live foreign marker → loud warning + `ctx.shutdown()` + spawn-refusal latch; same-pid self and stale (dead-pid) exempt. Resume is post-mortem only, enforced not just documented. ## Deviations Session-file link in TUI detail row (not grid card); no retention sweep yet (documented); `shutdown` guard gated off under vitest. ## Verify - [x] `tsc --noEmit` clean - [x] `vitest run`: 65 files / **621 tests pass (42 new)** — flag-off identity, dir isolation (real listAll/list + locateSessionFile replica), replay via SessionManager.open, tree-anchor pinning, guard lifecycle (create/clear/stale/foreign/self) - [ ] Flag-on live soak (Aaron opts in via `subagents.json`) **Reviewer focus:** guard semantics (shutdown hammer, fail-closed unreadable marker), the locateSessionFile isolation claim, tree-anchor pin across session switches, flag-off byte-identity. Refs HOMELAB-1558, epic HOMELAB-1555.
HOMELAB-1558: feat(subagents): persisted child sessions (flag-gated, single-writer enforced)
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 1m46s
CI / pi-otel-telemetry (pull_request) Successful in 27s
9e2bfb9b73
Flag-gated `persistChildSessions` (subagents.json + suite settings page, default
OFF): every subagent gets a real pi v3 session file instead of an in-memory
session, so `pi --session <child>` replays it post-mortem, `--fork` branches it,
and per-session cost attribution becomes queryable. Off is the exact pre-feature
call — `SessionManager.inMemory(cwd)`, arity-asserted in tests, nothing on disk.

- New `core/child-sessions.ts`: the whole feature seam. Dir namespace
  `<agentDir>/subagent-sessions/<encodedCwd>/<treeId>/` — a sibling of pi's
  `sessions/`, so `SessionManager.listAll/list` (real pi) never see a child, and
  rpiv-workflow's `locateSessionFile` (which reads only `dirname(ref.file)`,
  rungs 1–3, never recursing) can never adopt one as a stage driver. Second
  layer: child ids are `sub-<agentId>`, so the rungs' id equality cannot match
  a workflow session id either.
- The one spawn site (`agent-runner.ts`) now calls
  `createChildSessionManager({persist, cwd, agentId, treeId, parentSession})`;
  explicit `{id, parentSession}` linkage, file written on the first assistant
  turn like any pi session (verified replayable via `SessionManager.open`).
- Tree anchor: `resolveTreeAnchor()` + `pinRootSessionAnchor()` replace the three
  `rootSessionId ?? getSessionId()` sites in agent-manager. The root pins once at
  `session_start` (and re-pins on `/new`, `/resume`, `--fork`); children inherit
  the stamped id, so a lead's worker lands in the ROOT's dir, never in a dir named
  after the lead's own session id.
- Single-writer rule (pi has no locking — verified `SessionManager.open` appends
  with no owner check): the parent writes `<sessionFile>.live.json` for every live
  child and clears it on every terminal/dispose path. A pi whose current session
  file carries a live foreign marker warns loudly, refuses to spawn, and
  gracefully shuts down; the in-process child (same pid + same session id) and
  stale markers (dead holder → cleaned) are exempt, so post-mortem resume works.
  `pi --fork` documented as the safe live alternative.
- Linkage surfaces: `AgentRecord.sessionFile`, task-store `session.json`,
  `.output` entries, `<session-file>` in the task notification, `subagents:*`
  event payloads, and the swarm TUI detail row.

Tests: 621 passed (65 files); 42 new — dir/id/tree/guard units, spawn-path
wiring (marker lifecycle, task link, transcript stamp, flag-off no-op), settings
round-trip, TUI diagnosis. `npm run typecheck` clean. No pi-core changes.
Author
Owner

Landing review — BLOCKERS → NOT merged

Independent review in a fresh one-shot context (pi -p --no-session, reviewer ≠ implementer), built from primary sources only: PR diff 13ba30d..9e2bfb9, the ticket's acceptance criteria, and the real installed implementations (pi dist/, @juicesharp/rpiv-workflow/sessions/locate.ts). No implementer summary was used as the brief.

Local verification reproduced by me on the branch head: npx tsc --noEmit clean, npx vitest run → 65 files / 621 tests passed.


BLOCKER 1 — the single-writer stop is a silent no-op in print/JSON mode, so a second writer really does append to a live child's file

Acceptance criterion 4 ("enforced: never resume a LIVE child's file") and the docstring/README claim "any pi process whose current session file carries a live marker refuses to run" are false for -p and --mode json.

Verified chain (installed pi ~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent):

  1. src/core/child-sessions.ts:351-355 — the violation branch's only stop is hooks.shutdown?.().
  2. src/core/index.ts:599 — that hook is ctx.shutdown?.(); :601 gates it only on VITEST.
  3. dist/core/agent-session.js:2073 — shutdown: () => { this._extensionShutdownHandler?.(); }; :1919-1920 assigns _extensionShutdownHandler only if the mode passed one.
  4. dist/modes/print-mode.js:53-84 — bindExtensions({ mode, commandContextActions, onError }): no shutdownHandler (the file contains zero occurrences of "shutdown"). Print mode serves both -p text and --mode json.
    • Contrast: dist/modes/interactive/interactive-mode.js:1458 and dist/modes/rpc/rpc-mode.js:256 do pass one — so the guard works there and silently doesn't everywhere else.
  5. dist/modes/print-mode.js:105 then runs await session.prompt(initialMessage), and dist/core/session-manager.js:739-747 (_persist) short-circuits on hasAssistant — a resumed child file does already contain assistant entries — so it appendFileSyncs straight into the live child's file.

Net effect: pi -p --session <live child>.jsonl "…" prints the SINGLE-WRITER VIOLATION banner, latches the spawn refusal, does not exit, and writes a second writer into one append-only tree. That is exactly the corruption this criterion exists to prevent.

Repro (deterministic, no cluster): with persistChildSessions on, spawn a long-lived background child, then run pi -p --session ~/.pi/agent/subagent-sessions/<encCwd>/<treeId>/<ts>_sub-<id>.jsonl "say hi" — the warning prints, the process continues, and the file gains the second process's turns.

Fix direction (owner's call, but the stop must not be optional): have the guard hard-stop unconditionally — e.g. process.exit(1) on violation (extensions are free to do this; no pi-core change needed) — or, if the mode-dependent stop is kept, narrow the claim in src/core/child-sessions.ts:33 and README.md:376 to interactive/RPC and stop advertising enforcement. Docs and enforcement must agree either way.

Concerns (non-blocking)

  1. src/core/child-sessions.ts:311-327 — pid-recycling false positive is availability-destructive. A crashed parent's marker whose pid is later recycled → isPidAlive true → unescapable violation: a legitimate post-mortem resume of a finished child is warned, spawn-latched, and shut down, and the violating branch never clears the marker (only stale does). Nothing bounds marker age. Cheap mitigation: record/compare the holder's process start time (/proc/<pid>/stat field 22) or a boot id.
  2. src/core/agent-manager.ts:744-757, src/core/output-file.ts:87 — the replay handle can point at a nonexistent file. SessionManager.create writes the file lazily (first assistant message — asserted by this PR's own test at tests/core/child-sessions.test.ts:197-202), yet the marker, record.sessionFile, session.json and every .output entry are stamped at spawn. A child that dies before its first assistant turn leaves links to a missing path; pi --session <that path> then creates a new empty session at that path with a different id instead of replaying (dist/core/session-manager.js open: existsSync false → fresh newSession()).
  3. src/core/child-sessions.ts:295-308 — the self exemption keys on pid+id equality only, so it cannot distinguish "the child's own spawn-site instance" from any other manager in this process that opens the same file. No reachable trigger found today, hence a concern.

Suggestions

  • tests/core/child-sessions.test.ts:104-131 — the locateSessionFile replica is faithful in rungs/inputs (I diffed it against the real sessions/locate.ts; the isolation claim holds: dirname-only, no recursion, no parent walk) but it guarantees nothing about the real module long-term. Pin it (fixture/hash of the upstream file) or assert the structural property against the upstream source.
  • src/view/tui.ts:383 — the session: line renders for running agents, i.e. precisely the file that must not be opened; label the ownership state (live — replay post-mortem / replayable).
  • writeChildSessionLink — non-atomic writeFileSync; a reader landing mid-write reads a truncated marker → unreadable → fail-closed → could destroy a legitimate session. Write temp + renameSync.
  • Document that post-mortem replay of a child from a cleaned-up cwd fails with MissingSessionCwdError.

Claim audit highlights (verified TRUE)

locateSessionFile isolation (dir + sub-* id equality, two layers) · picker/listAll sees only getSessionsDir() subdirs (session-manager.js:1319-1336, config.js:457) · pi has no session locking · child bus carries the root anchor (agent-runner.ts:628 stamps before session creation; index.ts:570 pins root-only) · flag OFF = SessionManager.inMemory(cwd) with exact arity.
FALSE: the "refuses to run" claim (above).

CI + base state

  • No Forgejo Actions run exists for this PR. actions/tasks lists runs for #22, #23, #24 and main pushes only — nothing for #25, ~10 min after it was opened, while main-push runs were dispatched in that window. Working hypothesis: pull_request dispatch needs a merge ref, and this PR is unmergeable (mergeable: false).
  • Base conflict is real: branch is based on 13ba30d; origin/main now has PR #24 merged at f883bb6. git merge-tree → one conflict, pi-swarm-suite/README.md (this branch adds the Persisted child sessions table row; main added the Attach socket + CLI row). Everything else, incl. src/core/index.ts, auto-merges.
  • Recommended next step (fix worker, fresh context, one push): fix the blocker, sync the base and resolve the README conflict by keeping both the attach rows and the persisted-child-session rows, correct the overclaiming docs, then push — and confirm a CI run actually appears (unblocks the merge).

Decision: not merged. Blocker stands; no push made from this review (the fix worker should own the single follow-up commit so the branch isn't moved twice).

## Landing review — BLOCKERS → NOT merged Independent review in a **fresh one-shot context** (`pi -p --no-session`, reviewer ≠ implementer), built from primary sources only: PR diff `13ba30d..9e2bfb9`, the ticket's acceptance criteria, and the *real* installed implementations (pi `dist/`, `@juicesharp/rpiv-workflow/sessions/locate.ts`). No implementer summary was used as the brief. Local verification reproduced by me on the branch head: `npx tsc --noEmit` clean, `npx vitest run` → **65 files / 621 tests passed**. --- ### BLOCKER 1 — the single-writer stop is a silent no-op in print/JSON mode, so a second writer really does append to a live child's file Acceptance criterion 4 ("enforced: never resume a LIVE child's file") and the docstring/README claim *"any pi process whose current session file carries a live marker refuses to run"* are **false for `-p` and `--mode json`**. Verified chain (installed pi `~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent`): 1. `src/core/child-sessions.ts:351-355` — the violation branch's only stop is `hooks.shutdown?.()`. 2. `src/core/index.ts:599` — that hook is `ctx.shutdown?.()`; `:601` gates it only on `VITEST`. 3. `dist/core/agent-session.js:2073` — `shutdown: () => { this._extensionShutdownHandler?.(); }`; `:1919-1920` assigns `_extensionShutdownHandler` **only if the mode passed one**. 4. `dist/modes/print-mode.js:53-84` — `bindExtensions({ mode, commandContextActions, onError })`: **no `shutdownHandler`** (the file contains zero occurrences of "shutdown"). Print mode serves both `-p` text and `--mode json`. - Contrast: `dist/modes/interactive/interactive-mode.js:1458` and `dist/modes/rpc/rpc-mode.js:256` *do* pass one — so the guard works there and silently doesn't everywhere else. 5. `dist/modes/print-mode.js:105` then runs `await session.prompt(initialMessage)`, and `dist/core/session-manager.js:739-747` (`_persist`) short-circuits on `hasAssistant` — a resumed child file *does* already contain assistant entries — so it `appendFileSync`s straight into the live child's file. Net effect: `pi -p --session <live child>.jsonl "…"` prints the SINGLE-WRITER VIOLATION banner, latches the spawn refusal, **does not exit**, and writes a second writer into one append-only tree. That is exactly the corruption this criterion exists to prevent. Repro (deterministic, no cluster): with `persistChildSessions` on, spawn a long-lived background child, then run `pi -p --session ~/.pi/agent/subagent-sessions/<encCwd>/<treeId>/<ts>_sub-<id>.jsonl "say hi"` — the warning prints, the process continues, and the file gains the second process's turns. Fix direction (owner's call, but the stop must not be optional): have the guard hard-stop unconditionally — e.g. `process.exit(1)` on violation (extensions are free to do this; no pi-core change needed) — or, if the mode-dependent stop is kept, narrow the claim in `src/core/child-sessions.ts:33` and `README.md:376` to interactive/RPC and stop advertising enforcement. Docs and enforcement must agree either way. ### Concerns (non-blocking) 1. `src/core/child-sessions.ts:311-327` — **pid-recycling false positive is availability-destructive**. A crashed parent's marker whose pid is later recycled → `isPidAlive` true → unescapable `violation`: a legitimate post-mortem resume of a *finished* child is warned, spawn-latched, and shut down, and the violating branch never clears the marker (only `stale` does). Nothing bounds marker age. Cheap mitigation: record/compare the holder's process start time (`/proc/<pid>/stat` field 22) or a boot id. 2. `src/core/agent-manager.ts:744-757`, `src/core/output-file.ts:87` — **the replay handle can point at a nonexistent file**. `SessionManager.create` writes the file lazily (first assistant message — asserted by this PR's own test at `tests/core/child-sessions.test.ts:197-202`), yet the marker, `record.sessionFile`, `session.json` and every `.output` entry are stamped at spawn. A child that dies before its first assistant turn leaves links to a missing path; `pi --session <that path>` then creates a *new* empty session at that path with a different id instead of replaying (`dist/core/session-manager.js` `open`: `existsSync` false → fresh `newSession()`). 3. `src/core/child-sessions.ts:295-308` — the `self` exemption keys on pid+id equality only, so it cannot distinguish "the child's own spawn-site instance" from any other manager in this process that opens the same file. No reachable trigger found today, hence a concern. ### Suggestions - `tests/core/child-sessions.test.ts:104-131` — the `locateSessionFile` replica is faithful in rungs/inputs (I diffed it against the real `sessions/locate.ts`; the isolation claim holds: dirname-only, no recursion, no parent walk) but it guarantees nothing about the *real* module long-term. Pin it (fixture/hash of the upstream file) or assert the structural property against the upstream source. - `src/view/tui.ts:383` — the `session:` line renders for *running* agents, i.e. precisely the file that must not be opened; label the ownership state (`live — replay post-mortem` / `replayable`). - `writeChildSessionLink` — non-atomic `writeFileSync`; a reader landing mid-write reads a truncated marker → unreadable → fail-closed → could destroy a legitimate session. Write temp + `renameSync`. - Document that post-mortem replay of a child from a cleaned-up cwd fails with `MissingSessionCwdError`. ### Claim audit highlights (verified TRUE) `locateSessionFile` isolation (dir + `sub-*` id equality, two layers) · picker/`listAll` sees only `getSessionsDir()` subdirs (`session-manager.js:1319-1336`, `config.js:457`) · pi has no session locking · child bus carries the root anchor (`agent-runner.ts:628` stamps before session creation; `index.ts:570` pins root-only) · flag OFF = `SessionManager.inMemory(cwd)` with exact arity. FALSE: the "refuses to run" claim (above). ### CI + base state - **No Forgejo Actions run exists for this PR.** `actions/tasks` lists runs for `#22`, `#23`, `#24` and `main` pushes only — nothing for `#25`, ~10 min after it was opened, while main-push runs were dispatched in that window. Working hypothesis: `pull_request` dispatch needs a merge ref, and this PR is **unmergeable** (`mergeable: false`). - **Base conflict is real:** branch is based on `13ba30d`; `origin/main` now has PR #24 merged at `f883bb6`. `git merge-tree` → one conflict, `pi-swarm-suite/README.md` (this branch adds the *Persisted child sessions* table row; main added the *Attach socket + CLI* row). Everything else, incl. `src/core/index.ts`, auto-merges. - Recommended next step (fix worker, fresh context, one push): fix the blocker, sync the base and resolve the README conflict by keeping **both** the attach rows and the persisted-child-session rows, correct the overclaiming docs, then push — and confirm a CI run actually appears (unblocks the merge). **Decision: not merged.** Blocker stands; no push made from this review (the fix worker should own the single follow-up commit so the branch isn't moved twice).
# Conflicts:
#	pi-swarm-suite/README.md
HOMELAB-1558: fix(subagents): unconditional single-writer stop + marker hardening
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 2m3s
CI / pi-otel-telemetry (pull_request) Successful in 36s
79b590ebdb
Fresh-context review found the guard was a NO-OP in print/json mode: it called
`ctx.shutdown()` -> `agent-session.js` `_extensionShutdownHandler?.()`, and that
handler is only bound by the modes that supply one. `dist/modes/print-mode.js`
(serving `pi -p` AND `--mode json`) supplies NONE, so the process printed the
warning and ran on into `session.prompt()` — appending into the live child file
(the exact second writer this guard exists to prevent).

- Stop mechanism (Option A): after the loud warning, the guard requests the
  graceful shutdown AND hard-exits with a SYNCHRONOUS `process.exit(1)`. It
  cannot be delegated to pi, and it cannot be deferred: `session_start` is
  emitted from `bindExtensions`, which print mode awaits before its first
  prompt, so an in-handler exit is what guarantees no append. Injectable `stop`
  hook + `allowStop` gate keeps vitest alive (`!process.env.VITEST` at the call
  site, the existing gate pattern). Scoped to FOREIGN holders: a same-pid
  verdict means this process IS the holder (no second writer), so it warns +
  refuses to spawn + requests shutdown without killing a healthy parent.
  An unreadable marker has no known holder -> treated as foreign (fail closed).
- Warning durability: the box is written with a synchronous fd-2 write
  (`writeStderrSync`) — `console.warn` is async on a pipe and the exit would
  otherwise truncate it before it lands.
- Concern 1, pid-recycled false positive: markers now carry the HOLDER's
  process start time (`/proc/<pid>/stat` field 22) and the boot id. A
  mismatching token PROVES the holder is gone (pid recycled / previous boot) ->
  stale, cleaned, post-mortem resume works; a missing token (legacy marker or
  no /proc) falls back to `kill(pid, 0)` and only refuses — the safe direction.
  Residual risk on non-/proc platforms documented.
- Concern 2, lazily-created session file: pi writes a session file only on the
  first assistant entry, so a child that dies earlier leaves the creation-time
  handle pointing at a file that was never written. `resolveChildSessionFile()`
  re-resolves from the live SessionManager at terminal time, drops the handle,
  clears the marker and the durable `session.json` link instead of advertising
  a replay path that replays nothing.
- Overclaims corrected: `child-sessions.ts` module docstring + README now state
  the real guarantee (which modes are covered, how, and why the exit is not
  optional), plus the new limitations.

Tests: 717 passed (71 files); 12 new — headless-shape stop, stop ordering /
throwing shutdown, `process.exit(1)` default, allowStop gate, same-pid safety,
unreadable-marker hard stop, token parsing/offset, recycled-pid stale,
other-boot stale, legacy-marker fallback, holder-token authorship, replay-handle
resolution, early-death wiring. `npm run typecheck` clean.

End-to-end proof against real `pi -p` (print mode) with the suite extension and
a live foreign marker: pre-fix 4 -> 6 lines appended into the child file (and
the warning's shutdown was a no-op); post-fix exit 1 with the box on stderr and
4 -> 4 lines (no append).

Also merges origin/main (attach socket phase, PR #24) — conflict resolved in
pi-swarm-suite/README.md keeping both sections.
Author
Owner

Landing review round 2 — no blockers → merging

Independent fresh-context review (pi -p --no-session --no-context-files --no-skills, reviewer ≠ implementer, foreground one-shot), brief built from primary sources only: delta faf441f..79b590e, ticket acceptance criteria, invariants (flag-OFF identity / no pi-core change / single-writer), and the real installed implementations. No implementer summary used as the brief. CI at this head: pr-title ✅ pi-swarm-suite ✅ pi-otel-telemetry ✅.

Verdict: APPROVE-WITH-CONCERNS.

Round-1 blocker: CONFIRMED FIXED

The print/json no-op is closed. Re-derived from installed dist/ at 0.85.1: modes/print-mode.js binds {mode, commandContextActions, onError} — zero occurrences of shutdown; modes/interactive/interactive-mode.js:1458 + modes/rpc/rpc-mode.js:256 are the only handlers. The stop is now unconditional and synchronous on the real path: agent-session.js:1926 is the last statement of bindExtensions, print-mode.js:103 awaits rebindSession() before session.prompt at :105/108, and index.ts:617 stop: (code) => process.exit(code) runs inside the handler → no append on the prompt path. Same-pid scoping is correct (child-sessions.ts:545 foreignHolder = link===undefined || link.pid!==pid; unreadable marker has no link → foreign → hard stop). Warning lands on fd 2 via writeSync before the exit. --fork does not misfire (forkFrom writes a NEW file → no marker).

Round-1 concerns: CONFIRMED FIXED

  • pid recycle — token parse verified by hand against a live /proc/self/stat (index 1 == PPid → field 4; index 19 == field 22): correct, lastIndexOf(")") is the right split. Direction errs toward refuse ("unknown" only from a failed read, never a mismatch; then kill(0)). Tokens are written for the marker's own pid (child-sessions.ts:227), and the sole production call site passes process.pid — holder ≡ writer.
  • dead replay handle — releaseChildSession is reached from all four terminal paths (agent-manager.ts:241 terminal choke point, :276 closeTranscript, :1068 removeRecord, :1204 dispose); never-created case drops marker + record handle + task-store link; TaskStore.clearSessionLink idempotent.
  • round-1 verified-TRUE items survived the base sync — rpiv locate.ts rungs never walk up/sideways/recursive (rung 1 existsSync(ref.file), rungs 2–3 readdir/header-scan of dirname(ref.file) only), sub-<agentId> ids can't satisfy an id-equality rung; getSessionsDir() = <agentDir>/sessions (config.js:457) vs child dir <agentDir>/subagent-sessions/… — siblings, picker invisible. Tree anchor at all spawn sites; children never re-pin and never even emit session_start (sdk.js:253 constructs AgentSession without bindExtensions). Flag OFF = SessionManager.inMemory(cwd), byte-identical, no fs writes; default OFF at settings.ts:352 and suite-settings.ts:61. Merge integrity: no conflict markers, f883bb6..faf441f touches exactly the 16 feature files, attach-socket content intact on both sides.
  1. pi --name + live child appends one entry before the guard can fire. dist/main.ts:696 sessionManager.appendSessionInfo(name) runs right after createSessionManager, before runtime creation → before session_start. For a resumed child file _persist takes the appendFileSync branch → exactly one session_info line lands in the live tree, then the guard exits. Bounded (metadata-only, requires the unrelated --name), unfixable from the suite without a pi-core change (I2) → the actionable part is a docs correction: child-sessions.ts:567 "no append will be made" and the README "unconditional" wording should name this exception.
  2. allowStop: !process.env.VITEST (index.ts:619) is an env-var bypass of the only real guarantee. With VITEST set in a pi process's environment, print-mode resume returns to the exact round-1 shape. Test-survivability trade-off, but it should be narrowed (injected stop hook / explicit test opt-in) or documented next to the mode caveat.
  3. boot-id rung is fail-OPEN cross-machine (child-sessions.ts:366-368): /proc/sys/kernel/random/boot_id is per-machine, so a marker read from another box on a shared agent dir judges a genuinely live holder dead, deletes the marker, and allows a second writer — and the rung short-circuits before the valid pid token. Low likelihood in a single-box lab, high blast radius. Fix: only treat a boot mismatch as dead when a recorded machine identity (machine-id/hostname) also matches.
  4. dispose() nils this.taskStore before releaseChildSession (agent-manager.ts:1201 vs :1204), so the never-created-case session.json repair can be skipped when record.taskStoreRef is unset. Reorder or pass the store explicitly.
  5. Suggestions: same-pid verdict also fires the graceful ctx.shutdown() (documented intent says "only warns"), and resolveChildSessionFile trusts existence, not content/header id.

Local verification run by the reviewer: npx tsc --noEmit exit 0; npx vitest run tests/core/child-sessions.test.ts tests/core/child-session-wiring.test.ts → 2 files / 45 tests passed. Merging now (plain merge, flag default OFF).

## Landing review round 2 — **no blockers → merging** Independent fresh-context review (`pi -p --no-session --no-context-files --no-skills`, reviewer ≠ implementer, foreground one-shot), brief built from primary sources only: delta `faf441f..79b590e`, ticket acceptance criteria, invariants (flag-OFF identity / no pi-core change / single-writer), and the real installed implementations. No implementer summary used as the brief. CI at this head: `pr-title` ✅ `pi-swarm-suite` ✅ `pi-otel-telemetry` ✅. **Verdict: APPROVE-WITH-CONCERNS.** ### Round-1 blocker: CONFIRMED FIXED The print/json no-op is closed. Re-derived from installed `dist/` at 0.85.1: `modes/print-mode.js` binds `{mode, commandContextActions, onError}` — zero occurrences of `shutdown`; `modes/interactive/interactive-mode.js:1458` + `modes/rpc/rpc-mode.js:256` are the only handlers. The stop is now unconditional and **synchronous on the real path**: `agent-session.js:1926` is the last statement of `bindExtensions`, `print-mode.js:103` awaits `rebindSession()` before `session.prompt` at `:105/108`, and `index.ts:617` `stop: (code) => process.exit(code)` runs inside the handler → no append on the prompt path. Same-pid scoping is correct (`child-sessions.ts:545` `foreignHolder = link===undefined || link.pid!==pid`; unreadable marker has no link → foreign → hard stop). Warning lands on fd 2 via `writeSync` before the exit. `--fork` does not misfire (`forkFrom` writes a NEW file → no marker). ### Round-1 concerns: CONFIRMED FIXED - **pid recycle** — token parse verified by hand against a live `/proc/self/stat` (index 1 == PPid → field 4; index 19 == field 22): correct, `lastIndexOf(")")` is the right split. Direction errs toward **refuse** (`"unknown"` only from a failed read, never a mismatch; then `kill(0)`). Tokens are written for the marker's own pid (`child-sessions.ts:227`), and the sole production call site passes `process.pid` — holder ≡ writer. - **dead replay handle** — `releaseChildSession` is reached from all four terminal paths (`agent-manager.ts:241` terminal choke point, `:276` closeTranscript, `:1068` removeRecord, `:1204` dispose); never-created case drops marker + record handle + task-store link; `TaskStore.clearSessionLink` idempotent. - **round-1 verified-TRUE items survived the base sync** — rpiv `locate.ts` rungs never walk up/sideways/recursive (rung 1 `existsSync(ref.file)`, rungs 2–3 readdir/header-scan of `dirname(ref.file)` only), `sub-<agentId>` ids can't satisfy an id-equality rung; `getSessionsDir()` = `<agentDir>/sessions` (`config.js:457`) vs child dir `<agentDir>/subagent-sessions/…` — siblings, picker invisible. Tree anchor at all spawn sites; children never re-pin and never even emit `session_start` (`sdk.js:253` constructs `AgentSession` without `bindExtensions`). Flag OFF = `SessionManager.inMemory(cwd)`, byte-identical, no fs writes; default OFF at `settings.ts:352` and `suite-settings.ts:61`. Merge integrity: no conflict markers, `f883bb6..faf441f` touches exactly the 16 feature files, attach-socket content intact on both sides. ### Concerns posted (non-blocking) — follow-up work recommended 1. **`pi --name` + live child appends one entry before the guard can fire.** `dist/main.ts:696` `sessionManager.appendSessionInfo(name)` runs right after `createSessionManager`, before runtime creation → before `session_start`. For a resumed child file `_persist` takes the `appendFileSync` branch → exactly one `session_info` line lands in the live tree, then the guard exits. Bounded (metadata-only, requires the unrelated `--name`), unfixable from the suite without a pi-core change (I2) → the actionable part is a docs correction: `child-sessions.ts:567` "no append will be made" and the README "unconditional" wording should name this exception. 2. **`allowStop: !process.env.VITEST` (`index.ts:619`) is an env-var bypass of the only real guarantee.** With `VITEST` set in a pi process's environment, print-mode resume returns to the exact round-1 shape. Test-survivability trade-off, but it should be narrowed (injected `stop` hook / explicit test opt-in) or documented next to the mode caveat. 3. **boot-id rung is fail-OPEN cross-machine** (`child-sessions.ts:366-368`): `/proc/sys/kernel/random/boot_id` is per-machine, so a marker read from another box on a shared agent dir judges a genuinely live holder `dead`, deletes the marker, and allows a second writer — and the rung short-circuits before the valid pid token. Low likelihood in a single-box lab, high blast radius. Fix: only treat a boot mismatch as `dead` when a recorded machine identity (machine-id/hostname) also matches. 4. **`dispose()` nils `this.taskStore` before `releaseChildSession`** (`agent-manager.ts:1201` vs `:1204`), so the never-created-case `session.json` repair can be skipped when `record.taskStoreRef` is unset. Reorder or pass the store explicitly. 5. Suggestions: same-pid verdict also fires the graceful `ctx.shutdown()` (documented intent says "only warns"), and `resolveChildSessionFile` trusts existence, not content/header id. Local verification run by the reviewer: `npx tsc --noEmit` exit 0; `npx vitest run tests/core/child-sessions.test.ts tests/core/child-session-wiring.test.ts` → 2 files / 45 tests passed. Merging now (plain merge, flag default OFF).
aaron merged commit 12342f313c into main 2026-09-19 05:37:22 +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!25
No description provided.