pi-claude-supervisor 0.3.0 → 0.4.0
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 +14 -2
- package/README.cn.md +3 -3
- package/README.md +1 -0
- package/docs/architecture.md +39 -7
- package/docs/releasing.md +2 -1
- package/docs/testing.md +15 -6
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +2 -1
- package/src/config.ts +2 -0
- package/src/cwd-lease.ts +345 -0
- package/src/index.ts +188 -38
- package/src/supervisor.ts +100 -22
- package/src/types.ts +22 -1
- package/src/worker/process-adapter.ts +98 -26
- package/src/worker/tmux-adapter.ts +61 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.3.0...v0.4.0) (2026-09-13)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add cross-process cwd leases and cancellable startup ([#17](https://github.com/btnalit/pi-claude-supervisor/issues/17)) ([448cbdb](https://github.com/btnalit/pi-claude-supervisor/commit/448cbdb4c6b5dd02b9ea07ba660d3689b7e2ace8))
|
|
11
|
+
|
|
5
12
|
## [0.3.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.2.2...v0.3.0) (2026-09-13)
|
|
6
13
|
|
|
7
14
|
|
|
8
15
|
### Features
|
|
9
16
|
|
|
10
17
|
* add safe tmux Claude worker transport ([1706153](https://github.com/btnalit/pi-claude-supervisor/commit/1706153289f9a6f7f9f59b2763ed98d061b79204))
|
|
18
|
+
* add cross-process canonical cwd leases and identity-bound tmux handoff
|
|
19
|
+
* make startup cancellation scoped per start and shutdown cleanup fail closed
|
|
20
|
+
* add cgroup-v2 required/auto/off policies with verified fallback cleanup
|
|
21
|
+
* harden owned/adopted tmux lifecycle, pane identity validation, and restart re-adoption
|
|
22
|
+
* validate real Claude Code 2.1.270 tmux multi-turn, pause/resume, and re-adoption behavior
|
|
11
23
|
|
|
12
24
|
## [0.2.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.2.1...v0.2.2) (2026-09-12)
|
|
13
25
|
|
|
@@ -47,7 +59,7 @@ All notable changes to this project will be documented here.
|
|
|
47
59
|
- Bounded output capture, stdin-write timeout, process-group cleanup retry and stop preemption.
|
|
48
60
|
- Linux cgroup-v2 descendant cleanup, including a `setsid()` regression fixture, with required/auto modes.
|
|
49
61
|
- Lifecycle event retry/order preservation, output restoration after log failure and shutdown cleanup retries.
|
|
50
|
-
- Claude CLI 2.1.268 permission allow/deny and SIGTERM/SIGINT transport spike evidence.
|
|
62
|
+
- Historical Claude CLI 2.1.268 permission allow/deny and SIGTERM/SIGINT transport spike evidence; current release validation uses Claude CLI 2.1.270.
|
|
51
63
|
- Event-driven JSONL `control_request`/`result`/exit events, permission responses, persistent Pi Decision Worker automation, bounded duplicate/turn handling, and outbound human-intervention webhooks.
|
|
52
64
|
- 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.
|
|
53
65
|
- 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.
|
|
@@ -55,5 +67,5 @@ All notable changes to this project will be documented here.
|
|
|
55
67
|
|
|
56
68
|
### Limitations
|
|
57
69
|
|
|
58
|
-
- tmux/PTY screen state is not Claude JSONL: trust, permission and ambiguous TUI states require human handling. Cross-version Claude CLI permission/session semantics remain outside the pinned compatibility claim.
|
|
70
|
+
- tmux/PTY screen state is not Claude JSONL: trust, permission and ambiguous TUI states require human handling. Cross-version Claude CLI permission/session semantics remain outside the pinned compatibility claim. Historical permission and signal spikes use 2.1.268; current tmux validation uses 2.1.270, while the cgroup startup-attachment window remains.
|
|
59
71
|
- No automatic merge, deployment, release or publication is implemented.
|
package/README.cn.md
CHANGED
|
@@ -42,9 +42,9 @@ export PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT=generic
|
|
|
42
42
|
# export PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET='shared-secret'
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
`exit` 事件唤醒 Decision Worker
|
|
47
|
-
Claude CLI `2.1.
|
|
45
|
+
自动模式默认使用 `claude-jsonl`,通过 `result`、`control_request` 和进程
|
|
46
|
+
`exit` 事件唤醒 Decision Worker;显式选择 tmux 时仍使用屏幕交互,不使用 JSONL 权限协议,也不会依赖 `/supervise poll` 轮询。本版本固定按已验证设备的
|
|
47
|
+
Claude CLI `2.1.270` 运行,跨版本兼容性不在本轮范围内。
|
|
48
48
|
|
|
49
49
|
然后在 Pi 中使用:
|
|
50
50
|
|
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ For an interactive Claude Code window, opt in to the tmux transport:
|
|
|
104
104
|
```bash
|
|
105
105
|
export PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux
|
|
106
106
|
export PI_CLAUDE_SUPERVISOR_WORKER='claude --permission-mode plan'
|
|
107
|
+
# tmux does not support cgroup required mode; use cgroup mode auto/off.
|
|
107
108
|
# Optional automatic Decision Worker (manual mode is the default):
|
|
108
109
|
# export PI_CLAUDE_SUPERVISOR_MODE=auto
|
|
109
110
|
# Optional, only when adopting a non-default tmux server:
|
package/docs/architecture.md
CHANGED
|
@@ -39,8 +39,9 @@ are also awaited during Pi shutdown.
|
|
|
39
39
|
|
|
40
40
|
This is a control-boundary fixture and headless transport. It does not emulate a
|
|
41
41
|
terminal. Manual compatibility mode remains `process-pipe`; automatic mode
|
|
42
|
-
(`PI_CLAUDE_SUPERVISOR_MODE=auto`)
|
|
43
|
-
validated by the fixed-version spike
|
|
42
|
+
(`PI_CLAUDE_SUPERVISOR_MODE=auto`) defaults to Claude JSONL and uses the CLI
|
|
43
|
+
contract validated by the fixed-version spike; an explicit tmux transport remains
|
|
44
|
+
screen-based.
|
|
44
45
|
|
|
45
46
|
A worker exit automatically triggers cleanup, and terminal status waits for
|
|
46
47
|
that cleanup to be confirmed (or reports a cleanup error). On Linux the adapter
|
|
@@ -62,11 +63,14 @@ signals.
|
|
|
62
63
|
## tmux/PTY transport
|
|
63
64
|
|
|
64
65
|
`TmuxWorkerAdapter` is an explicit second transport, selected with
|
|
65
|
-
`PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux`.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
`PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux`. Because tmux has no equivalent cgroup
|
|
67
|
+
containment boundary, `PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required` is rejected
|
|
68
|
+
with this transport; use `auto`/`off` only when the tmux boundary is acceptable.
|
|
69
|
+
An owned worker gets a private tmux server/socket and executes the validated Claude command directly in the pane,
|
|
70
|
+
so its pane identity remains re-adoptable after a Pi restart. The worker
|
|
71
|
+
environment is supplied to the tmux server through the same least-privilege
|
|
72
|
+
environment builder; credentials are not copied into a file; credential-shaped
|
|
73
|
+
command arguments are rejected.
|
|
70
74
|
`load-buffer`, bracketed `paste-buffer` and `send-keys Enter` provide the input
|
|
71
75
|
boundary without interpolating a task into a shell command. C0/C1 terminal
|
|
72
76
|
control bytes are rejected; CRLF is normalized to a newline.
|
|
@@ -114,6 +118,34 @@ lifecycle event append fails after the state transition, it remains pending and
|
|
|
114
118
|
is retried before the next lifecycle operation; output events restore their
|
|
115
119
|
chunks for a lossless retry.
|
|
116
120
|
|
|
121
|
+
### Cross-process cwd leases and startup cancellation
|
|
122
|
+
|
|
123
|
+
Every start and explicit recovery acquires an atomic lease in the shared lease
|
|
124
|
+
registry before spawning Claude. The default registry is
|
|
125
|
+
`~/.pi/agent/claude-supervisor/cwd-leases`; `PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR`
|
|
126
|
+
may point all Pi processes at an alternate shared directory. Canonical paths
|
|
127
|
+
conflict with both their parents and descendants, and the registry lock
|
|
128
|
+
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.
|
|
132
|
+
An explicitly adopted tmux session may hand off an existing lease only after
|
|
133
|
+
its owner identity is no longer live and its canonical cwd, tmux session/socket,
|
|
134
|
+
pane id, pane PID/start time, and pane command all match; ordinary starts
|
|
135
|
+
cannot consume another task's lease. If post-start worker identity registration
|
|
136
|
+
fails, owned workers are stopped before the lease is released; cleanup failure
|
|
137
|
+
retains both the worker and lease fail-closed. Explicit `stop` cleans owned tmux
|
|
138
|
+
sessions, while Pi shutdown detaches persistent sessions so they remain explicitly
|
|
139
|
+
re-adoptable.
|
|
140
|
+
|
|
141
|
+
Startup owns an `AbortController` and passes its signal to the adapter. A stop
|
|
142
|
+
or shutdown request aborts the controller and calls the adapter's out-of-band
|
|
143
|
+
startup cleanup without waiting behind the serialized start operation. Each
|
|
144
|
+
startup carries an opaque token, so cancelling one concurrent session cannot
|
|
145
|
+
abort another. Process and tmux adapters terminate or detach their startup
|
|
146
|
+
work, and the Supervisor rechecks cancellation before reporting `running`; a cancelled startup becomes
|
|
147
|
+
`stopped` and never reports a worker that was not cleanup-verified.
|
|
148
|
+
|
|
117
149
|
## Event log
|
|
118
150
|
|
|
119
151
|
Events are JSONL with a monotonic sequence number, timestamp, task ID and worker
|
package/docs/releasing.md
CHANGED
|
@@ -104,7 +104,8 @@ Major updates remain separate for explicit review. There is no blanket auto-merg
|
|
|
104
104
|
A maintainer can retry an existing stable release from Actions:
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
|
-
|
|
107
|
+
TAG=v0.3.0
|
|
108
|
+
gh workflow run release.yml --ref main -f tag="$TAG"
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
The workflow verifies that the release tag is stable and belongs to `main`, then
|
package/docs/testing.md
CHANGED
|
@@ -49,8 +49,11 @@ non-sensitive prompt, and prints protocol metadata rather than raw model output.
|
|
|
49
49
|
It must not be added to the normal CI gate because authentication is an owner
|
|
50
50
|
controlled prerequisite.
|
|
51
51
|
|
|
52
|
-
The
|
|
52
|
+
The historical fixtures validate one prompt, multiple turns, session resume,
|
|
53
53
|
permission allow/deny and SIGTERM/SIGINT behavior with Claude Code 2.1.268.
|
|
54
|
+
The current release validation uses Claude Code 2.1.270 at
|
|
55
|
+
`/home/yancao/.local/share/mise/installs/claude/2.1.270/claude`, including real
|
|
56
|
+
owned tmux turns, pause/resume, and restart re-adoption.
|
|
54
57
|
The adapter regression suite also verifies event subscription, parsed
|
|
55
58
|
`permission_request` events, and the exact nested `control_response` envelope.
|
|
56
59
|
The automation spike additionally exercises a real Pi SDK Decision Worker with
|
|
@@ -70,10 +73,15 @@ npm run spike:signals
|
|
|
70
73
|
npm run spike:automation
|
|
71
74
|
SPIKE_AUTOMATION_PERMISSION=1 npm run spike:automation
|
|
72
75
|
SPIKE_AUTOMATION_QUESTION=1 npm run spike:automation
|
|
76
|
+
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
|
|
73
77
|
```
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
The tmux spike is gated, authenticated, and excluded from normal CI. It uses
|
|
80
|
+
plan mode, records only protocol metadata, and verifies two real Claude turns,
|
|
81
|
+
pause/resume, owned detach, and identity-bound restart re-adoption.
|
|
82
|
+
|
|
83
|
+
For each release, pin and record the validated Claude Code version and resolved
|
|
84
|
+
executable path. For this release the validated version is `2.1.270`. Record:
|
|
77
85
|
|
|
78
86
|
1. exact version and resolved executable path;
|
|
79
87
|
2. license and source revision;
|
|
@@ -86,9 +94,10 @@ executable path. Record:
|
|
|
86
94
|
A passing worker task is not sufficient. The independent verifier must repeat the
|
|
87
95
|
relevant checks from a clean host perspective.
|
|
88
96
|
|
|
89
|
-
Automatic mode is enabled with `PI_CLAUDE_SUPERVISOR_MODE=auto`; it
|
|
90
|
-
and routes `result`, permission, and process-exit events to the persistent
|
|
91
|
-
Decision Worker. `
|
|
97
|
+
Automatic mode is enabled with `PI_CLAUDE_SUPERVISOR_MODE=auto`; it defaults to
|
|
98
|
+
JSONL and routes `result`, permission, and process-exit events to the persistent
|
|
99
|
+
Pi Decision Worker. An explicit `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux` selection
|
|
100
|
+
remains screen-based and does not use the JSONL permission protocol. `process-pipe` remains the manual compatibility mode. Human
|
|
92
101
|
escalation is outbound-only through `PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL`;
|
|
93
102
|
approval callbacks are deliberately not accepted without a separately
|
|
94
103
|
authenticated endpoint.
|
|
@@ -8,7 +8,7 @@ without exposing credentials or allowing repository changes.
|
|
|
8
8
|
## Environment
|
|
9
9
|
|
|
10
10
|
- Node: `v26.8.1` (declared package minimum remains `>=22.19`)
|
|
11
|
-
- Claude Code: `2.1.268`
|
|
11
|
+
- Claude Code: `2.1.268` (historical headless probe; current release validation uses `2.1.270`)
|
|
12
12
|
- Executable: resolved through `PATH` as `claude`
|
|
13
13
|
- Working directory: `/home/yancao/Work`
|
|
14
14
|
- Session persistence: disabled for the stateless fixture; enabled for the separate resume fixture
|
|
@@ -56,7 +56,7 @@ existing default remains generic `process-pipe`.
|
|
|
56
56
|
|
|
57
57
|
## Required follow-up
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Historical permission evidence was collected with Claude Code `2.1.268` using
|
|
60
60
|
`--permission-prompt-tool stdio --permission-mode default --tools Bash`:
|
|
61
61
|
|
|
62
62
|
- The CLI emitted `control_request` with `request.subtype=can_use_tool`,
|
|
@@ -72,7 +72,7 @@ Additional evidence was collected with Claude Code `2.1.268` using
|
|
|
72
72
|
decision at the wrong envelope level is rejected as an invalid permission
|
|
73
73
|
result.
|
|
74
74
|
|
|
75
|
-
The exact signal fixture also passed for `2.1.268`: after `system/init`, a group
|
|
75
|
+
The exact signal fixture also passed for historical version `2.1.268`: after `system/init`, a group
|
|
76
76
|
`SIGTERM` produced exit code `143` with no terminal result; group `SIGINT`
|
|
77
77
|
produced exit code `0` and a terminal result with `terminal_reason=
|
|
78
78
|
"aborted_streaming"` and `is_error=true`. The new adapter cgroup-v2 fixture
|
|
@@ -87,8 +87,8 @@ npm run spike:signals
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Remaining evidence is malformed/duplicate CLI input, no secret leakage in
|
|
90
|
-
captured events, and the cgroup startup-attachment window.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
lifecycle track is now **GO** for the installed
|
|
94
|
-
containment remains follow-up work.
|
|
90
|
+
captured events, and the cgroup startup-attachment window. The current release
|
|
91
|
+
also passed a real Claude Code `2.1.270` tmux validation: owned multi-turn
|
|
92
|
+
interaction, pause/resume, explicit detach, and identity-bound restart
|
|
93
|
+
re-adoption. The adapter-level lifecycle track is now **GO** for the installed
|
|
94
|
+
CLI and host; atomic OS process containment remains follow-up work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-claude-supervisor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"test:install": "node scripts/test-install.mjs",
|
|
57
57
|
"test:pi": "node scripts/test-pi.mjs",
|
|
58
58
|
"spike:transport": "node scripts/spike-claude-transport.mjs",
|
|
59
|
+
"spike:tmux": "node scripts/spike-claude-tmux.mjs",
|
|
59
60
|
"spike:permissions": "node scripts/spike-claude-permissions.mjs",
|
|
60
61
|
"spike:signals": "node scripts/spike-claude-signals.mjs",
|
|
61
62
|
"spike:automation": "node scripts/spike-claude-automation.mjs",
|
package/src/config.ts
CHANGED
|
@@ -7,9 +7,11 @@ const allowed = new Set([
|
|
|
7
7
|
"PI_CLAUDE_SUPERVISOR_MODE",
|
|
8
8
|
"PI_CLAUDE_SUPERVISOR_AUTOMATION",
|
|
9
9
|
"PI_CLAUDE_SUPERVISOR_TRANSPORT",
|
|
10
|
+
"PI_CLAUDE_SUPERVISOR_CGROUP_MODE",
|
|
10
11
|
"PI_CLAUDE_SUPERVISOR_TMUX_SOCKET",
|
|
11
12
|
"PI_CLAUDE_SUPERVISOR_WORKER",
|
|
12
13
|
"PI_CLAUDE_SUPERVISOR_STATE_DIR",
|
|
14
|
+
"PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR",
|
|
13
15
|
"PI_CLAUDE_SUPERVISOR_WORKER_ENV",
|
|
14
16
|
"PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL",
|
|
15
17
|
"PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT",
|
package/src/cwd-lease.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile, rename } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { redactSensitive } from "./redaction.ts";
|
|
5
|
+
|
|
6
|
+
export type CwdLeaseTransport = "process-pipe" | "jsonl" | "pty" | "tmux";
|
|
7
|
+
|
|
8
|
+
export interface CwdLeaseWorker {
|
|
9
|
+
transport: CwdLeaseTransport;
|
|
10
|
+
pid?: number;
|
|
11
|
+
startTime?: string;
|
|
12
|
+
sessionName?: string;
|
|
13
|
+
tmuxSocket?: string;
|
|
14
|
+
ownership?: "owned" | "adopted";
|
|
15
|
+
cgroupPath?: string;
|
|
16
|
+
tmuxTarget?: string;
|
|
17
|
+
tmuxPaneId?: string;
|
|
18
|
+
paneStartTime?: string;
|
|
19
|
+
paneCommand?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CwdLeaseRecord {
|
|
23
|
+
version: 1;
|
|
24
|
+
leaseId: string;
|
|
25
|
+
taskId: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
ownerPid: number;
|
|
28
|
+
ownerStartTime?: string;
|
|
29
|
+
acquiredAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
worker?: CwdLeaseWorker;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CwdLeaseHandoff {
|
|
35
|
+
sessionName: string;
|
|
36
|
+
tmuxSocket?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CwdLeaseHandle {
|
|
40
|
+
readonly record: CwdLeaseRecord;
|
|
41
|
+
updateWorker(worker: CwdLeaseWorker): Promise<void>;
|
|
42
|
+
release(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
46
|
+
const STALE_LOCK_MS = 5_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cross-process working-directory lease registry.
|
|
50
|
+
*
|
|
51
|
+
* A lease is deliberately not reclaimed merely because its owner process died:
|
|
52
|
+
* a worker may have survived the Pi process. The operator must clean up an
|
|
53
|
+
* unconfirmed lease explicitly after verifying the old worker is gone.
|
|
54
|
+
*/
|
|
55
|
+
export class CwdLeaseStore {
|
|
56
|
+
readonly #directory: string;
|
|
57
|
+
|
|
58
|
+
constructor(directory: string) {
|
|
59
|
+
this.#directory = resolve(directory);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get directory(): string {
|
|
63
|
+
return this.#directory;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options: { handoff?: CwdLeaseHandoff } = {}): Promise<CwdLeaseHandle> {
|
|
67
|
+
const canonicalCwd = await realpath(resolve(cwd));
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const lease: CwdLeaseRecord = {
|
|
70
|
+
version: 1,
|
|
71
|
+
leaseId: randomUUID(),
|
|
72
|
+
taskId,
|
|
73
|
+
cwd: canonicalCwd,
|
|
74
|
+
ownerPid: process.pid,
|
|
75
|
+
ownerStartTime: await processStartTime(process.pid),
|
|
76
|
+
acquiredAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
worker: { transport },
|
|
79
|
+
};
|
|
80
|
+
await this.#withLock(async () => {
|
|
81
|
+
const leases = await this.#readAll();
|
|
82
|
+
let handoffLease: CwdLeaseRecord | undefined;
|
|
83
|
+
for (const existing of leases) {
|
|
84
|
+
if (options.handoff && existing.cwd === canonicalCwd && await matchesHandoff(existing, options.handoff)) {
|
|
85
|
+
if (handoffLease) throw new Error("multiple matching tmux cwd leases; refusing ambiguous handoff");
|
|
86
|
+
handoffLease = existing;
|
|
87
|
+
lease.worker = { ...existing.worker!, transport };
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!pathsOverlap(existing.cwd, canonicalCwd)) continue;
|
|
91
|
+
throw new Error(`working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
|
|
92
|
+
}
|
|
93
|
+
await this.#write(lease);
|
|
94
|
+
if (handoffLease) await rm(this.#path(handoffLease.leaseId), { force: true });
|
|
95
|
+
});
|
|
96
|
+
return this.#handle(lease);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async list(): Promise<CwdLeaseRecord[]> {
|
|
100
|
+
return this.#withLock(() => this.#readAll());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#handle(initial: CwdLeaseRecord): CwdLeaseHandle {
|
|
104
|
+
let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
|
|
105
|
+
let released = false;
|
|
106
|
+
return {
|
|
107
|
+
get record() { return current; },
|
|
108
|
+
updateWorker: async (worker) => {
|
|
109
|
+
if (released) throw new Error("cwd lease has already been released");
|
|
110
|
+
assertWorker(worker);
|
|
111
|
+
const next = { ...current, worker: { ...worker }, updatedAt: new Date().toISOString() };
|
|
112
|
+
await this.#withLock(async () => {
|
|
113
|
+
const leases = await this.#readAll();
|
|
114
|
+
const existing = leases.find((lease) => lease.leaseId === current.leaseId);
|
|
115
|
+
if (!existing) throw new Error("cwd lease disappeared before worker identity could be recorded");
|
|
116
|
+
if (existing.cwd !== current.cwd || existing.taskId !== current.taskId) throw new Error("cwd lease identity changed; refusing update");
|
|
117
|
+
await this.#write(next);
|
|
118
|
+
});
|
|
119
|
+
current = next;
|
|
120
|
+
},
|
|
121
|
+
release: async () => {
|
|
122
|
+
if (released) return;
|
|
123
|
+
await this.#withLock(async () => {
|
|
124
|
+
const leases = await this.#readAll();
|
|
125
|
+
const existing = leases.find((lease) => lease.leaseId === current.leaseId);
|
|
126
|
+
if (!existing) throw new Error("cwd lease disappeared before release");
|
|
127
|
+
await rm(this.#path(existing.leaseId), { force: true });
|
|
128
|
+
});
|
|
129
|
+
released = true;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #readAll(): Promise<CwdLeaseRecord[]> {
|
|
135
|
+
let names: string[];
|
|
136
|
+
try {
|
|
137
|
+
names = await readdir(this.#directory);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return [];
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const leases: CwdLeaseRecord[] = [];
|
|
143
|
+
for (const name of names.filter((item) => item.endsWith(".json"))) {
|
|
144
|
+
const leasePath = join(this.#directory, name);
|
|
145
|
+
const leaseInfo = await lstat(leasePath);
|
|
146
|
+
if (!leaseInfo.isFile()) throw new Error(`cwd lease entry is not a regular file: ${redactText(leasePath)}`);
|
|
147
|
+
const value = JSON.parse(await readFile(leasePath, "utf8")) as Partial<CwdLeaseRecord>;
|
|
148
|
+
const lease = normalizeLease(value);
|
|
149
|
+
// A missing or replaced cwd invalidates the registry evidence. Do not
|
|
150
|
+
// silently drop that record and allow a second worker to start.
|
|
151
|
+
lease.cwd = await realpath(lease.cwd);
|
|
152
|
+
leases.push(lease);
|
|
153
|
+
}
|
|
154
|
+
return leases;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #write(lease: CwdLeaseRecord): Promise<void> {
|
|
158
|
+
await this.#ensureDirectory();
|
|
159
|
+
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 });
|
|
162
|
+
await rename(temporary, target);
|
|
163
|
+
await chmod(target, 0o600);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#path(leaseId: string): string {
|
|
167
|
+
return join(this.#directory, `${leaseId}.json`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
171
|
+
await this.#ensureDirectory();
|
|
172
|
+
const lockPath = join(this.#directory, ".lock");
|
|
173
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
174
|
+
while (true) {
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(lockPath);
|
|
177
|
+
await writeFile(join(lockPath, "owner.json"), JSON.stringify({
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
startTime: await processStartTime(process.pid),
|
|
180
|
+
at: new Date().toISOString(),
|
|
181
|
+
}), { mode: 0o600 });
|
|
182
|
+
break;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
|
|
185
|
+
if (await removeStaleLock(lockPath)) continue;
|
|
186
|
+
if (Date.now() >= deadline) throw new Error(`cwd lease lock timeout: ${redactText(lockPath)}`);
|
|
187
|
+
await delay(25);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return await operation();
|
|
192
|
+
} finally {
|
|
193
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #ensureDirectory(): Promise<void> {
|
|
198
|
+
await mkdir(this.#directory, { recursive: true, mode: 0o700 });
|
|
199
|
+
const info = await lstat(this.#directory);
|
|
200
|
+
if (!info.isDirectory()) throw new Error(`cwd lease registry is not a directory: ${redactText(this.#directory)}`);
|
|
201
|
+
await chmod(this.#directory, 0o700);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function pathsOverlap(first: string, second: string): boolean {
|
|
206
|
+
const left = resolve(first);
|
|
207
|
+
const right = resolve(second);
|
|
208
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function workerIdentity(worker: Pick<CwdLeaseWorker, "pid" | "cgroupPath" | "tmuxTarget" | "tmuxPaneId" | "paneStartTime" | "paneCommand">): Promise<Pick<CwdLeaseWorker, "pid" | "startTime" | "cgroupPath" | "tmuxTarget" | "tmuxPaneId" | "paneStartTime" | "paneCommand">> {
|
|
212
|
+
return {
|
|
213
|
+
pid: worker.pid,
|
|
214
|
+
startTime: worker.pid ? await processStartTime(worker.pid) : undefined,
|
|
215
|
+
cgroupPath: worker.cgroupPath,
|
|
216
|
+
tmuxTarget: worker.tmuxTarget,
|
|
217
|
+
tmuxPaneId: worker.tmuxPaneId,
|
|
218
|
+
paneStartTime: worker.paneStartTime,
|
|
219
|
+
paneCommand: worker.paneCommand,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function removeStaleLock(lockPath: string): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
const lockInfo = await lstat(lockPath);
|
|
226
|
+
if (!lockInfo.isDirectory()) throw new Error("cwd lease lock is not a directory");
|
|
227
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
228
|
+
const ownerInfo = await lstat(ownerPath);
|
|
229
|
+
if (!ownerInfo.isFile()) throw new Error("cwd lease lock owner is not a regular file");
|
|
230
|
+
const info = await stat(ownerPath);
|
|
231
|
+
if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
|
|
232
|
+
const owner = JSON.parse(await readFile(ownerPath, "utf8")) as { pid?: unknown; startTime?: unknown };
|
|
233
|
+
if (typeof owner.pid === "number") {
|
|
234
|
+
const currentStart = await processStartTime(owner.pid);
|
|
235
|
+
if (currentStart && typeof owner.startTime === "string" && currentStart === owner.startTime) return false;
|
|
236
|
+
try {
|
|
237
|
+
process.kill(owner.pid, 0);
|
|
238
|
+
return false;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof Error && /EPERM/u.test(error.message)) return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
244
|
+
return true;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function processIdentityLive(pid: number, expectedStartTime?: string): Promise<boolean> {
|
|
252
|
+
const currentStartTime = await processStartTime(pid);
|
|
253
|
+
if (currentStartTime && expectedStartTime) return currentStartTime === expectedStartTime;
|
|
254
|
+
if (currentStartTime) return true;
|
|
255
|
+
try {
|
|
256
|
+
process.kill(pid, 0);
|
|
257
|
+
return true;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return error instanceof Error && /EPERM/u.test(error.message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function processStartTime(pid: number): Promise<string | undefined> {
|
|
264
|
+
try {
|
|
265
|
+
const statText = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
266
|
+
const closeParen = statText.lastIndexOf(")");
|
|
267
|
+
const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
|
|
268
|
+
const startTime = fields[19];
|
|
269
|
+
return startTime && /^\d+$/u.test(startTime) ? startTime : undefined;
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function matchesHandoff(lease: CwdLeaseRecord, handoff: CwdLeaseHandoff): Promise<boolean> {
|
|
276
|
+
const worker = lease.worker;
|
|
277
|
+
if (worker?.transport !== "tmux"
|
|
278
|
+
|| worker.sessionName !== handoff.sessionName
|
|
279
|
+
|| worker.tmuxSocket !== handoff.tmuxSocket
|
|
280
|
+
|| worker.tmuxTarget !== handoff.sessionName
|
|
281
|
+
|| !worker.pid
|
|
282
|
+
|| !worker.startTime
|
|
283
|
+
|| !worker.tmuxPaneId
|
|
284
|
+
|| !worker.paneStartTime
|
|
285
|
+
|| !worker.paneCommand) return false;
|
|
286
|
+
// A live owner is still an active supervisor. Do not let another Pi consume
|
|
287
|
+
// its lease merely because the tmux session name is known.
|
|
288
|
+
return !(await processIdentityLive(lease.ownerPid, lease.ownerStartTime));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function assertWorker(worker: CwdLeaseWorker): void {
|
|
292
|
+
if (!["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport)
|
|
293
|
+
|| (worker.pid !== undefined && (!Number.isSafeInteger(worker.pid) || worker.pid < 1))
|
|
294
|
+
|| (worker.startTime !== undefined && !/^\d+$/u.test(worker.startTime))
|
|
295
|
+
|| (worker.tmuxPaneId !== undefined && !/^%\d+$/u.test(worker.tmuxPaneId))
|
|
296
|
+
|| (worker.cgroupPath !== undefined && !worker.cgroupPath.startsWith("/"))) {
|
|
297
|
+
throw new Error("invalid cwd lease worker identity");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeLease(value: Partial<CwdLeaseRecord>): CwdLeaseRecord {
|
|
302
|
+
if (value.version !== 1 || typeof value.leaseId !== "string" || !/^[0-9a-f-]{36}$/iu.test(value.leaseId)
|
|
303
|
+
|| typeof value.taskId !== "string" || !/^[0-9a-f-]{36}$/iu.test(value.taskId)
|
|
304
|
+
|| typeof value.cwd !== "string" || !value.cwd.startsWith("/")
|
|
305
|
+
|| !Number.isSafeInteger(value.ownerPid) || (value.ownerPid ?? 0) < 1
|
|
306
|
+
|| typeof value.acquiredAt !== "string" || !Number.isFinite(Date.parse(value.acquiredAt))
|
|
307
|
+
|| typeof value.updatedAt !== "string" || !Number.isFinite(Date.parse(value.updatedAt))
|
|
308
|
+
|| (value.ownerStartTime !== undefined && (typeof value.ownerStartTime !== "string" || !/^\d+$/u.test(value.ownerStartTime)))) {
|
|
309
|
+
throw new Error("invalid cwd lease record");
|
|
310
|
+
}
|
|
311
|
+
const worker = value.worker;
|
|
312
|
+
if (worker && (typeof worker !== "object" || !["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport as string))) throw new Error("invalid cwd lease worker identity");
|
|
313
|
+
const ownerPid = value.ownerPid!;
|
|
314
|
+
return {
|
|
315
|
+
version: 1,
|
|
316
|
+
leaseId: value.leaseId,
|
|
317
|
+
taskId: value.taskId,
|
|
318
|
+
cwd: resolve(value.cwd),
|
|
319
|
+
ownerPid,
|
|
320
|
+
ownerStartTime: typeof value.ownerStartTime === "string" ? value.ownerStartTime : undefined,
|
|
321
|
+
acquiredAt: value.acquiredAt,
|
|
322
|
+
updatedAt: value.updatedAt,
|
|
323
|
+
worker: worker ? {
|
|
324
|
+
transport: worker.transport!,
|
|
325
|
+
pid: Number.isSafeInteger(worker.pid) && worker.pid! > 0 ? worker.pid : undefined,
|
|
326
|
+
startTime: typeof worker.startTime === "string" && /^\d+$/u.test(worker.startTime) ? worker.startTime : undefined,
|
|
327
|
+
sessionName: typeof worker.sessionName === "string" ? worker.sessionName : undefined,
|
|
328
|
+
tmuxSocket: typeof worker.tmuxSocket === "string" ? worker.tmuxSocket : undefined,
|
|
329
|
+
ownership: worker.ownership === "owned" || worker.ownership === "adopted" ? worker.ownership : undefined,
|
|
330
|
+
cgroupPath: typeof worker.cgroupPath === "string" ? worker.cgroupPath : undefined,
|
|
331
|
+
tmuxTarget: typeof worker.tmuxTarget === "string" ? worker.tmuxTarget : undefined,
|
|
332
|
+
tmuxPaneId: typeof worker.tmuxPaneId === "string" && /^%\d+$/u.test(worker.tmuxPaneId) ? worker.tmuxPaneId : undefined,
|
|
333
|
+
paneStartTime: typeof worker.paneStartTime === "string" ? worker.paneStartTime : undefined,
|
|
334
|
+
paneCommand: typeof worker.paneCommand === "string" ? worker.paneCommand : undefined,
|
|
335
|
+
} : undefined,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function redactText(value: string): string {
|
|
340
|
+
return String(redactSensitive(value));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function delay(ms: number): Promise<void> {
|
|
344
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
345
|
+
}
|