instar 1.3.1302 → 1.3.1304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli.js +37 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/core/SessionManager.d.ts +9 -0
  4. package/dist/core/SessionManager.d.ts.map +1 -1
  5. package/dist/core/SessionManager.js +34 -2
  6. package/dist/core/SessionManager.js.map +1 -1
  7. package/dist/core/processIdentity.d.ts +39 -0
  8. package/dist/core/processIdentity.d.ts.map +1 -0
  9. package/dist/core/processIdentity.js +72 -0
  10. package/dist/core/processIdentity.js.map +1 -0
  11. package/dist/data/standards-guard-index.json +1 -1
  12. package/dist/data/standards-guard-index.meta.json +2 -2
  13. package/dist/data/standards-registry.meta.json +1 -1
  14. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts +6 -0
  15. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts.map +1 -1
  16. package/dist/feedback-factory/drain/FeedbackDrainStore.js +18 -5
  17. package/dist/feedback-factory/drain/FeedbackDrainStore.js.map +1 -1
  18. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  19. package/dist/lifeline/TelegramLifeline.js +1 -119
  20. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  21. package/dist/lifeline/lifelineLock.d.ts +38 -0
  22. package/dist/lifeline/lifelineLock.d.ts.map +1 -0
  23. package/dist/lifeline/lifelineLock.js +153 -0
  24. package/dist/lifeline/lifelineLock.js.map +1 -0
  25. package/dist/lifeline/startupMarker.d.ts +3 -0
  26. package/dist/lifeline/startupMarker.d.ts.map +1 -1
  27. package/dist/lifeline/startupMarker.js +2 -0
  28. package/dist/lifeline/startupMarker.js.map +1 -1
  29. package/dist/monitoring/OrphanProcessReaper.d.ts +21 -1
  30. package/dist/monitoring/OrphanProcessReaper.d.ts.map +1 -1
  31. package/dist/monitoring/OrphanProcessReaper.js +59 -12
  32. package/dist/monitoring/OrphanProcessReaper.js.map +1 -1
  33. package/dist/threadline/PipeSessionSpawner.d.ts.map +1 -1
  34. package/dist/threadline/PipeSessionSpawner.js +66 -45
  35. package/dist/threadline/PipeSessionSpawner.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/data/builtin-manifest.json +37 -37
  38. package/src/data/standards-guard-index.json +1 -1
  39. package/src/data/standards-guard-index.meta.json +2 -2
  40. package/src/data/standards-registry.meta.json +1 -1
  41. package/upgrades/{1.3.1302.md → 1.3.1303.md} +26 -0
  42. package/upgrades/1.3.1304.md +89 -0
  43. package/upgrades/side-effects/feedback-drain-identity.md +78 -0
  44. package/upgrades/side-effects/test-instance-foreign-kills.md +261 -0
