Skip to content

fix(pi): keep one watcher owner and exit cleanup per Pi session - #1183

Open
v4nimus wants to merge 5 commits into
kunchenguid:mainfrom
v4nimus:fm/pi-watch-new-rearm-fix
Open

fix(pi): keep one watcher owner and exit cleanup per Pi session#1183
v4nimus wants to merge 5 commits into
kunchenguid:mainfrom
v4nimus:fm/pi-watch-new-rearm-fix

Conversation

@v4nimus

@v4nimus v4nimus commented Jul 28, 2026

Copy link
Copy Markdown

Intent

Fix the Pi primary watcher extension so a same-process session_shutdown followed by session_start restores a clean reusable lifecycle, re-arms supervision without duplicate watcher children or weakened lock checks, reinstalls exactly one process-exit cleanup listener per active logical session, resets retry and restoration state safely, and ships the correction as an unmerged PR with focused regression coverage and green checks.

What Changed

  • .pi/extensions/fm-primary-pi-watch.ts makes generation activation first-owner-wins: a factory bind that overlaps a still-live generation now defers instead of taking over the live arm child, and session_shutdown deactivates the ending generation (clearing activeGeneration) rather than only stopping it, so a replacement session_start can claim ownership and arm again in the same process.
  • Process-exit cleanup moved from a single module-level process.once("exit", …) to install/remove tied to the active generation, so exactly one exit listener is installed per active logical session and it is dropped when that session ends.
  • Arm refusals are now three distinct messages — session shutting down, another live generation owns supervision, and (from the review-phase fix) an inactive binding that owns nothing — with docs/supervision-protocols/pi.md, docs/verification/supervision.md, and docs/watcher-continuity.md updated to match, and tests/fm-pi-watch-extension.test.sh extended to cover replacement arming with reset retry state, the overlapping-bind refusal, the deferred binding's refusal after its incumbent ends plus arming through its own session_start, and per-session listener install/removal across /new, /resume, /fork, and quit.

Risk Assessment

✅ Low: The branch is well-bounded to one Pi extension lifecycle path, satisfies every source-verifiable intent criterion, and the round-1 messaging inaccuracy is now resolved by a discriminator that provably only claims a live competing owner when one exists, with focused regression coverage for the previously-untested owner-ended state.

Testing

Ran the branch's own targeted suite (tests/fm-pi-watch-extension.test.sh, 33 checks green) and then proved the intent at the product level with a manual harness that loads the tracked extension exactly as Pi does and drives a real session lifecycle: startup arm, then /new, /resume and /fork same-process restarts, an overlapping stray binding, that binding outliving its owner, and terminal quit. Running the identical harness against the base commit isolates the correction: base armed a second watcher child for the overlapping binding (2 live children) and falsely told the real live owner "Pi session is shutting down", while the branch refuses with the new live-owner message, keeps one child, later reports the binding as inactive rather than naming a vanished owner, and arms only after its own session_start. Across four replacements the branch held exactly one live watcher child and one process-exit cleanup listener, dropping to zero on quit where base left the listener installed; every replacement arm reported "arm child 1", showing retry/restoration state reset. Swapping the base extension under the branch tests confirmed the new generation-owner coverage genuinely fails on the old behavior. No screenshot applies: the change touches no rendering code, and the end-user surface is the fm_watch_arm_pi tool-result text, captured verbatim in the transcripts. Worktree left clean and the leaked test watcher child cleaned up.

Evidence: Pi lifecycle transcript — branch a4d3724 (fixed)

[5] A stray extra extension binding activates while session A's generation is still live. agent(stray binding) calls fm_watch_arm_pi -> watcher: not armed - another live Pi session generation owns watcher supervision; this binding stays idle until that session ends observed: live watcher children=1 (pid 241072), process-exit cleanup listeners=1 agent(live owner) calls fm_watch_arm_pi -> watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed [6] The live owner's session ends (/new). The stray binding is now behind no live owner. agent(stray binding) calls fm_watch_arm_pi -> watcher: not armed - this Pi session binding is inactive and does not own watcher supervision; it stays idle until it receives its own session start observed: live watcher children=0, process-exit cleanup listeners=0 [8] Operator quits Pi: session_shutdown(quit). agent(after quit) calls fm_watch_arm_pi -> watcher: not armed - Pi session is shutting down observed: live watcher children=0, process-exit cleanup listeners=0

=== Pi primary watcher lifecycle demo (fixed) ===

