pi-claude-supervisor 0.5.0 → 0.5.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.5.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.5.0...v0.5.1) (2026-09-14)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * harden single-worker recovery lifecycle ([14c01ec](https://github.com/btnalit/pi-claude-supervisor/commit/14c01ec610b1210eda1ea2d60269fad25b8b3575))
11
+
5
12
  ## [0.5.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.4.1...v0.5.0) (2026-09-14)
6
13
 
7
14
 
@@ -17,6 +24,9 @@ All notable changes to this project will be documented here.
17
24
  - Independent read-only Reviewer results with bounded findings and automatic repair rounds.
18
25
  - JSONL malformed-record handling and duplicate result/permission suppression fixtures.
19
26
  - Active JSONL request shutdown coverage and deterministic acceptance/review tests.
27
+ - Durable Decision Worker recovery claims with stale-owner reconciliation and explicit fail-closed takeover.
28
+ - Identity-bound tmux handoff cleanup and recovery/lease lifecycle coverage.
29
+ - Claude Code 2.1.270 bounded stability matrix evidence in `docs/stability-matrix-2.1.270.md`.
20
30
 
21
31
  ## [0.4.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.4.0...v0.4.1) (2026-09-14)
22
32
 
@@ -85,7 +95,7 @@ All notable changes to this project will be documented here.
85
95
  - Historical Claude CLI 2.1.268 permission allow/deny and SIGTERM/SIGINT transport spike evidence; current release validation uses Claude CLI 2.1.270.
86
96
  - Event-driven JSONL `control_request`/`result`/exit events, permission responses, persistent Pi Decision Worker automation, bounded duplicate/turn handling, and outbound human-intervention webhooks.
87
97
  - Long-task defaults are now 100 automatic turns, 4 hours wall time and 20 minutes without output; Decision Worker API failures alert human operators directly instead of attempting an LLM fallback.
88
- - Automatic Decision Worker sessions now persist as Pi JSONL with a 0600 task registry. Unclean Pi restarts expose explicit `/supervise recover <task-id>` recovery; Claude work is not silently duplicated.
98
+ - Automatic Decision Worker sessions now persist as Pi JSONL with a 0600 task registry. Unclean Pi restarts expose explicit `/supervise recover [--takeover] <task-id>` recovery; Claude work is not silently duplicated.
89
99
  - Local tests and package-content checks.
90
100
 
91
101
  ### Limitations
package/README.cn.md CHANGED
@@ -54,7 +54,7 @@ Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
54
54
  /supervise start --spec ./task.json
55
55
  /supervise poll
56
56
  /supervise sessions
57
- /supervise recover <task-id>
57
+ /supervise recover [--takeover] <task-id>
58
58
  /supervise stop human requested stop
59
59
  /supervise verify
60
60
  /supervise approve <task-id> allow|deny [request-id]
@@ -94,17 +94,20 @@ Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
94
94
 
95
95
  当前 webhook 是出站通知,不直接接受批准命令;批准或接管仍通过 Pi。
96
96
  自动模式会将 Decision Worker 会话持久化到状态目录。Pi 非正常重启后,`/supervise sessions`
97
- 会显示 `recoverable` 任务;显式执行 `/supervise recover <task-id>` 会恢复 Decision Worker 上下文并
98
- 重新启动 Claude Worker,不会静默恢复或重复执行任务。
97
+ 会显示 `recoverable` 任务;显式执行 `/supervise recover [--takeover] <task-id>` 会恢复 Decision Worker 上下文并
98
+ 重新启动 Claude Worker,不会静默恢复或重复执行任务。旧 Pi 进程已退出且租约确认旧 Worker
99
+ 进程组已消失且 cgroup 仍是真实、可读取的空边界时,才可显式添加 `--takeover`;缺失、仍存活或无法确认的 Worker 会被拒绝。持久 tmux
100
+ Worker 应使用 `adopt-tmux`,而不是 takeover。
99
101
  自动模式下,Decision Worker 可以安全拒绝 `AskUserQuestion`,让 Claude 将问题转成普通文本,
100
102
  再根据任务和仓库证据自动回答;无法确定时才升级人工。如需微信内闭环,需要另建带签名验证、
101
103
  一次性 action token 和重放保护的入站 callback 服务。
102
104
 
103
- 近期自动化目标是先稳定完成“多命令验收—独立只读 Reviewer—结构化修复轮次—再次验收”闭环。
105
+ `v0.5.0` 已完成并发布“多命令验收—独立只读 Reviewer—结构化修复轮次—再次验收”闭环。
104
106
  任务可通过 API 或 JSON spec 提供 `goal`、`scope`、`constraints`、`forbidden` 和多个
105
107
  `acceptance` 命令;旧的纯文本任务继续使用默认 `git diff --check`。Reviewer 只能使用
106
- `read`、`grep`、`find`、`ls`,不会修改工作树或批准权限。当前稳定性验证固定针对 Claude Code
107
- `2.1.270`,暂不把多版本兼容、sandbox、低权限和网络隔离作为本阶段门禁。
108
+ `read`、`grep`、`find`、`ls`,不会修改工作树或批准权限。短期剩余门禁是固定 Claude Code
109
+ `2.1.270` 的重复稳定性统计和 recovery 测试;协同多 Worker 属于后续独立开发阶段,
110
+ 暂不把多版本兼容、sandbox、低权限和网络隔离作为本阶段门禁。
108
111
 
109
112
  ### tmux/PTY 交互模式
110
113
 
@@ -150,6 +153,11 @@ PTY 屏幕文字不是 Claude JSONL。权限/信任对话框和无法确定的 T
150
153
  /supervise send <task-id> continue after checking the test failure
151
154
  ```
152
155
 
156
+ 当前支持的是**独立任务会话并行**,不是共享工作树的协同多 Worker。后续多 Worker
157
+ 开发任务会引入 parent/child 任务图、依赖、并发上限、结构化 handoff、汇总验收和
158
+ 跨进程恢复,但不会放宽“一个 worktree 一个写入者”的边界,也不会自动 merge 或 publish。
159
+ 该阶段应安排在固定 Claude `2.1.270` 稳定性统计和单 Worker recovery 语义完成之后。
160
+
153
161
  Pull Request 必须通过聚合的 `CI / Quality gate`。Release Please 根据 Conventional Commits 创建版本 PR;维护者合并后,Release workflow 会针对精确 tag commit 重新验证,并通过受保护的 `npm` environment 使用 npm provenance 发布。
154
162
 
155
163
  详细内容见 [engineering-plan.md](docs/engineering-plan.md)、[independent-review.md](docs/independent-review.md)、[architecture.md](docs/architecture.md)、[testing.md](docs/testing.md) 和 [releasing.md](docs/releasing.md)。
package/README.md CHANGED
@@ -59,7 +59,7 @@ export PI_CLAUDE_SUPERVISOR_WORKER=claude
59
59
  /supervise start inspect the current repository and report what should be changed
60
60
  /supervise start --spec ./task.json
61
61
  /supervise sessions
62
- /supervise recover <task-id>
62
+ /supervise recover [--takeover] <task-id>
63
63
  /supervise poll
64
64
  /supervise poll all
65
65
  /supervise send continue with read-only inspection
@@ -100,24 +100,36 @@ The supervisor allows only one active JSONL request per session: poll until its
100
100
  yet exposed by the adapter. Multiple independent task sessions can run
101
101
  concurrently when they use different canonical working directories or
102
102
  worktrees; same-directory starts are rejected even when concurrent, and
103
- `/supervise sessions` lists the sessions. Unattended use still requires the
104
- remaining lifecycle, signal and recovery checks. Host permissions and network
105
- access follow explicit caller authorization and host policy; there is no
106
- automatic merge, deploy, release or publish.
107
-
108
- The near-term automation milestone adds a structured acceptance pipeline:
103
+ `/supervise sessions` lists the sessions. This is independent-session
104
+ parallelism, not coordinated multi-worker collaboration. A future multi-worker
105
+ milestone will add explicit parent/child task graphs, dependencies, bounded
106
+ scheduling, structured handoffs, aggregate acceptance and graph-aware recovery;
107
+ it will not relax the one-writer-per-worktree rule or enable automatic
108
+ merge/publish. Unattended use still requires the remaining lifecycle, signal and
109
+ recovery checks. Host permissions and network access follow explicit caller
110
+ authorization and host policy; there is no automatic merge, deploy, release or
111
+ publish.
112
+
113
+ The `v0.5.0` automation milestone adds a structured acceptance pipeline:
109
114
  multiple argv-based checks, an independent read-only Reviewer, bounded structured
110
115
  findings and repair rounds. Legacy text tasks keep the default `git diff --check`.
111
116
  The Reviewer only has `read`, `grep`, `find` and `ls`; it cannot edit files or grant
112
- permissions. Stability evidence is pinned to Claude Code `2.1.270`; CLI
117
+ permissions. The next gates are pinned Claude Code `2.1.270` stability statistics
118
+ and explicit session recovery. Coordinated multi-worker scheduling is a later
119
+ milestone; CI will use deterministic fake Workers/replay fixtures, and real
120
+ multi-worker Claude tests will remain authenticated manual Spikes. CLI
113
121
  multi-version compatibility, sandboxing, low-privilege execution and network
114
122
  isolation are not part of this milestone.
115
123
 
116
124
  Automatic mode persists the Pi Decision Worker session under the configured state
117
125
  directory. After an unclean Pi restart, `/supervise sessions` lists recoverable
118
- tasks; `/supervise recover <task-id>` explicitly restores the Decision Worker
126
+ tasks; `/supervise recover [--takeover] <task-id>` explicitly restores the Decision Worker
119
127
  context and starts a new Claude Worker. It never silently resumes or duplicates
120
- work. The adapter intentionally does not inherit arbitrary host environment variables.
128
+ work. If the old Pi owner is dead, add `--takeover` only after the lease proves
129
+ the old Worker's process group is gone and its cgroup is a real, readable empty
130
+ boundary; missing or unverifiable Worker evidence is refused.
131
+ For a persistent tmux Worker, use explicit `adopt-tmux` instead of takeover.
132
+ The adapter intentionally does not inherit arbitrary host environment variables.
121
133
  Pass credentials through an explicit `WorkerStartInput.env` in an embedding
122
134
  integration. For the built-in command, opt in to named variables, for example
123
135
  `PI_CLAUDE_SUPERVISOR_WORKER_ENV=ANTHROPIC_API_KEY`.
@@ -27,6 +27,28 @@ directories/worktrees; same-cwd and parent/child cwd starts are rejected before
27
27
  spawn, including concurrent starts, to prevent uncoordinated edits. Pending starts
28
28
  are also awaited during Pi shutdown.
29
29
 
30
+ ### Multi-worker boundary and roadmap
31
+
32
+ The current implementation supports multiple **independent** task sessions, not
33
+ coordinated shared-worktree editing. Every active session must own a
34
+ non-overlapping canonical cwd/worktree and has an isolated Supervisor,
35
+ watchdog, Worker handle and acceptance/review loop. The shared EventLog is only
36
+ an audit stream; it is not a collaboration or authorization channel.
37
+
38
+ A future multi-worker scheduler must introduce an explicit parent/child task
39
+ graph, roles, dependencies, bounded concurrency and structured handoff
40
+ artifacts. Child Workers must communicate through validated evidence and event
41
+ references rather than another Worker's control channel. Each child is accepted
42
+ independently; the parent can complete only after aggregate acceptance and
43
+ independent Review. Integration, conflict resolution, merge and publication
44
+ remain explicit human-controlled operations in a separate integration worktree.
45
+
46
+ Recovery and shutdown must be graph-aware: a parent with an unknown child state
47
+ cannot complete, cancellation must propagate within a bounded budget, and Pi
48
+ shutdown must leave every child either cleanup-verified or explicitly
49
+ recoverable. This work is scheduled after single-worker stability and session
50
+ recovery, not by relaxing the current cwd lease rule.
51
+
30
52
  ## MVP transport
31
53
 
32
54
  `ProcessWorkerAdapter` uses `node:child_process.spawn` with:
@@ -126,9 +148,12 @@ registry before spawning Claude. The default registry is
126
148
  may point all Pi processes at an alternate shared directory. Canonical paths
127
149
  conflict with both their parents and descendants, and the registry lock
128
150
  serializes acquisition across independent Pi processes. A lease is released
129
- only after the adapter confirms the worker and its descendant cleanup; an
130
- unconfirmed lease left by a crashed Pi is intentionally retained and requires
131
- operator verification/manual cleanup rather than unsafe automatic reclamation.
151
+ only after the adapter confirms the worker and its descendant cleanup. An
152
+ unconfirmed lease left by a crashed Pi is intentionally retained. Ordinary
153
+ recovery refuses it; an operator may use `recover --takeover` only when the old
154
+ owner is dead, the Worker process group is gone, and the lease independently
155
+ reads a real empty cgroup boundary for the old Worker. Missing or unverifiable
156
+ Worker evidence still requires manual cleanup rather than unsafe reclamation.
132
157
  An explicitly adopted tmux session may hand off an existing lease only after
133
158
  its owner identity is no longer live and its canonical cwd, tmux session/socket,
134
159
  pane id, pane PID/start time, and pane command all match; ordinary starts
@@ -170,7 +195,8 @@ duplicate turns. The adapter also exposes event subscriptions for `result`,
170
195
  `control_request`, permission requests and process exit. Automatic mode routes
171
196
  those events to a persistent, read-only Pi Decision Worker; its Pi session JSONL
172
197
  and task mapping are persisted under the supervisor state directory. After an
173
- unclean Pi restart, recovery is explicit: `/supervise recover <task-id>` restores
198
+ unclean Pi restart, recovery is explicit: `/supervise recover [--takeover]
199
+ <task-id>` restores
174
200
  the Decision Worker context and starts a new Claude Worker. It does not silently
175
201
  resume or duplicate a task. It does not poll to detect turn completion. A watchdog timer remains only as a deadlock safety
176
202
  fallback. Permission actions pass through `evaluatePermission` and can be
@@ -1,6 +1,6 @@
1
1
  # Pi Claude Supervisor 完整方案
2
2
 
3
- > 文档状态:方案设计稿 / MVP 实施基线
3
+ > 文档状态:`v0.5.0` 已发布 / 后续开发路线图
4
4
  > 目标项目目录:`pi-claude-supervisor`
5
5
  > 适用对象:W、项目负责人、实现人员、评审人员
6
6
 
@@ -36,7 +36,7 @@ Claude Code Worker
36
36
  4. Supervisor 因误判导致无限循环、危险操作或不可审计的修改。
37
37
  5. 人工无法随时接管或恢复任务。
38
38
 
39
- **总体判断:架构方向可以 GO。先完成兼容性 Spike、生命周期和故障恢复验证;低权限用户、OS sandbox 与网络隔离不作为当前主线或硬性阻塞,按调用者明确授权和宿主机策略运行,后续再做安全加固。**
39
+ **当前状态:`v0.5.0` 已完成“验收—独立 Review—自动修复—再验收”闭环并正式发布。下一阶段先完成固定 Claude Code `2.1.270` 的稳定性统计和恢复语义,再推进有边界的多 Worker 协作;低权限用户、OS sandbox 与网络隔离仍是后续安全加固,不作为当前主线硬性阻塞。**
40
40
 
41
41
  ---
42
42
 
@@ -74,7 +74,9 @@ Claude Code 适合作为实际开发 Worker,但在长时间任务中可能出
74
74
  - 不自动 merge、deploy 或 release。
75
75
 
76
76
  当前扩展已支持多个独立任务会话并行推进,但不允许活动会话共享同一
77
- 工作目录。事件日志由跨进程锁协调,状态和 watchdog 按会话隔离。
77
+ 工作目录。事件日志由跨进程锁协调,状态和 watchdog 按会话隔离。这里要区分两种
78
+ “多 Worker”:**独立会话并行**已经属于当前能力;**有依赖、交接和汇总验收的协同多
79
+ Worker**属于后续开发任务,不能通过简单地放宽 cwd 限制来实现。
78
80
 
79
81
  暂不支持:
80
82
 
@@ -979,3 +981,52 @@ Reviewer 必须使用独立 Pi session,只允许 `read`、`grep`、`find`、`l
979
981
  - OS sandbox、低权限执行和网络隔离;
980
982
  - 自动 merge、deploy、release、publish;
981
983
  - 多 Worker 在同一工作树协作。
984
+
985
+ ## 21. 后续开发路线图
986
+
987
+ `v0.5.0` 的发布不代表所有自动化目标都已完成。后续任务按“稳定性 → 恢复 → 协同
988
+ 调度 → 安全加固”推进;多 Worker 可以纳入开发任务,但应作为独立阶段,不能与当前
989
+ 单 Worker 稳定性门禁混在一起。
990
+
991
+ ### 21.1 短期:稳定性收尾
992
+
993
+ - 完成真实 Claude Code `2.1.270` 重复 Spike:普通任务连续 10 次,权限和问题回退各至少 5 次;
994
+ - 补齐 replay:多轮修复、验收失败修复、repair budget 耗尽、takeover、recover 和 Pi shutdown;
995
+ - 补齐边界测试:Reviewer 流式输出上限、`DecisionSessionStore.list()` 任务 ID 校验、跨进程恢复和超时/输出截断;
996
+ - 继续观察 npm `0.5.0`、GitHub Release 资产、provenance 和回滚路径;
997
+ - 验收标准:无重复动作、错误 complete、未清理 Worker 或未审计的自动放行。
998
+
999
+ ### 21.2 中期:恢复能力
1000
+
1001
+ - 设计安全的 Claude session resume;明确 `--resume` 与实时 PTY attach 的边界;
1002
+ - 完善 takeover、recover、Pi shutdown、Worker 崩溃和部分完成的状态语义;
1003
+ - 增加跨进程恢复端到端测试,包括 cwd lease、Decision Worker session、Worker 身份和事件日志一致性;
1004
+ - 恢复失败必须进入 `HUMAN_REQUIRED`,不能静默重放原始任务或重复发送输入。
1005
+
1006
+ ### 21.3 后续:多 Worker 协作与调度
1007
+
1008
+ 第一阶段只做**独立 worktree 的多 Worker 编排**,不允许共享工作树写入。建议拆成以下
1009
+ 开发任务:
1010
+
1011
+ 1. **任务图与角色模型**:增加 `parentTaskId`、Worker role、`dependsOn`、worktree、handoff
1012
+ artifact 和子任务状态;明确 root task 与 child task 的审计关联。
1013
+ 2. **受限调度器**:实现并发上限、依赖就绪、全局时间/修复预算、取消传播和失败隔离;
1014
+ 不让任意 Worker 自行启动、停止或批准另一个 Worker。
1015
+ 3. **结构化交接**:Worker 之间只通过受限 artifact、事件引用和验收报告交接,不直接共享
1016
+ 控制通道;交接内容必须经过 schema 校验和大小限制。
1017
+ 4. **汇总验收**:每个 child 先独立验收,root task 再汇总目标、diff、测试和 Reviewer 结果;
1018
+ 冲突、缺失证据或任一 P0/P1 自动升级人工。
1019
+ 5. **恢复与关闭**:支持单个 child、整棵任务图和 Pi shutdown 的一致性恢复;父任务不能在
1020
+ 子任务状态未知时报告 `completed`。
1021
+ 6. **冲突检测和人工整合**:只允许在独立 integration worktree 中进行显式整合;不自动
1022
+ merge/publish,冲突和整合动作必须保留人工控制权。
1023
+
1024
+ 多 Worker 阶段的最小验收矩阵:两个独立 Worker 并行、依赖顺序、一个 Worker 失败、取消
1025
+ 传播、重复交接、工作树冲突、单 child 恢复、整棵任务图恢复和 shutdown 中断。通过这些
1026
+ 门禁后,才评估是否需要更复杂的 lead-worker 或动态任务分解。
1027
+
1028
+ ### 21.4 后置:安全加固
1029
+
1030
+ - CLI 多版本兼容矩阵;
1031
+ - OS sandbox、低权限执行、网络隔离/allowlist;
1032
+ - 更深的供应链、SBOM、密钥隔离和生产监控。
@@ -0,0 +1,24 @@
1
+ # Recovery follow-up review
2
+
3
+ The independent read-only review of the recovery changes initially returned
4
+ `BLOCK` with three P1 findings, one P2 finding, and a coverage note. The findings
5
+ were addressed in this follow-up.
6
+
7
+ - Stale `starting`/`registered`/`recovered_idle` claims now persist recovery
8
+ owner PID/start time, are reconciled only after the old owner and Worker
9
+ boundary are independently gone, and return to an explicit `interrupted`
10
+ state.
11
+ - `--takeover` requires a dead owner, dead Worker/process group, and a real,
12
+ readable empty cgroup boundary; missing or unverifiable Worker evidence is
13
+ rejected.
14
+ - Worker registration and `recovered_idle` transitions require an active record,
15
+ persist atomically, and are read back and checked before the recovered session
16
+ is exposed.
17
+ - Adopted tmux handoff reports the replaced task and closes its old Decision
18
+ session mapping only after worker identity registration succeeds.
19
+ - Session closure now carries cleanup evidence and intent; uncertain cleanup
20
+ retains the active record and cwd lease.
21
+
22
+ Validation: `npm run check`, `npm run build`, the pinned Claude Code 2.1.270
23
+ question matrix (5/5 verified), and the bounded stability evidence in
24
+ [`stability-matrix-2.1.270.md`](stability-matrix-2.1.270.md).
@@ -0,0 +1,32 @@
1
+ # Claude Code 2.1.270 stability matrix
2
+
3
+ Run date: 2026-09-14. The executable was resolved as
4
+ `/home/yancao/.local/share/mise/installs/claude/2.1.270/claude` and reported
5
+ `2.1.270 (Claude Code)`. Runs used the authenticated local provider and
6
+ `scripts/spike-claude-automation.mjs`; no Claude `--resume` was used.
7
+
8
+ ## Required matrix (120 s bounded run)
9
+
10
+ | Scenario | Runs | Completed + verified | Fail-closed outcomes |
11
+ | --- | ---: | ---: | --- |
12
+ | ordinary task | 10 | 9 | 1 stopped at the bounded deadline without verification |
13
+ | permission handling | 5 | 5 | 0 |
14
+ | question handling | 5 | 5 | 0 |
15
+
16
+ The ordinary outlier was not treated as success: the spike exited non-zero,
17
+ kept verification false, and stopped rather than retrying or replaying the task.
18
+ This is the intended timeout fail-closed behavior. An earlier question spike also
19
+ escalated after invalid Decision Worker output; it was likewise not counted as a
20
+ success. The five-run question matrix above was then rerun with a 180 s bound and
21
+ all five completed with `verified=true` and zero human interventions.
22
+
23
+ ## Bounded follow-up
24
+
25
+ Three additional ordinary and three additional question runs with the same
26
+ pinned executable and a 180 s bound also completed with `verified=true` and zero
27
+ human interventions. These runs support a provider-latency explanation for the
28
+ 120 s outliers; they do not turn an outlier into a success.
29
+
30
+ The deterministic replay, acceptance, recovery-state, lease, cleanup and
31
+ package checks remain the CI evidence. Authenticated Claude runs are manual
32
+ release evidence only.
package/docs/testing.md CHANGED
@@ -67,8 +67,10 @@ guarantee.
67
67
  The extension persists each automatic Decision Worker session as Pi JSONL plus a
68
68
  0600 task mapping. Recovery is explicit and safe: after an unclean Pi restart,
69
69
  `/supervise sessions` shows the task as `recoverable`, and `/supervise recover
70
- <task-id>` restores the Decision Worker history before starting a new Claude
71
- Worker.
70
+ [--takeover] <task-id>` restores the Decision Worker history before starting a new
71
+ Claude Worker. `--takeover` is accepted only when the old Pi owner is dead, the
72
+ Worker process group is gone, and its cgroup is a real readable empty boundary;
73
+ persistent tmux sessions use `adopt-tmux`.
72
74
  Run the permission and signal probes explicitly when validating a CLI release:
73
75
 
74
76
  ```bash
@@ -94,7 +96,9 @@ response, and an exact result marker; it also records metadata only.
94
96
 
95
97
  For each release, pin and record the validated Claude Code version, resolved
96
98
  executable path, and model. The spikes reject an unpinned/mismatched executable
97
- version. For this release the validated version is `2.1.270` with model `opus`.
99
+ version. For this release the validated version is `2.1.270` with model `opus`;
100
+ the bounded matrix and its fail-closed outliers are recorded in
101
+ [`docs/stability-matrix-2.1.270.md`](stability-matrix-2.1.270.md).
98
102
  Record:
99
103
 
100
104
  1. exact version and resolved executable path;
@@ -152,7 +156,8 @@ the managed process group.
152
156
 
153
157
  ## Near-term automation acceptance gate
154
158
 
155
- The next implementation milestone focuses on real stability for the pinned
159
+ The `v0.5.0` implementation of the acceptance—independent Review—repair—reacceptance
160
+ loop is shipped. The remaining gate focuses on real stability for the pinned
156
161
  Claude Code `2.1.270` CLI. It does not add a multi-version matrix or wait for
157
162
  OS sandbox, low-privilege or network-isolation work.
158
163
 
@@ -186,6 +191,30 @@ The adapter/replay matrix must cover:
186
191
 
187
192
  Real Claude tests remain authenticated manual Spikes and are pinned to
188
193
  `2.1.270`; they are not part of normal CI. Normal CI runs deterministic fake
189
- Worker and replay fixtures. Stability evidence should include ten consecutive
190
- ordinary automatic runs and at least five runs each for permission and question
191
- handling, with no duplicate action, false completion or unreaped Worker.
194
+ Worker and replay fixtures. The short-term stability gate is still pending and
195
+ must include ten consecutive ordinary automatic runs and at least five runs each
196
+ for permission and question handling, with no duplicate action, false completion
197
+ or unreaped Worker.
198
+
199
+ ## Future multi-worker test plan
200
+
201
+ Multiple independent task sessions already run concurrently when their canonical
202
+ cwd/worktrees do not overlap. This is not yet coordinated multi-worker
203
+ collaboration. The future multi-worker milestone must be tested as a task graph,
204
+ not as unrestricted shared-worker access.
205
+
206
+ Required deterministic and integration coverage:
207
+
208
+ - two independent Workers with separate worktrees and aggregated parent status;
209
+ - dependency ordering and a blocked child that must not start early;
210
+ - child failure, timeout, cancellation propagation and bounded global budgets;
211
+ - schema-validated handoff artifacts with duplicate/oversized/stale handoffs;
212
+ - conflicting diffs detected before integration, with no same-worktree writes;
213
+ - independent child acceptance followed by root-task aggregate acceptance and Review;
214
+ - single-child recovery, whole-graph recovery and Pi shutdown during scheduling;
215
+ - no child can grant permissions, send control input to another child or bypass Policy Gate;
216
+ - explicit human-controlled integration in a separate worktree; no automatic merge or publish.
217
+
218
+ The multi-worker gate should be added only after the pinned single-worker stability
219
+ and recovery gates pass. CI should use fake Workers and replay fixtures; authenticated
220
+ Claude multi-worker Spikes remain manual and version-pinned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/cwd-lease.ts CHANGED
@@ -36,8 +36,25 @@ export interface CwdLeaseHandoff {
36
36
  tmuxSocket?: string;
37
37
  }
38
38
 
39
+ /**
40
+ * Explicit recovery takeover. This is intentionally separate from ordinary
41
+ * acquisition: an old Pi owner may have died while its Worker survived.
42
+ */
43
+ export interface CwdLeaseTakeover {
44
+ taskId: string;
45
+ /** Run before the old lease is removed; failure leaves the old lease intact. */
46
+ beforeReplace?: (lease: CwdLeaseRecord) => Promise<void>;
47
+ }
48
+
49
+ export interface CwdLeaseAcquireOptions {
50
+ handoff?: CwdLeaseHandoff;
51
+ takeover?: CwdLeaseTakeover;
52
+ }
53
+
39
54
  export interface CwdLeaseHandle {
40
55
  readonly record: CwdLeaseRecord;
56
+ /** Task id whose lease was explicitly handed off/taken over, if any. */
57
+ readonly replacedTaskId?: string;
41
58
  updateWorker(worker: CwdLeaseWorker): Promise<void>;
42
59
  release(): Promise<void>;
43
60
  }
@@ -63,7 +80,9 @@ export class CwdLeaseStore {
63
80
  return this.#directory;
64
81
  }
65
82
 
66
- async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options: { handoff?: CwdLeaseHandoff } = {}): Promise<CwdLeaseHandle> {
83
+ async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options: CwdLeaseAcquireOptions = {}): Promise<CwdLeaseHandle> {
84
+ assertTaskId(taskId);
85
+ if (options.takeover) assertTaskId(options.takeover.taskId);
67
86
  const canonicalCwd = await realpath(resolve(cwd));
68
87
  const now = new Date().toISOString();
69
88
  const lease: CwdLeaseRecord = {
@@ -77,6 +96,7 @@ export class CwdLeaseStore {
77
96
  updatedAt: now,
78
97
  worker: { transport },
79
98
  };
99
+ let replacedTaskId: string | undefined;
80
100
  await this.#withLock(async () => {
81
101
  const leases = await this.#readAll();
82
102
  let handoffLease: CwdLeaseRecord | undefined;
@@ -88,23 +108,35 @@ export class CwdLeaseStore {
88
108
  continue;
89
109
  }
90
110
  if (!pathsOverlap(existing.cwd, canonicalCwd)) continue;
111
+ if (options.takeover?.taskId === existing.taskId
112
+ && existing.cwd === canonicalCwd
113
+ && await canTakeoverLease(existing)) {
114
+ await options.takeover.beforeReplace?.(existing);
115
+ replacedTaskId = existing.taskId;
116
+ await rm(this.#path(existing.leaseId), { force: true });
117
+ continue;
118
+ }
91
119
  throw new Error(`working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
92
120
  }
93
121
  await this.#write(lease);
94
- if (handoffLease) await rm(this.#path(handoffLease.leaseId), { force: true });
122
+ if (handoffLease) {
123
+ replacedTaskId = handoffLease.taskId;
124
+ await rm(this.#path(handoffLease.leaseId), { force: true });
125
+ }
95
126
  });
96
- return this.#handle(lease);
127
+ return this.#handle(lease, replacedTaskId);
97
128
  }
98
129
 
99
130
  async list(): Promise<CwdLeaseRecord[]> {
100
131
  return this.#withLock(() => this.#readAll());
101
132
  }
102
133
 
103
- #handle(initial: CwdLeaseRecord): CwdLeaseHandle {
134
+ #handle(initial: CwdLeaseRecord, replacedTaskId?: string): CwdLeaseHandle {
104
135
  let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
105
136
  let released = false;
106
137
  return {
107
138
  get record() { return current; },
139
+ get replacedTaskId() { return replacedTaskId; },
108
140
  updateWorker: async (worker) => {
109
141
  if (released) throw new Error("cwd lease has already been released");
110
142
  assertWorker(worker);
@@ -157,8 +189,8 @@ export class CwdLeaseStore {
157
189
  async #write(lease: CwdLeaseRecord): Promise<void> {
158
190
  await this.#ensureDirectory();
159
191
  const target = this.#path(lease.leaseId);
160
- const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
161
- await writeFile(temporary, `${JSON.stringify(lease, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
192
+ const temporary = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
193
+ await writeFile(temporary, `${JSON.stringify(lease, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
162
194
  await rename(temporary, target);
163
195
  await chmod(target, 0o600);
164
196
  }
@@ -252,6 +284,10 @@ async function processIdentityLive(pid: number, expectedStartTime?: string): Pro
252
284
  const currentStartTime = await processStartTime(pid);
253
285
  if (currentStartTime && expectedStartTime) return currentStartTime === expectedStartTime;
254
286
  if (currentStartTime) return true;
287
+ return processExists(pid);
288
+ }
289
+
290
+ async function processExists(pid: number): Promise<boolean> {
255
291
  try {
256
292
  process.kill(pid, 0);
257
293
  return true;
@@ -260,6 +296,55 @@ async function processIdentityLive(pid: number, expectedStartTime?: string): Pro
260
296
  }
261
297
  }
262
298
 
299
+ async function processGroupExists(pid: number): Promise<boolean> {
300
+ try {
301
+ process.kill(-pid, 0);
302
+ return true;
303
+ } catch (error) {
304
+ return error instanceof Error && /EPERM/u.test(error.message);
305
+ }
306
+ }
307
+
308
+ async function cgroupHasProcesses(path: string): Promise<boolean> {
309
+ // Lease files are untrusted state. Never read an arbitrary path during
310
+ // takeover; only a real, canonical cgroup below the kernel cgroup root is
311
+ // eligible for this check. Missing/unreadable evidence is not proof of an
312
+ // empty cgroup: a descendant may have escaped before the cgroup disappeared.
313
+ const cgroupRoot = resolve("/sys/fs/cgroup");
314
+ const cgroupPath = resolve(path);
315
+ if (cgroupPath === cgroupRoot || !cgroupPath.startsWith(`${cgroupRoot}/`)) return true;
316
+ try {
317
+ const cgroupInfo = await lstat(cgroupPath);
318
+ if (!cgroupInfo.isDirectory() || cgroupInfo.isSymbolicLink()) return true;
319
+ const canonicalPath = await realpath(cgroupPath);
320
+ if (canonicalPath !== cgroupPath || !canonicalPath.startsWith(`${cgroupRoot}/`)) return true;
321
+ const procsPath = join(canonicalPath, "cgroup.procs");
322
+ const procsInfo = await lstat(procsPath);
323
+ if (!procsInfo.isFile() || procsInfo.isSymbolicLink()) return true;
324
+ const contents = await readFile(procsPath, "utf8");
325
+ return contents.split(/\s+/u).some((pid) => /^\d+$/u.test(pid));
326
+ } catch {
327
+ return true;
328
+ }
329
+ }
330
+
331
+ async function canTakeoverLease(lease: CwdLeaseRecord): Promise<boolean> {
332
+ // The old supervisor owner must be gone. A dead owner is not enough when
333
+ // the detached Worker itself is still alive.
334
+ if (await processExists(lease.ownerPid)) return false;
335
+ const worker = lease.worker;
336
+ if (!worker) return false;
337
+ if (worker.transport === "tmux") return false;
338
+ if (!worker.pid) return false;
339
+ if (await processExists(worker.pid)) return false;
340
+ if (await processGroupExists(worker.pid)) return false;
341
+ // A process-group check cannot see a setsid descendant. Explicit takeover
342
+ // therefore requires the verified cgroup boundary used by the adapter.
343
+ if (!worker.cgroupPath || !resolve(worker.cgroupPath).startsWith(`${resolve("/sys/fs/cgroup")}/`)) return false;
344
+ if (await cgroupHasProcesses(worker.cgroupPath)) return false;
345
+ return true;
346
+ }
347
+
263
348
  async function processStartTime(pid: number): Promise<string | undefined> {
264
349
  try {
265
350
  const statText = await readFile(`/proc/${pid}/stat`, "utf8");
@@ -288,6 +373,10 @@ async function matchesHandoff(lease: CwdLeaseRecord, handoff: CwdLeaseHandoff):
288
373
  return !(await processIdentityLive(lease.ownerPid, lease.ownerStartTime));
289
374
  }
290
375
 
376
+ function assertTaskId(taskId: string): void {
377
+ if (!/^[0-9a-f-]{36}$/iu.test(taskId)) throw new Error("invalid cwd lease task id");
378
+ }
379
+
291
380
  function assertWorker(worker: CwdLeaseWorker): void {
292
381
  if (!["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport)
293
382
  || (worker.pid !== undefined && (!Number.isSafeInteger(worker.pid) || worker.pid < 1))