@@ -0,0 +1,89 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ An investigation into six `claude -p` builders SIGKILLed on the Mac Studio
9
+ (2026-09-29/30) did not identify their cause. It did find automatic kill
10
+ paths that picked their target by name, keyword or bare pid. None of those
11
+ proves the target is the agent's own. Each path now proves ownership first,
12
+ and when it cannot, it does not signal:
13
+
14
+ - New `src/core/processIdentity.ts`: a recorded pid counts as the same process
15
+ only while its start time (`ps -o lstart`) matches the recorded one.
16
+ - `instar lifeline restart` fallback (used when `launchctl kickstart` fails):
17
+ `lifeline.lock` and the startup marker now record `procStart`. The fallback
18
+ signals the lock holder and the marker pid only when each is proven to be
19
+ that same process, and it proves this again before SIGKILL. The old
20
+ `pkill -f '<agent>.*lifeline'` and the round-1 "command contains lifeline"
21
+ check are both gone. Legacy records without a start time are never
22
+ signalled.
23
+ - Lifeline startup takeover (`acquireLockFile`, now in
24
+ `src/lifeline/lifelineLock.ts`): a stuck holder is terminated only when
25
+ proven. A pid that started after the lock was written counts as reused, and
26
+ the lock is taken without signalling anyone. If ownership is unproven, the
27
+ lock is respected. That includes a start time `ps` cannot read: the holder
28
+ counts as dead only when the kernel confirms (ESRCH) nothing runs there.
29
+ - `PipeSessionSpawner`: records tmux `#{session_id}` + pane pid at creation. It
30
+ kills only that exact session, by id, and never a same-name replacement or a
31
+ stale pid's group. It signals the pane's process group only while tmux
32
+ reports the pane live; a retained dead pane (`remain-on-exit`) gets only its
33
+ session removed. It refuses to spawn over a `pipe-<threadId>` session it
34
+ did not start.
35
+ - `SessionManager.spawnTriageSession`: it replaces an existing session under
36
+ the triage name only when `ownsLiveTmuxSession()` proves that session is its
37
+ own. Proof means the session's `INSTAR_SESSION_ID` names this agent's record
38
+ for that name.
39
+ - `OrphanProcessReaper`: auto-kill requires `ownsLiveTmuxSession()`. A process
40
+ in a session that only reuses a recorded name is reported as external. Only
41
+ live panes map a process to a session. The start time seen when ownership
42
+ was established must still match before SIGTERM (unreadable sends nothing)
43
+ and before the delayed SIGKILL. Ownership is re-checked before the tmux
44
+ session is removed.
45
+ - Tests: `createTempProject().cleanup()` removes only sessions named after its
46
+ unique temp dir. The broad `cleanupTmuxSessions('akit-integ-'/'akit-sched-')`
47
+ calls are removed. `scheduler-basic` now waits for spawns that are still in
48
+ flight before cleanup. Its every-second cron could land a spawn after
49
+ cleanup and leak a dead `instar-test-*-job-fast-test-*` session, which was
50
+ seen twice during a full `npm test` run here.
51
+
52
+ ## What to Tell Your User
53
+
54
+ Several of my automatic clean-up paths for stuck sessions and processes now
55
+ check that something is mine before they stop it. Before, a few repair paths went by a name or a
56
+ process number, and on a busy machine those can end up belonging to some
57
+ other program. If I can't prove something is mine, I leave it running.
58
+
59
+ ## Summary of New Capabilities
60
+
61
+ - None new. Lifeline restart and takeover, pipe-session and triage cleanup,
62
+ and orphan cleanup now check that a process or session is their own before
63
+ stopping it, in the cases listed under Evidence.
64
+
65
+ ## Evidence
66
+
67
+ - `tests/integration/foreign-process-survives-test-instance.test.ts` (real
68
+ tmux, 8 cases, including a retained dead pane that must get no signal). The orphan pass reaps an owned orphaned session. A foreign
69
+ `claude`-shaped process survives, and so does a foreign process in a session
70
+ that reuses a name the instance recorded. Fixture cleanup removes the
71
+ instance's own sessions and nothing else. Triage replaces its own session and
72
+ refuses a foreign one. The pipe spawner kills its own session, leaves a
73
+ same-name replacement and a stale pid alone, and refuses to spawn over a
74
+ foreign session. We restored each old behavior in turn: the reaper's name
75
+ match, the round-1 spawner, no triage guard, and the old helper. Each one
76
+ makes the matching case fail.
77
+ - `tests/unit/lifeline/lifeline-lock-identity.test.ts` (real child processes).
78
+ A stale lock whose pid now runs a foreign command mentioning "lifeline" is
79
+ neither a restart target nor signalled at startup. The own proven holder is
80
+ a target and is terminated when wedged. Making the identity check accept any
81
+ live pid fails 3 cases. A `ps` that always fails leaves a live holder's lock
82
+ in place, and a confirmed-dead holder's lock is still taken over.
83
+ - `tests/unit/orphan-reaper-pane-identity.test.ts` (scripted `ps`/tmux, no
84
+ real signals): a pid reused from a dead pane is not reaped; a changed or
85
+ unreadable start time sends nothing; a live owned orphan is still reaped.
86
+ - These tests cover the specific cases listed. They do not cover every kill
87
+ path in instar, and PID reuse is simulated rather than reproduced. The six
88
+ builder deaths remain unattributed; these paths are not ruled out as their
89
+ cause.
@@ -0,0 +1,78 @@
1
+ # Side-Effects Review — feedback drain file identity drops st_dev
2
+
3
+ **Version / slug:** `feedback-drain-identity`
4
+ **Date:** `2026-09-30`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required (one pure comparison function; checksum + epoch checks untouched)`
7
+
8
+ ## Summary of the change
9
+
10
+ `FeedbackDrainStore.sqliteFileIdentity()` recorded `dev:ino:birthtimeMs`. macOS APFS renumbers
11
+ `st_dev` across reboots/remounts. Live on the Mac Studio the checkpoint held
12
+ `16777231:454176:1787103639952.4048` while the untouched file now stats as dev `16777232`, same
13
+ inode and birth time. `restorePending()` saw a different identity, the checksum and epoch matched,
14
+ so it returned `true`: the drain refused every tick and checkpoint (274 reports waiting) and the
15
+ only exit was the PIN-gated failover-finalize route — a false operator escalation.
16
+
17
+ Change: identity is now `ino:birthtimeMs`. New exported pure helper `sameSqliteFileIdentity(recorded, current)`
18
+ compares a legacy three-part identity on its `ino:birthtime` parts. Both comparison sites
19
+ (`restorePending()` and `finalizeRestore()`) use it. Checksum and epoch checks are unchanged.
20
+
21
+ ## Decision-point inventory
22
+
23
+ - Is the DB file the checkpointed one? — **modify** — compare on inode + birth time only.
24
+
25
+ ## 1. Over-block
26
+
27
+ Less than before: a reboot or remount no longer makes the drain refuse work.
28
+
29
+ ## 2. Under-block
30
+
31
+ A replacement file that keeps the same inode AND the same birth time would pass. A restore by copy
32
+ or rename produces a new inode and a new birth time (tested). The previous form had the same
33
+ exposure for same-device replacements; st_dev only added detection for a cross-volume move with a
34
+ colliding inode and birth time, which is not credible. On filesystems without birth time
35
+ (`birthtimeMs` 0) identity falls to the inode alone, the same as before within one device.
36
+
37
+ ## 3. Level-of-abstraction fit
38
+
39
+ The fix lives in the one function that defines identity; no caller changes.
40
+
41
+ ## 4. Signal vs authority compliance
42
+
43
+ Unchanged: restore detection still blocks via `DrainConflictError`/restorePending, with the same
44
+ checksum and epoch authority.
45
+
46
+ ## 5. Interactions
47
+
48
+ `finalizeRestore()` requires the identity to DIFFER from the checkpoint; it now uses the same
49
+ comparison, so a same-file finalize is still refused and a genuine restore still finalizes
50
+ (existing destructive-restore test passes).
51
+
52
+ ## 6. External surfaces
53
+
54
+ None. `/feedback-factory/drain/status` output shape unchanged; the stuck drain reports
55
+ `restorePending:false` after the update.
56
+
57
+ ## 7. Multi-machine posture
58
+
59
+ Per-machine file identity; not replicated. No change.
60
+
61
+ ## 7b. Constitutional Rules touched
62
+
63
+ Durable intake floor: restores the drain's intake without weakening restore detection.
64
+ Occam (Rule 116): one comparison helper, no new state or options.
65
+
66
+ ## 8. Rollback cost
67
+
68
+ Release revert. Checkpoints written in the two-part form read as "different" to the old code,
69
+ which reproduces today's escalation, no data loss.
70
+
71
+ ## Conclusion
72
+
73
+ Safe to ship. Tests: unit (st_dev change on the same file → not restored; replaced file → still
74
+ restored, new and legacy formats), integration backup-restore and e2e drain lifecycle all green.
75
+
76
+ ## Evidence pointers
77
+
78
+ - `tests/unit/feedback-drain-store.test.ts` — new identity tests; the st_dev tests fail on the old code.
@@ -0,0 +1,261 @@
1
+ # Side-Effects Review — automatic kills signal only what the agent can prove it started
2
+
3
+ **Version / slug:** `test-instance-foreign-kills`
4
+ **Date:** `2026-09-30`
5
+ **Author:** `Echo (builder)`
6
+ **Second-pass reviewer:** `independent reviewer subagent (see below)`
7
+
8
+ ## Summary of the change
9
+
10
+ Round 3, after Astra's round-2 review (four must-fixes: M1 unreadable identity
11
+ read as a dead lifeline; M2 and M3 retained dead tmux panes; M4 overreaching
12
+ claims). The kill paths the reviews named now check ownership through one
13
+ small shared check before they signal, in the cases the tests below exercise.
14
+ Where a check cannot prove ownership, the path logs or reports and does not
15
+ signal.
16
+
17
+ Round 3 changes:
18
+
19
+ - `processIdentity.checkRecordedProcess`: an unreadable start time (ps denied,
20
+ timed out, unparseable) is `gone` only when `kill(pid, 0)` returns ESRCH;
21
+ otherwise `unproven`, so a live lifeline lock is kept (M1). In
22
+ `acquireLockFile`, if the holder's identity turns unreadable during the
23
+ SIGTERM grace, the new lifeline neither sends SIGKILL nor takes the lock
24
+ (found by the round-3 second-pass review).
25
+ - `PipeSessionSpawner.killPipeSession`: reads `#{pane_dead}`. The process
26
+ group is signalled only for an owned pane tmux reports live; an owned dead
27
+ or unreadable pane gets only `kill-session -t <session_id>`. The fallback
28
+ SIGKILL of the bare pane pid (when the pgid read failed) is removed (M2).
29
+ - `OrphanProcessReaper`: only live panes (`pane_dead=0`) map a pid to a
30
+ session. The start time read at classification is carried on the orphan and
31
+ must still match right before SIGTERM; null sends nothing and the tmux
32
+ session is left alone. Session ownership is re-checked before the terminal
33
+ `kill-session`. The operator-requested external kill is a separate mode of
34
+ `killProcess` (M3).
35
+
36
+ - `src/core/processIdentity.ts` (new, ~60 lines): `processStartMs(pid)` reads
37
+ `ps -o lstart=`. `checkRecordedProcess({pid, procStart, startedAt})` returns
38
+ one of four verdicts: `same` (the start time matches), `gone`, `reused` (the
39
+ live process started after the record was written), or `unproven`.
40
+ - `src/lifeline/lifelineLock.ts` (new; `acquireLockFile` moved here from
41
+ `TelegramLifeline.ts`): the lock records `procStart`. Takeover signals are
42
+ sent only on `same`, and identity is proven again before SIGKILL. On
43
+ `reused`, the new lifeline takes the lock without a signal. On `unproven`,
44
+ it respects the lock. `provenLifelineRecords()` selects the `lifeline
45
+ restart` targets.
46
+ - `src/lifeline/startupMarker.ts`: the marker records `procStart`.
47
+ - `src/cli.ts` (`lifeline restart` fallback): targets come from
48
+ `provenLifelineRecords(lock, marker)`, and SIGKILL goes only to targets
49
+ that are still `same` after the grace period. The round-1 keyword check is
50
+ removed.
51
+ - `src/threadline/PipeSessionSpawner.ts`: `new-session -P -F '#{session_id}
52
+ #{pane_pid}'` records the incarnation. `killPipeSession` signals the group
53
+ and runs `kill-session -t <session_id>` only while the named session still
54
+ lists that exact id and pid; otherwise it signals nothing. `spawn()` replaces
55
+ a same-name session only when it is recorded in `activeSessions`, and
56
+ otherwise refuses.
57
+ - `src/core/SessionManager.ts`: new `ownsLiveTmuxSession(name)`. It reads the
58
+ live session's `INSTAR_SESSION_ID` (every spawn path sets it) and checks
59
+ that this agent's state holds a record with that id for that tmux name.
60
+ `spawnTriageSession` refuses to kill an occupied name without that proof.
61
+ - `src/monitoring/OrphanProcessReaper.ts`: a historical-name match becomes
62
+ `instar-orphan` (auto-kill eligible) only with `ownsLiveTmuxSession`.
63
+ Without it, the process is classed `external` and only reported.
64
+ `killProcess` records the start time before SIGTERM and sends SIGKILL only
65
+ if it is unchanged.
66
+ - Tests: the broad `cleanupTmuxSessions('akit-integ-'/'akit-sched-')` calls are
67
+ removed. `scheduler-basic` waits for in-flight spawns before cleanup (a
68
+ late spawn leaked a dead-pane session). The regression test is rewritten with 7 real-tmux cases, both sides
69
+ of each decision, with teardown in `finally`. There is a new unit test with
70
+ real child processes for the lifeline identity.
71
+
72
+ ## Decision-point inventory
73
+
74
+ - Lifeline restart target selection — modify — keyword → proven incarnation.
75
+ - Lifeline lock takeover — modify — any live pid → proven incarnation; reused → silent takeover; unproven → respect.
76
+ - Pipe kill / pipe spawn over an existing name — modify — name → recorded session id + pane pid; foreign name → refuse.
77
+ - Triage spawn over an existing name — modify — name → `ownsLiveTmuxSession`; foreign → refuse (throw).
78
+ - Orphan auto-kill eligibility — modify — historical name → name + live `INSTAR_SESSION_ID` match.
79
+ - Delayed SIGKILL escalations (reaper, lifeline) — modify — `kill(pid,0)` → same start time.
80
+
81
+ ---
82
+
83
+ ## 1. Over-block
84
+
85
+ - A legacy lifeline lock or marker, written before this change and so with no
86
+ `procStart`, is never signalled by `lifeline restart`. At startup, a legacy
87
+ lock whose live pid started before the lock was written is respected, not
88
+ taken over. The first restart after the update normally goes through
89
+ launchd's `kickstart -k`, which does not use these paths. Once a lifeline
90
+ from this version is running, its records carry `procStart`.
91
+ - A process whose start time `ps` cannot read is not signalled. For the
92
+ lifeline lock it counts as gone only on a confirmed ESRCH; a live holder
93
+ whose start time cannot be read keeps its lock, so a new lifeline refuses to
94
+ start until `ps` works again.
95
+ - Pipe spawner: a live owned pane whose pgid cannot be read gets no process
96
+ signal; `kill-session` still hangs up the pane.
97
+ - Orphan reaper: an owned orphan whose start time cannot be read is skipped
98
+ (reported as "Skipped orphan"), not killed.
99
+ - Triage: if a session record was purged while its tmux session lived on, the
100
+ triage name stays occupied and the spawn throws. `TriageOrchestrator`
101
+ already handles a failed spawn. That is better than killing an unproven
102
+ session.
103
+ - Orphan reaper: an orphan whose record was purged, or whose tmux env lacks
104
+ `INSTAR_SESSION_ID` (a very old session), is only reported. The operator
105
+ can still use the explicit external-process API.
106
+ - Pipe spawner: a pipe session from before a server restart, which is no
107
+ longer in `activeSessions`, blocks a new pipe spawn for that thread. The
108
+ caller then falls through to the normal A2A path, as it does for any
109
+ `spawned:false`.
110
+
111
+ ## 2. Under-block
112
+
113
+ - Start-time resolution is 1 s. A pid reused within the same second as the
114
+ original start would pass. That is not a realistic collision.
115
+ - Two SessionManagers sharing one agent state dir are treated as one agent
116
+ and may clean up each other's sessions. That is the same agent, which is
117
+ intended.
118
+ - The final ownership check and the signal are separate calls, a few
119
+ milliseconds apart. The reaper's terminal `kill-session` goes by name after
120
+ an ownership re-check.
121
+ - Only the paths named here were changed. Other kill paths (for example the
122
+ SessionWatchdog, and SessionManager's own passes) were not re-audited in
123
+ this change.
124
+ - This change does not identify what killed the six builders. Those deaths
125
+ remain unattributed, and these paths are not excluded as a possible cause.
126
+
127
+ ## 3. Level-of-abstraction fit
128
+
129
+ There is one shared primitive for pids (`processIdentity`) and one for
130
+ sessions (`ownsLiveTmuxSession`, which uses the `INSTAR_SESSION_ID` every
131
+ spawn already sets). The pipe spawner uses tmux's own unique session id. No
132
+ new subsystem, store or loop is added.
133
+
134
+ ## 4. Signal vs authority compliance
135
+
136
+ - [x] No — no block/allow surface on information flow. These checks narrow an
137
+ irreversible action (a signal) to proven targets. Each one removes reach and
138
+ none adds authority.
139
+
140
+ ## 4b. Judgment-point check
141
+
142
+ The checks are deterministic safety floors on a SIGKILL. They are not
143
+ heuristics at a competing-signals decision point.
144
+
145
+ ## 5. Interactions
146
+
147
+ - The SessionWatchdog already checks the parent pid, command and incarnation
148
+ before its descendant signals, and this change leaves it untouched.
149
+ SessionManager's own passes iterate its own records.
150
+ - The reaper's `trackedNow` path to `terminateSession` is unchanged.
151
+ Ownership proof only gates the non-tracked, historical-record path.
152
+ - `TelegramLifeline.releaseLockFile` still compares pid only when removing its
153
+ own lock file (a file, not a signal). That is unchanged.
154
+ - `acquireLockFile` behavior for a dead pid (take over) and a fresh proven
155
+ holder (respect) is unchanged.
156
+
157
+ ## 6. External surfaces
158
+
159
+ - New optional JSON fields: `procStart` in `state/lifeline.lock` and
160
+ `state/lifeline-started-at.json`. Readers ignore unknown fields, and
161
+ `readStartupMarker` keeps its existing validation.
162
+ - A new `spawned:false` reason from the pipe spawner, and a new triage spawn
163
+ error message.
164
+ - Orphan reaper reports can now list a process as `external` with the reason
165
+ "ownership unproven".
166
+
167
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
168
+
169
+ Machine-local BY DESIGN. Pids, start times and tmux sessions are per-host, and
170
+ every check is on the host that would send the signal.
171
+
172
+ ## 8. Rollback cost
173
+
174
+ Revert and ship a patch release. The new JSON fields are additive, and older
175
+ code ignores them. No migration is needed.
176
+
177
+ ---
178
+
179
+ ## Conclusion
180
+
181
+ Clear to ship pending review. In the cases the tests exercise, the paths
182
+ Astra named signal only a proven incarnation, and the positive cleanup cases
183
+ still work. The limits in section 2 remain.
184
+
185
+ ---
186
+
187
+ ## Second-pass review (if required)
188
+
189
+ **Round 3 reviewer:** independent reviewer subagent (general-purpose,
190
+ read-only). **Verdict: Concur.** M1-M4 closed in the code read; no automatic
191
+ path read signals an unproven process. Its minor points: (a) identity turning
192
+ unreadable during the lifeline SIGTERM grace fell through to a takeover —
193
+ fixed in this round, with a test; (b) the reaper reads `procStart` just after
194
+ the ownership check, not from the original `ps` scan (documented limit);
195
+ (c) the integration test's teardown killed detached children on an `alive`
196
+ check only — it now also requires the recorded start time.
197
+
198
+ **Round 2 reviewer:** independent reviewer subagent (general-purpose, read-only).
199
+ **Verdict: Concur with the review** (round 2). Astra's round-2 review then
200
+ found M1-M3 below, so this verdict did not hold; the round-3 fixes await
201
+ Astra's next review.
202
+
203
+ - Round-2 reviewer's claim (superseded): all six must-fixes are closed in the
204
+ touched code. No touched path lets a
205
+ name, keyword or bare pid alone authorize an automatic signal. The lifeline
206
+ restart and takeover signal only on `same`, and SIGKILL re-checks. Pipe
207
+ teardown needs an exact id+pid match and kills by id. Triage throws on a
208
+ session it does not own. The reaper's name-only matches are report-only,
209
+ and its SIGKILL re-checks the start time.
210
+ - `ownsLiveTmuxSession` checks the id against a regex before
211
+ `StateManager.getSession`, so its `validateKey` cannot throw. All four spawn
212
+ paths set `INSTAR_SESSION_ID`.
213
+ - The over-block and under-block lists are accurate.
214
+ - Minor residuals, none blocking. After proving ownership, the reaper kills
215
+ the tmux session by name, which leaves a window of milliseconds. Its first
216
+ SIGTERM goes to a pid from the same scan. A wedged legacy lifeline is left to
217
+ launchd's `kickstart -k`, as section 1 says.
218
+
219
+ ---
220
+
221
+ ## Evidence pointers
222
+
223
+ - `tests/integration/foreign-process-survives-test-instance.test.ts`: 7/7
224
+ pass. We restored each old behavior in turn: the reaper name match, the
225
+ round-1 `PipeSessionSpawner.ts`, no triage guard, and the pre-fix
226
+ `tests/helpers/setup.ts`. Each makes its matching case fail (1, 2, 1 and 1
227
+ failures).
228
+ - `tests/unit/lifeline/lifeline-lock-identity.test.ts`: 7/7 pass. With
229
+ `checkRecordedProcess` changed to accept any live pid, 3 fail. The legacy
230
+ "respect" case does not discriminate on its own, because a fresh child cannot
231
+ carry a 5-minute-old lock. Its verdict is covered by the `checkRecordedProcess` case.
232
+ - Round 3: `tests/unit/lifeline/lifeline-lock-identity.test.ts` adds a `ps`
233
+ that always fails on PATH: a live holder is `unproven` and keeps its lock; a
234
+ holder confirmed dead (ESRCH) is `gone` and the lock is taken over.
235
+ - Round 3: a holder that ignores SIGTERM, with `ps` failing after the first
236
+ identity check: `acquireLockFile` returns false, sends no SIGKILL, and the
237
+ lock is unchanged. This case fails against the round-2 `lifelineLock.ts`.
238
+ - Round 3: the integration file adds a real retained dead pane
239
+ (`remain-on-exit`, its pane process killed by exact pid): `process.kill` is
240
+ never called and the owned session is removed. With the round-2 spawner this
241
+ case fails. The live-pane positive case now also asserts the pane process
242
+ exits. 8/8 pass.
243
+ - Round 3: `tests/unit/orphan-reaper-pane-identity.test.ts` (scripted ps/tmux,
244
+ stubbed `process.kill`): a foreign pid reusing a dead or indeterminate
245
+ pane's pid is not an orphan and gets no signal; the same pid in a live owned
246
+ pane is reaped (SIGTERM then SIGKILL, ownership re-checked before tmux
247
+ cleanup); a changed or unreadable start time sends nothing; the operator
248
+ kill still works. All six fail against the round-2 reaper. PID reuse itself
249
+ is simulated, not reproduced.
250
+ - Round-1 live check: a foreign `claude -p … fix the lifeline` process survived
251
+ the `lifeline restart` fallback.
252
+
253
+ ---
254
+
255
+ ## Class-Closure Declaration (display-only mirror)
256
+
257
+ - **`defectClass`** — `unbounded-self-action`
258
+ - **`closure`** — `n/a` (negative declaration)
259
+ - **`reason`** — No new trigger or loop. The change only narrows which
260
+ pid or session existing kills may signal, and adds ownership proof before
261
+ each one.