[1] Pi starts. session_start fires for session A.
    agent(A) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 241035), process-exit cleanup listeners=1

[2] Operator runs /new: session_shutdown(new) then session_start(new) in the same Pi process.
    agent(after /new) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 241048), process-exit cleanup listeners=1, retired child(ren) 241035 stopped

[3] Operator runs /resume: session_shutdown(resume) then session_start(resume) in the same Pi process.
    agent(after /resume) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 241060), process-exit cleanup listeners=1, retired child(ren) 241048 stopped

[4] Operator runs /fork: session_shutdown(fork) then session_start(fork) in the same Pi process.
    agent(after /fork) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 241072), process-exit cleanup listeners=1, retired child(ren) 241060 stopped

[5] A stray extra extension binding activates while session A's generation is still live.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: not armed - another live Pi session generation owns watcher supervision; this binding stays idle until that session ends
    observed: live watcher children=1 (pid 241072), process-exit cleanup listeners=1, session A's own child was 241072
    agent(live owner) calls fm_watch_arm_pi ->
      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy

[6] The live owner's session ends (/new). The stray binding is now behind no live owner.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: not armed - this Pi session binding is inactive and does not own watcher supervision; it stays idle until it receives its own session start
    observed: live watcher children=0, process-exit cleanup listeners=0

[7] The stray binding receives its OWN session_start and becomes the owner.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 241086), process-exit cleanup listeners=1

[8] Operator quits Pi: session_shutdown(quit).
    agent(after quit) calls fm_watch_arm_pi ->
      watcher: not armed - Pi session is shutting down
    observed: live watcher children=0, process-exit cleanup listeners=0

=== total arm children spawned across the whole demo: 5 ===
Evidence: Pi lifecycle transcript — base fa0d85d (before)

[5] A stray extra extension binding activates while session A's generation is still live. agent(stray binding) calls fm_watch_arm_pi -> watcher: started Pi extension arm child 1; future ordinary re-arms are automatic observed: live watcher children=2 (pid 240348, 240360), process-exit cleanup listeners=1 agent(live owner) calls fm_watch_arm_pi -> watcher: not armed - Pi session is shutting down [8] Operator quits Pi: session_shutdown(quit). observed: live watcher children=0, process-exit cleanup listeners=1

=== Pi primary watcher lifecycle demo (base) ===

[1] Pi starts. session_start fires for session A.
    agent(A) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 239826), process-exit cleanup listeners=1

[2] Operator runs /new: session_shutdown(new) then session_start(new) in the same Pi process.
    agent(after /new) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 240252), process-exit cleanup listeners=1, retired child(ren) 239826 stopped

[3] Operator runs /resume: session_shutdown(resume) then session_start(resume) in the same Pi process.
    agent(after /resume) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 240336), process-exit cleanup listeners=1, retired child(ren) 240252 stopped

[4] Operator runs /fork: session_shutdown(fork) then session_start(fork) in the same Pi process.
    agent(after /fork) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 240348), process-exit cleanup listeners=1, retired child(ren) 240336 stopped

[5] A stray extra extension binding activates while session A's generation is still live.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=2 (pid 240348, 240360), process-exit cleanup listeners=1, session A's own child was 240348
    agent(live owner) calls fm_watch_arm_pi ->
      watcher: not armed - Pi session is shutting down

[6] The live owner's session ends (/new). The stray binding is now behind no live owner.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 240360), process-exit cleanup listeners=1

[7] The stray binding receives its OWN session_start and becomes the owner.
    agent(stray binding) calls fm_watch_arm_pi ->
      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
    observed: live watcher children=1 (pid 240360), process-exit cleanup listeners=1

[8] Operator quits Pi: session_shutdown(quit).
    agent(after quit) calls fm_watch_arm_pi ->
      watcher: not armed - Pi session is shutting down
    observed: live watcher children=0, process-exit cleanup listeners=1

=== total arm children spawned across the whole demo: 5 ===
Evidence: Before/after diff of the same lifecycle run (PIDs normalized)
--- /tmp/b.n	2026-07-28 12:56:39.611166903 +0100
+++ /tmp/a.n	2026-07-28 12:56:39.612166898 +0100
@@ -1,4 +1,4 @@
-=== Pi primary watcher lifecycle demo (base) ===
+=== Pi primary watcher lifecycle demo (fixed) ===
 
 [1] Pi starts. session_start fires for session A.
     agent(A) calls fm_watch_arm_pi ->
@@ -22,24 +22,24 @@
 
 [5] A stray extra extension binding activates while session A's generation is still live.
     agent(stray binding) calls fm_watch_arm_pi ->
-      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
-    observed: live watcher children=2, process-exit cleanup listeners=1, session A's own child was <pid>
+      watcher: not armed - another live Pi session generation owns watcher supervision; this binding stays idle until that session ends
+    observed: live watcher children=1, process-exit cleanup listeners=1, session A's own child was <pid>
     agent(live owner) calls fm_watch_arm_pi ->
-      watcher: not armed - Pi session is shutting down
+      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
 
 [6] The live owner's session ends (/new). The stray binding is now behind no live owner.
     agent(stray binding) calls fm_watch_arm_pi ->
-      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
-    observed: live watcher children=1, process-exit cleanup listeners=1
+      watcher: not armed - this Pi session binding is inactive and does not own watcher supervision; it stays idle until it receives its own session start
+    observed: live watcher children=0, process-exit cleanup listeners=0
 
 [7] The stray binding receives its OWN session_start and becomes the owner.
     agent(stray binding) calls fm_watch_arm_pi ->
-      watcher: unchanged - Pi extension already owns an arm child; no manual re-arm needed; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
+      watcher: started Pi extension arm child 1; future ordinary re-arms are automatic; call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy
     observed: live watcher children=1, process-exit cleanup listeners=1
 
 [8] Operator quits Pi: session_shutdown(quit).
     agent(after quit) calls fm_watch_arm_pi ->
       watcher: not armed - Pi session is shutting down
-    observed: live watcher children=0, process-exit cleanup listeners=1
+    observed: live watcher children=0, process-exit cleanup listeners=0
 
 === total arm children spawned across the whole demo: 5 ===
Evidence: Reproducible lifecycle demo harness
#!/usr/bin/env bash
# Manual end-to-end demo of the Pi primary watcher extension session lifecycle.
# Usage: pi-session-restart-demo.sh <extension.ts> <label>
# Builds a throwaway Pi-like fixture, loads the tracked extension exactly the way
# Pi loads it, then drives session_start / session_shutdown / fm_watch_arm_pi and
# prints the transcript an operator would see.
set -u

EXT_SRC=${1:?extension path required}
LABEL=${2:-run}
REPO_ROOT=${REPO_ROOT:?repo root required}

WORK=$(mktemp -d "/tmp/pi-watch-demo-${LABEL}.XXXXXX")
repo="$WORK/repo"
home="$WORK/home"
mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" "$home/state" "$home/config" \
  "$repo/node_modules/@earendil-works/pi-coding-agent" \
  "$repo/node_modules/@earendil-works/pi-tui" \
  "$repo/node_modules/typebox"

cp "$EXT_SRC" "$repo/.pi/extensions/fm-primary-pi-watch.ts"
cp "$REPO_ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts"
cp "$REPO_ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
cp "$REPO_ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
chmod +x "$repo/bin/fm-operational-input.sh"

cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON'
{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}
JSON
cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS'
export function getMarkdownTheme() { return {}; }
export class UserMessageComponent { render() { return []; } invalidate() {} }
JS
cat > "$repo/node_modules/@earendil-works/pi-tui/package.json" <<'JSON'
{"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}
JSON
cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
export class Box { addChild() {} clear() {} setBgFn() {} }
export class Container {}
export class Text {}
JS
cat > "$repo/node_modules/typebox/package.json" <<'JSON'
{"name":"typebox","type":"module","exports":"./index.js"}
JSON
cat > "$repo/node_modules/typebox/index.js" <<'JS'
export const Type = { Object(properties) { return { type: "object", properties, additionalProperties: false }; } };
JS

# Stand-in for the real watcher arm cycle: a long-lived attached child.
cat > "$repo/bin/fm-watch-arm.sh" <<'SH'
#!/usr/bin/env bash
printf 'watcher: started pid=%s\n' "$$"
printf '%s\n' "$$" >> "${FM_ARM_LOG:?}"
trap 'exit 0' TERM INT
while :; do sleep 0.2; done
SH
chmod +x "$repo/bin/fm-watch-arm.sh"

export NODE_NO_WARNINGS=1
PLUGIN="$repo/.pi/extensions/fm-primary-pi-watch.ts" \
FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$WORK/arm.log" \
DEMO_LABEL="$LABEL" \
node --input-type=module <<'EOF'
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { pathToFileURL } from "node:url";

const armLog = process.env.FM_ARM_LOG;
writeFileSync(armLog, "");
// Claim the firstmate session lock for this process, like a real Pi session.
writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`);

const pidAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch { return false; } };
const armPids = () => readFileSync(armLog, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
const livePids = () => armPids().filter(pidAlive);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(pred, attempts = 200) {
  for (let i = 0; i < attempts; i += 1) { if (pred()) return true; await sleep(20); }
  return false;
}

function makeBinding(name) {
  const handlers = new Map();
  let tool = null;
  const pi = {
    on(event, handler) { handlers.set(event, handler); },
    registerCommand() {},
    registerTool(c) { if (c.name === "fm_watch_arm_pi") tool = c; },
    sendUserMessage: async () => {},
    events: { on() {} },
  };
  return { name, pi, handlers, getTool: () => tool };
}

const baseline = process.listenerCount("exit");
const mod = await import(pathToFileURL(process.env.PLUGIN).href);

const say = (s) => console.log(s);
const step = (n, s) => say(`\n[${n}] ${s}`);
function observe(note = "") {
  const live = livePids();
  const listeners = process.listenerCount("exit") - baseline;
  say(`    observed: live watcher children=${live.length}${live.length ? ` (pid ${live.join(", ")})` : ""}` +
      `, process-exit cleanup listeners=${listeners}${note ? `, ${note}` : ""}`);
  return { live, listeners };
}
async function arm(binding, who) {
  const r = await binding.getTool().execute(`demo-${who}`, {}, undefined, undefined, {});
  const text = r.content?.[0]?.text ?? String(r.details?.message ?? "");
  say(`    ${who} calls fm_watch_arm_pi ->`);
  for (const line of String(text).split("\n")) say(`      ${line}`);
  return r;
}

say(`=== Pi primary watcher lifecycle demo (${process.env.DEMO_LABEL}) ===`);

// --- Session A: ordinary Pi startup -----------------------------------------
const a = makeBinding("session-A");
mod.default(a.pi);
step(1, "Pi starts. session_start fires for session A.");
await a.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {});
await arm(a, "agent(A)");
await waitFor(() => livePids().length === 1);
observe();

// --- Same-process replacements: /new, /resume, /fork -------------------------
let current = a;
let n = 2;
for (const reason of ["new", "resume", "fork"]) {
  step(n, `Operator runs /${reason}: session_shutdown(${reason}) then session_start(${reason}) in the same Pi process.`);
  const previous = livePids();
  await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason }, {});
  await waitFor(() => previous.every((p) => !pidAlive(p)));
  await current.handlers.get("session_start")?.({ type: "session_start", reason }, {});
  await arm(current, `agent(after /${reason})`);
  await waitFor(() => livePids().length === 1);
  observe(`retired child(ren) ${previous.join(", ")} stopped`);
  n += 1;
}

// --- Stray overlapping factory bind while a session is still live ------------
step(n, "A stray extra extension binding activates while session A's generation is still live.");
const stray = makeBinding("stray");
mod.default(stray.pi);
await stray.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {});
const owned = livePids();
await arm(stray, "agent(stray binding)");
await sleep(400); // let any child the stray binding spawned come up before observing
observe(`session A's own child was ${owned.join(", ") || "none"}`);
await arm(current, "agent(live owner)");
n += 1;

// --- The stray binding outlives its owner ------------------------------------
step(n, "The live owner's session ends (/new). The stray binding is now behind no live owner.");
const ownerChildren = livePids();
await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {});
await waitFor(() => ownerChildren.every((p) => !pidAlive(p)));
await arm(stray, "agent(stray binding)");
observe();
n += 1;

step(n, "The stray binding receives its OWN session_start and becomes the owner.");
await stray.handlers.get("session_start")?.({ type: "session_start", reason: "new" }, {});
await arm(stray, "agent(stray binding)");
await waitFor(() => livePids().length === 1);
observe();
n += 1;

// --- Terminal quit ------------------------------------------------------------
step(n, "Operator quits Pi: session_shutdown(quit).");
const finalChildren = livePids();
await stray.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, {});
await waitFor(() => finalChildren.every((p) => !pidAlive(p)));
await arm(stray, "agent(after quit)");
observe();

say(`\n=== total arm children spawned across the whole demo: ${armPids().length} ===`);
for (const p of livePids()) { try { process.kill(Number(p), "SIGTERM"); } catch {} }
EOF
status=$?
rm -rf "$WORK"
exit $status
Evidence: Branch tests run against the base extension (regression guard proof)

ok - Pi watcher arm distinguishes all session lock ownership states not ok - Pi session transitions must rearm through an explicit generation owner: expected exit 0, got 1

ok - Pi primary watcher extension is tracked, self-hashing, and self-locating
ok - Pi secondmate launch wiring includes both tracked primary extensions
ok - Pi extension reports external healthy watcher output
ok - Pi custom tool exposes repair-only metadata and returns automatic-continuation guidance
ok - Pi redundant tool call returns ownership guidance and spawns no second child
ok - Pi scheduled retry remains extension-owned and resets for a replacement session
ok - Pi actionable close starts one successor before wake delivery settles
ok - Pi hung successor falls back to one typed actionable wake
ok - Pi unretired successor falls back without an overlapping retry
ok - Pi late unretired closes resume classified supervision
ok - Pi clean empty close triggers a bounded continuity retry
ok - Pi established clean closes stop at the configured retry limit
ok - Pi close handler verifies session-lock ownership before successor launch
ok - Pi watcher arm distinguishes all session lock ownership states
not ok - Pi session transitions must rearm through an explicit generation owner: expected exit 0, got 1

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:365 - The new non-owner refusal is selected by !owner.stopping alone, which does not prove another generation is live, and a deferred binding has no path back to ownership. Reachable sequence: binding A activates and arms; an overlapping factory bind B defers (activateGeneration returns early at :212); A's session_shutdown runs deactivateGeneration and sets activeGeneration = null (:232). B's fm_watch_arm_pi now returns "watcher: not armed - another live Pi session generation owns watcher supervision; this binding stays idle until that session ends" even though no generation owns supervision at all. Nothing re-activates B when the owner ends — the message's own promise is unimplemented, since B only re-activates if it later receives its own session_start, by which point another binding will normally have claimed ownership. The new guidance at docs/supervision-protocols/pi.md:12 ("is not a repair condition ... do not retry the call or restart Pi") turns that state into a terminal, silent loss of watcher supervision — the exact failure class this extension exists to prevent. The regression tests cover the overlap-while-live case (tests/fm-pi-watch-extension.test.sh:1119-1133) but not the owner-ended case. Single-binding sessions cannot reach it (a non-live generation is always stopping), so this is a follow-up rather than a blocker. Earliest shared boundary for the fix: have startArm/activateGeneration adopt owner when activeGeneration === null instead of refusing, and keep the non-owner message for the genuinely-contended case only.

🔧 Fix: distinguish inactive-binding arm refusal from live-owner contention
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-pi-watch-extension.test.sh — full targeted file for this change, 33/33 ok, including Pi scheduled retry remains extension-owned and resets for a replacement session, Pi session transitions use a generation owner across /new /resume /fork, stale callbacks, and quit, and Pi process-exit cleanup listener tracks each active replacement session exactly once
  • Manual end-to-end lifecycle demo against the branch extension: REPO_ROOT=&lt;worktree&gt; bash /tmp/no-mistakes-evidence/01KYM87QBGGQCJ038GW0J82AZZ/pi-session-restart-demo.sh &lt;worktree&gt;/.pi/extensions/fm-primary-pi-watch.ts fixed — loads the tracked extension the way Pi does, claims a real session lock, drives session_start/session_shutdown(new|resume|fork|quit) plus fm_watch_arm_pi, and records tool-result text, live watcher child PIDs, and process-exit listener counts
  • Same demo against the base-commit extension for contrast: bash pi-session-restart-demo.sh base-fm-primary-pi-watch.ts base (extension extracted via git show fa0d85d:.pi/extensions/fm-primary-pi-watch.ts)
  • Regression-guard check: temporarily swapped the base extension into the worktree, ran bash tests/fm-pi-watch-extension.test.sh (fails at Pi session transitions must rearm through an explicit generation owner), then restored with git checkout -- .pi/extensions/fm-primary-pi-watch.ts and verified git status --porcelain is empty
  • Cleanup verification: killed the watcher child leaked by the deliberately-failing base run, removed its temp dir, and confirmed no diff against a4d3724
⚠️ **Document** - 1 info
  • ℹ️ docs/verification/supervision.md:135 - docs/verification/supervision.md:135 records that tests/fm-pi-primary-types.test.sh skipped its typecheck because tsc was unavailable, so the changed TypeScript extension has no refreshed typecheck evidence on this branch. I confirmed tsc is still absent on this host (no PATH entry, no local node_modules), so the caveat is currently accurate and I left it in place rather than overstating verification. If the validation phase runs the typecheck on a host with tsc, that clause should be dropped from the record.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant