@pinet/slack-bridge 0.2.2 → 0.2.6
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/README.md +316 -451
- package/dist/activity-log.d.ts +10 -3
- package/dist/agent-event-runtime.d.ts +0 -2
- package/dist/agent-event-runtime.js +0 -1
- package/dist/agent-prompt-guidance.d.ts +1 -6
- package/dist/agent-prompt-guidance.js +1 -6
- package/dist/broker/adapters/slack.d.ts +4 -1
- package/dist/broker/adapters/slack.js +16 -9
- package/dist/broker/client.d.ts +21 -4
- package/dist/broker/client.js +65 -25
- package/dist/broker/hibernation-activation-authority.d.ts +22 -0
- package/dist/broker/hibernation-activation-authority.js +51 -0
- package/dist/broker/hibernation-activation.d.ts +75 -0
- package/dist/broker/hibernation-activation.js +95 -0
- package/dist/broker/hibernation-command-router.d.ts +54 -0
- package/dist/broker/hibernation-command-router.js +124 -0
- package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
- package/dist/broker/hibernation-runtime-adapters.js +473 -0
- package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
- package/dist/broker/hibernation-runtime-helpers.js +243 -0
- package/dist/broker/index.d.ts +16 -1
- package/dist/broker/index.js +68 -10
- package/dist/broker/lock-conflict.d.ts +109 -0
- package/dist/broker/lock-conflict.js +409 -0
- package/dist/broker/socket-server.d.ts +26 -0
- package/dist/broker/socket-server.js +287 -4
- package/dist/broker-delivery.d.ts +1 -0
- package/dist/broker-delivery.js +10 -1
- package/dist/broker-runtime.d.ts +14 -2
- package/dist/broker-runtime.js +114 -3
- package/dist/canvases.d.ts +12 -2
- package/dist/canvases.js +1 -3
- package/dist/compaction-gate.d.ts +7 -0
- package/dist/compaction-gate.js +35 -0
- package/dist/deploy-manifest.d.ts +8 -2
- package/dist/deploy-manifest.js +9 -7
- package/dist/follower-runtime.d.ts +5 -2
- package/dist/follower-runtime.js +39 -5
- package/dist/git-metadata.d.ts +5 -9
- package/dist/git-metadata.js +23 -42
- package/dist/guardrails.js +5 -0
- package/dist/helpers.d.ts +77 -27
- package/dist/helpers.js +150 -39
- package/dist/hibernation-config.d.ts +13 -0
- package/dist/hibernation-config.js +14 -0
- package/dist/home-tab.d.ts +13 -2
- package/dist/imessage-tools.d.ts +4 -1
- package/dist/inbox-drain-runtime.d.ts +1 -0
- package/dist/inbox-drain-runtime.js +5 -1
- package/dist/index.js +341 -108
- package/dist/pinet-commands.d.ts +14 -1
- package/dist/pinet-commands.js +93 -22
- package/dist/pinet-mesh-ops.d.ts +6 -4
- package/dist/pinet-mesh-ops.js +9 -1
- package/dist/pinet-runtime-composition.d.ts +2 -1
- package/dist/pinet-runtime-composition.js +14 -3
- package/dist/pinet-session-formatting.d.ts +26 -2
- package/dist/pinet-session-formatting.js +2 -0
- package/dist/pinet-tools.d.ts +14 -0
- package/dist/pinet-tools.js +355 -50
- package/dist/prompts/broker/default.md +3 -3
- package/dist/prompts/broker/tmux.md +3 -3
- package/dist/runtime-agent-context.d.ts +3 -3
- package/dist/runtime-agent-context.js +12 -6
- package/dist/runtime-mode.js +4 -0
- package/dist/session-ui-runtime.js +3 -1
- package/dist/single-player-runtime.d.ts +1 -1
- package/dist/single-player-runtime.js +21 -5
- package/dist/slack-access.d.ts +10 -2
- package/dist/slack-access.js +64 -22
- package/dist/slack-block-kit.d.ts +44 -2
- package/dist/slack-block-kit.js +5 -5
- package/dist/slack-file-access.d.ts +12 -1
- package/dist/slack-file-access.js +4 -4
- package/dist/slack-message-context.d.ts +9 -2
- package/dist/slack-modals.d.ts +4 -1
- package/dist/slack-modals.js +5 -5
- package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
- package/dist/slack-pinet-runtime-adapter.js +10 -0
- package/dist/slack-presence.d.ts +5 -4
- package/dist/slack-request-runtime.d.ts +3 -2
- package/dist/slack-scope-diagnostics.d.ts +27 -2
- package/dist/slack-socket-dedup.d.ts +8 -5
- package/dist/slack-tools.d.ts +10 -0
- package/dist/slack-tools.js +51 -64
- package/dist/slack-upload.d.ts +17 -1
- package/dist/subtree-broker-runtime.d.ts +83 -2
- package/dist/subtree-broker-runtime.js +774 -109
- package/dist/tool-output-limits.d.ts +11 -0
- package/dist/tool-output-limits.js +20 -0
- package/dist/tool-registration-runtime.d.ts +3 -0
- package/dist/tool-registration-runtime.js +27 -0
- package/package.json +8 -8
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Real hibernation runtime adapters: the live `HibernationProcessController` and
|
|
2
|
+
// `HibernationTmuxController` the broker injects into `HibernationOrchestrator`.
|
|
3
|
+
//
|
|
4
|
+
// Design (proven feasible on disposable tmux/pi workers):
|
|
5
|
+
// • A broker-managed worker runs `pi` as the foreground process of a tmux pane
|
|
6
|
+
// whose session was launched with `remain-on-exit on`. The pane's `pane_pid`
|
|
7
|
+
// IS the Pi runtime pid; its session `.jsonl` path is recorded in the runtime
|
|
8
|
+
// spec's `sessionResumeRef` (`session:<path>`).
|
|
9
|
+
// • Hibernate = stop the Pi pid (TERM then bounded KILL). Because the pane has
|
|
10
|
+
// `remain-on-exit on`, the tmux session survives (operator-attachable) and is
|
|
11
|
+
// NEVER destroyed here; the pane transitions to `pane_dead=1`.
|
|
12
|
+
// • Wake = respawn a launcher into the recorded pane ONLY when that exact pane
|
|
13
|
+
// is present and explicitly dead, exporting the single-use wake fence and
|
|
14
|
+
// resuming the exact session via `pi --session <path>`. The woken Pi
|
|
15
|
+
// re-registers under the SAME stable id and presents the fence for atomic
|
|
16
|
+
// generation acceptance.
|
|
17
|
+
//
|
|
18
|
+
// PID-reuse / signal safety (independent-review P1): a numeric pid is NEVER
|
|
19
|
+
// signalled on its own. Every TERM/KILL/liveness action is bound to a process
|
|
20
|
+
// GENERATION and re-verified immediately before each signal. A generation is the
|
|
21
|
+
// tuple (pane-authoritative `pane_pid`, `pane_dead=0`, and the OS process START
|
|
22
|
+
// TIME from `ps -o lstart=`). If the pane no longer hosts that exact live
|
|
23
|
+
// generation — the process exited, the pane died, or the pid was reused by a
|
|
24
|
+
// process with a different start time — the action is abandoned as "already gone"
|
|
25
|
+
// rather than risking a signal to an unrelated process. Start time is used (not
|
|
26
|
+
// the command line) because the wake launcher `exec`s Pi over its own shell: that
|
|
27
|
+
// preserves the pid AND the start time but REWRITES the command line, so a
|
|
28
|
+
// command-line token would spuriously drift across the exec while start time stays
|
|
29
|
+
// stable and correctly pins the process instance.
|
|
30
|
+
//
|
|
31
|
+
// Honest limits (independent-review P2 hardening): `ps -o lstart=` is only
|
|
32
|
+
// second-resolution and the verify→signal step is not atomic, so a residual TOCTOU
|
|
33
|
+
// window remains — a same-user process that reused the pid WITHIN the same wall
|
|
34
|
+
// second could compare equal. No higher-resolution start token is portably
|
|
35
|
+
// available via `ps` across macOS/Linux, so this is strong defense-in-depth that
|
|
36
|
+
// makes cross-second reuse safe, NOT an absolute guarantee; the same-second window
|
|
37
|
+
// is an accepted, documented residual rather than a claimed "never".
|
|
38
|
+
//
|
|
39
|
+
// The wake attempt's generation travels INSIDE the `RuntimeAttemptHandle` the
|
|
40
|
+
// controller returns (see {@link LaunchedAttemptHandle}); the orchestrator
|
|
41
|
+
// round-trips that handle opaquely and hands it back to the attempt-scoped
|
|
42
|
+
// stop/liveness probes. There is therefore NO shared mutable registry between the
|
|
43
|
+
// two controllers to mis-compose — the generation binding is carried by the
|
|
44
|
+
// handle itself, so independent construction of the two controllers (as the live
|
|
45
|
+
// wiring does) is inherently correct and a failed/timed-out wake is always
|
|
46
|
+
// cleanable by the exact process it launched.
|
|
47
|
+
//
|
|
48
|
+
// All process/tmux/ps/git interaction is funnelled through an injectable
|
|
49
|
+
// `CommandRunner` + signal/liveness hooks so the security- and correctness-
|
|
50
|
+
// critical control flow is unit-testable without spawning real processes.
|
|
51
|
+
import { execFile } from "node:child_process";
|
|
52
|
+
import * as fs from "node:fs";
|
|
53
|
+
import * as os from "node:os";
|
|
54
|
+
import * as path from "node:path";
|
|
55
|
+
import { buildResumeLauncherScript, buildWakeFenceEnv, deriveVcsIdentity, parseRssBytesFromPs, resumePathFromSessionRef, } from "./hibernation-runtime-helpers.js";
|
|
56
|
+
/** Real `CommandRunner` over `child_process.execFile` that never throws on a
|
|
57
|
+
* non-zero exit — it resolves the captured exit code so callers branch on it. */
|
|
58
|
+
export function createExecFileRunner() {
|
|
59
|
+
return {
|
|
60
|
+
run(file, args) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
execFile(file, args, { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
63
|
+
const rawCode = error?.code;
|
|
64
|
+
const code = typeof rawCode === "number" ? rawCode : error ? 1 : 0;
|
|
65
|
+
resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Boundary read of the launch generation carried by a handle. The handle arrives
|
|
73
|
+
* typed as the public broker-core shape; if it was minted by our
|
|
74
|
+
* `respawnRuntime` it carries a well-formed {@link AttemptGeneration}, otherwise
|
|
75
|
+
* the field is absent and we return null (probes then fail closed).
|
|
76
|
+
*/
|
|
77
|
+
function launchedGenerationOf(handle) {
|
|
78
|
+
return handle.generation ?? null;
|
|
79
|
+
}
|
|
80
|
+
function tmuxSocketArgs(socket) {
|
|
81
|
+
return socket ? ["-S", socket] : [];
|
|
82
|
+
}
|
|
83
|
+
// agent-standards-ignore prefer-inline-single-use-helper: centralizes default
|
|
84
|
+
// resolution for the ResolvedProcessDeps bundle shared by the controller and the
|
|
85
|
+
// module-level generation-termination helper.
|
|
86
|
+
function resolveProcessDeps(deps) {
|
|
87
|
+
return {
|
|
88
|
+
runner: deps.runner ?? createExecFileRunner(),
|
|
89
|
+
sessionByteSize: deps.sessionByteSize ??
|
|
90
|
+
((p) => {
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(p).size;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
processAlive: deps.processAlive ??
|
|
99
|
+
((pid) => {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// EPERM means the process exists but we may not signal it — still "alive".
|
|
106
|
+
return error.code === "EPERM";
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
sendSignal: deps.sendSignal ?? ((pid, signal) => process.kill(pid, signal)),
|
|
110
|
+
now: deps.now ?? (() => Date.now()),
|
|
111
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
112
|
+
pendingInboxCount: deps.pendingInboxCount ?? (() => 0),
|
|
113
|
+
stopGraceMs: deps.stopGraceMs ?? 5_000,
|
|
114
|
+
pollMs: deps.pollMs ?? 100,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function paneField(runner, addr, field) {
|
|
118
|
+
const result = await runner.run("tmux", [
|
|
119
|
+
...tmuxSocketArgs(addr.tmuxSocket),
|
|
120
|
+
"display-message",
|
|
121
|
+
"-p",
|
|
122
|
+
"-t",
|
|
123
|
+
addr.tmuxTarget,
|
|
124
|
+
`#{${field}}`,
|
|
125
|
+
]);
|
|
126
|
+
if (result.code !== 0)
|
|
127
|
+
return null;
|
|
128
|
+
const value = result.stdout.trim();
|
|
129
|
+
return value.length > 0 ? value : null;
|
|
130
|
+
}
|
|
131
|
+
async function panePid(runner, addr) {
|
|
132
|
+
const raw = await paneField(runner, addr, "pane_pid");
|
|
133
|
+
const pid = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
134
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
135
|
+
}
|
|
136
|
+
async function rssBytesOf(runner, pid) {
|
|
137
|
+
const result = await runner.run("ps", ["-o", "rss=", "-p", String(pid)]);
|
|
138
|
+
if (result.code !== 0)
|
|
139
|
+
return null;
|
|
140
|
+
return parseRssBytesFromPs(result.stdout);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The OS generation token for a pid: its process START TIME (`ps -o lstart=`),
|
|
144
|
+
* normalized. Two processes sharing a pid but not this start time are DIFFERENT
|
|
145
|
+
* process instances (cross-second reuse), so this pins the generation against the
|
|
146
|
+
* common pid-reuse case. The command line is deliberately NOT included: the wake
|
|
147
|
+
* launcher `exec`s Pi over its shell, rewriting the command line while preserving
|
|
148
|
+
* the pid and start time, so a command token would drift spuriously across that
|
|
149
|
+
* exec. Returns null when the pid is gone (no ps row). See the "Honest limits"
|
|
150
|
+
* header note re: the residual same-second window.
|
|
151
|
+
*/
|
|
152
|
+
async function readGenerationToken(runner, pid) {
|
|
153
|
+
const started = await runner.run("ps", ["-o", "lstart=", "-p", String(pid)]);
|
|
154
|
+
if (started.code !== 0)
|
|
155
|
+
return null;
|
|
156
|
+
const startedAt = started.stdout.trim().replace(/\s+/g, " ");
|
|
157
|
+
return startedAt.length > 0 ? startedAt : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Read the pane's current process generation: its foreground pid, whether the
|
|
161
|
+
* pane is dead (only an explicit `pane_dead=0` is live; null/"1" ⇒ dead/gone),
|
|
162
|
+
* and the pid's generation token. `generationToken` is null when the pane has no
|
|
163
|
+
* live pid.
|
|
164
|
+
*/
|
|
165
|
+
async function readPaneGeneration(runner, addr) {
|
|
166
|
+
const pid = await panePid(runner, addr);
|
|
167
|
+
const deadRaw = await paneField(runner, addr, "pane_dead");
|
|
168
|
+
const dead = deadRaw !== "0";
|
|
169
|
+
const generationToken = pid != null ? await readGenerationToken(runner, pid) : null;
|
|
170
|
+
return { pid, dead, generationToken };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* True only if the pane provably still hosts the EXACT expected live generation:
|
|
174
|
+
* the pane's foreground pid equals the expected pid, the pane is not dead, the
|
|
175
|
+
* pid is a live process, and its current generation token matches the expected
|
|
176
|
+
* one (defeating pid reuse). Any drift ⇒ false (the expected process is gone /
|
|
177
|
+
* not ours), so a signal is never sent to a reused or replaced pid.
|
|
178
|
+
*/
|
|
179
|
+
async function paneHostsGeneration(runner, addr, expected, processAlive) {
|
|
180
|
+
const current = await readPaneGeneration(runner, addr);
|
|
181
|
+
return (current.pid === expected.pid &&
|
|
182
|
+
!current.dead &&
|
|
183
|
+
current.generationToken != null &&
|
|
184
|
+
current.generationToken === expected.generationToken &&
|
|
185
|
+
processAlive(expected.pid));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* TERM then bounded KILL a process addressed by its pane + generation. The pane
|
|
189
|
+
* is re-verified to still host the exact expected live generation immediately
|
|
190
|
+
* before EVERY signal; the moment it no longer does (exit, pane death, or pid
|
|
191
|
+
* reuse with a different generation token) the process is treated as confirmed
|
|
192
|
+
* gone and no further signal is sent. Shared by pre-hibernation stop and
|
|
193
|
+
* failed-wake attempt cleanup. Resolves whether the generation is confirmed gone.
|
|
194
|
+
*/
|
|
195
|
+
async function terminateGeneration(addr, expected, deps) {
|
|
196
|
+
const stillOurs = () => paneHostsGeneration(deps.runner, addr, expected, deps.processAlive);
|
|
197
|
+
if (!(await stillOurs()))
|
|
198
|
+
return true;
|
|
199
|
+
try {
|
|
200
|
+
deps.sendSignal(expected.pid, "SIGTERM");
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Raced to exit between the verification and the signal.
|
|
204
|
+
return !(await stillOurs());
|
|
205
|
+
}
|
|
206
|
+
const deadline = deps.now() + deps.stopGraceMs;
|
|
207
|
+
while (deps.now() < deadline) {
|
|
208
|
+
await deps.sleep(deps.pollMs);
|
|
209
|
+
if (!(await stillOurs()))
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (!(await stillOurs()))
|
|
213
|
+
return true;
|
|
214
|
+
try {
|
|
215
|
+
deps.sendSignal(expected.pid, "SIGKILL");
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Raced to exit during escalation.
|
|
219
|
+
}
|
|
220
|
+
await deps.sleep(deps.pollMs);
|
|
221
|
+
return !(await stillOurs());
|
|
222
|
+
}
|
|
223
|
+
export function createHibernationProcessController(deps = {}) {
|
|
224
|
+
const d = resolveProcessDeps(deps);
|
|
225
|
+
async function runtimeAlive(spec) {
|
|
226
|
+
const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
|
|
227
|
+
const current = await readPaneGeneration(d.runner, addr);
|
|
228
|
+
const alive = current.pid != null &&
|
|
229
|
+
!current.dead &&
|
|
230
|
+
current.generationToken != null &&
|
|
231
|
+
d.processAlive(current.pid);
|
|
232
|
+
return {
|
|
233
|
+
alive,
|
|
234
|
+
generation: alive && current.pid != null && current.generationToken != null
|
|
235
|
+
? { pid: current.pid, generationToken: current.generationToken }
|
|
236
|
+
: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
async requestCheckpoint(spec) {
|
|
241
|
+
// Contract (narrowed, evidence-backed): a `hibernateSafe` result asserts
|
|
242
|
+
// the RESUMABILITY PRECONDITION — the recorded pane still hosts a live
|
|
243
|
+
// process generation (pane-authoritative pid, `pane_dead=0`, live pid with
|
|
244
|
+
// a readable generation token) AND the session `.jsonl` exists and is
|
|
245
|
+
// NON-EMPTY on disk. It does NOT perform a cooperative in-flight flush/ack
|
|
246
|
+
// handshake with Pi (which would require Pi-side support and is future
|
|
247
|
+
// work); the orchestrator additionally refuses to hibernate while any inbox
|
|
248
|
+
// work is pending/arriving, so a mid-turn runtime is not silently discarded.
|
|
249
|
+
const { alive, generation } = await runtimeAlive(spec);
|
|
250
|
+
const rssBytes = generation != null ? await rssBytesOf(d.runner, generation.pid) : null;
|
|
251
|
+
const pendingInboxCount = d.pendingInboxCount(spec.agentId);
|
|
252
|
+
const base = { sessionResumeRef: spec.sessionResumeRef, pendingInboxCount, rssBytes };
|
|
253
|
+
if (!alive) {
|
|
254
|
+
return { hibernateSafe: false, reason: "runtime_not_alive", ...base };
|
|
255
|
+
}
|
|
256
|
+
const resumePath = resumePathFromSessionRef(spec.sessionResumeRef);
|
|
257
|
+
const sessionBytes = resumePath != null ? d.sessionByteSize(resumePath) : null;
|
|
258
|
+
if (sessionBytes == null) {
|
|
259
|
+
return { hibernateSafe: false, reason: "session_unresumable", ...base };
|
|
260
|
+
}
|
|
261
|
+
if (sessionBytes <= 0) {
|
|
262
|
+
return { hibernateSafe: false, reason: "session_empty", ...base };
|
|
263
|
+
}
|
|
264
|
+
return { hibernateSafe: true, reason: null, ...base };
|
|
265
|
+
},
|
|
266
|
+
async stopRuntime(spec) {
|
|
267
|
+
const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
|
|
268
|
+
// Capture the live generation to stop BEFORE any mutation; if the pane is
|
|
269
|
+
// already dead / has no live generation there is nothing to stop.
|
|
270
|
+
const current = await readPaneGeneration(d.runner, addr);
|
|
271
|
+
if (current.pid == null || current.dead || current.generationToken == null) {
|
|
272
|
+
return { stopped: true, rssBytes: null };
|
|
273
|
+
}
|
|
274
|
+
if (!d.processAlive(current.pid))
|
|
275
|
+
return { stopped: true, rssBytes: null };
|
|
276
|
+
const generation = {
|
|
277
|
+
pid: current.pid,
|
|
278
|
+
generationToken: current.generationToken,
|
|
279
|
+
};
|
|
280
|
+
const rssBytes = await rssBytesOf(d.runner, generation.pid);
|
|
281
|
+
// Ensure the pane survives the Pi exit BEFORE stopping it, so hibernation
|
|
282
|
+
// requires no change to the worker spawn path. With remain-on-exit on,
|
|
283
|
+
// killing the pane's foreground Pi leaves the tmux session intact and
|
|
284
|
+
// operator-attachable, and a wake can respawn into the (now dead) pane.
|
|
285
|
+
await d.runner.run("tmux", [
|
|
286
|
+
...tmuxSocketArgs(spec.tmuxSocket),
|
|
287
|
+
"set-option",
|
|
288
|
+
"-t",
|
|
289
|
+
spec.tmuxSession,
|
|
290
|
+
"remain-on-exit",
|
|
291
|
+
"on",
|
|
292
|
+
]);
|
|
293
|
+
const stopped = await terminateGeneration(addr, generation, d);
|
|
294
|
+
return { stopped, rssBytes };
|
|
295
|
+
},
|
|
296
|
+
async isRuntimeAlive(spec) {
|
|
297
|
+
return (await runtimeAlive(spec)).alive;
|
|
298
|
+
},
|
|
299
|
+
async stopLaunchedAttempt(handle) {
|
|
300
|
+
// Bind to the generation THIS attempt carried in its handle. A handle with
|
|
301
|
+
// no generation is unprovable, so fail closed rather than risk relaunching
|
|
302
|
+
// over a live runtime.
|
|
303
|
+
const generation = launchedGenerationOf(handle);
|
|
304
|
+
if (!generation)
|
|
305
|
+
return { stopped: false };
|
|
306
|
+
const addr = {
|
|
307
|
+
tmuxSocket: generation.tmuxSocket,
|
|
308
|
+
tmuxTarget: generation.tmuxTarget,
|
|
309
|
+
};
|
|
310
|
+
return { stopped: await terminateGeneration(addr, generation, d) };
|
|
311
|
+
},
|
|
312
|
+
async isLaunchedAttemptAlive(handle) {
|
|
313
|
+
// Unprovable (no carried generation) ⇒ treat as still alive so cleanup
|
|
314
|
+
// never assumes a phantom exit.
|
|
315
|
+
const generation = launchedGenerationOf(handle);
|
|
316
|
+
if (!generation)
|
|
317
|
+
return true;
|
|
318
|
+
const addr = {
|
|
319
|
+
tmuxSocket: generation.tmuxSocket,
|
|
320
|
+
tmuxTarget: generation.tmuxTarget,
|
|
321
|
+
};
|
|
322
|
+
return paneHostsGeneration(d.runner, addr, generation, d.processAlive);
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
export function createHibernationTmuxController(deps) {
|
|
327
|
+
const runner = deps.runner ?? createExecFileRunner();
|
|
328
|
+
const writeFile = deps.writeFile ?? ((filePath, content, mode) => fs.writeFileSync(filePath, content, { mode }));
|
|
329
|
+
const unlink = deps.unlink ??
|
|
330
|
+
((filePath) => {
|
|
331
|
+
try {
|
|
332
|
+
fs.unlinkSync(filePath);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Best effort: already gone (e.g. the launcher self-deleted).
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const readEnv = deps.readEnv ?? ((key) => process.env[key]);
|
|
339
|
+
const buildNickname = deps.buildNickname ??
|
|
340
|
+
((ctx) => `Woken ${ctx.spec.launchSource || "worker"} ${ctx.reservationNonce.slice(0, 8)}`);
|
|
341
|
+
// Private, owner-only launcher dir (0700), created lazily so constructing the
|
|
342
|
+
// controller has no filesystem side effect and tests can inject an explicit dir.
|
|
343
|
+
let launcherDir = deps.launcherDir;
|
|
344
|
+
const resolveLauncherDir = () => {
|
|
345
|
+
if (launcherDir)
|
|
346
|
+
return launcherDir;
|
|
347
|
+
launcherDir = fs.mkdtempSync(path.join(os.tmpdir(), "pinet-wake-"));
|
|
348
|
+
return launcherDir;
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
async isSessionAttachable(spec) {
|
|
352
|
+
const result = await runner.run("tmux", [
|
|
353
|
+
...tmuxSocketArgs(spec.tmuxSocket),
|
|
354
|
+
"has-session",
|
|
355
|
+
"-t",
|
|
356
|
+
spec.tmuxSession,
|
|
357
|
+
]);
|
|
358
|
+
return result.code === 0;
|
|
359
|
+
},
|
|
360
|
+
async respawnRuntime(ctx) {
|
|
361
|
+
if (ctx.spec.runtimeKind !== "tmux") {
|
|
362
|
+
throw new Error("hibernation unsupported on this runtime");
|
|
363
|
+
}
|
|
364
|
+
const spec = ctx.spec;
|
|
365
|
+
const resumePath = resumePathFromSessionRef(spec.sessionResumeRef);
|
|
366
|
+
// Unresumable spec ⇒ no session to bring back; fail closed (no handle).
|
|
367
|
+
if (!resumePath)
|
|
368
|
+
return { launched: false, handle: null };
|
|
369
|
+
const addr = { tmuxSocket: spec.tmuxSocket, tmuxTarget: spec.tmuxTarget };
|
|
370
|
+
// Destructive-wake guard (independent-review P1): only respawn when the
|
|
371
|
+
// EXACT recorded pane is present AND explicitly dead. A missing pane cannot
|
|
372
|
+
// be woken; a LIVE pane must never be clobbered (drift / manual recovery /
|
|
373
|
+
// ambiguous state), so we neither force-kill it with `respawn-pane -k` nor
|
|
374
|
+
// proceed. `respawn-pane` (no `-k`) additionally refuses a non-dead pane at
|
|
375
|
+
// the tmux layer, giving defence in depth.
|
|
376
|
+
const sessionPresent = (await runner.run("tmux", [
|
|
377
|
+
...tmuxSocketArgs(spec.tmuxSocket),
|
|
378
|
+
"has-session",
|
|
379
|
+
"-t",
|
|
380
|
+
spec.tmuxSession,
|
|
381
|
+
])).code === 0;
|
|
382
|
+
if (!sessionPresent)
|
|
383
|
+
return { launched: false, handle: null };
|
|
384
|
+
const deadRaw = await paneField(runner, addr, "pane_dead");
|
|
385
|
+
if (deadRaw !== "1")
|
|
386
|
+
return { launched: false, handle: null };
|
|
387
|
+
const inheritedEnv = {};
|
|
388
|
+
for (const key of deps.inheritedEnvKeys)
|
|
389
|
+
inheritedEnv[key] = readEnv(key);
|
|
390
|
+
const pinetEnv = {
|
|
391
|
+
...deps.baseLaunchEnv,
|
|
392
|
+
...buildWakeFenceEnv({
|
|
393
|
+
wakeLeaseId: ctx.wakeLeaseId,
|
|
394
|
+
fenceToken: ctx.fenceToken,
|
|
395
|
+
reservedGeneration: ctx.reservedGeneration,
|
|
396
|
+
reservationNonce: ctx.reservationNonce,
|
|
397
|
+
correlationId: ctx.correlationId,
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
const script = buildResumeLauncherScript({
|
|
401
|
+
repoPath: spec.cwd || spec.repoRoot,
|
|
402
|
+
sessionPath: resumePath,
|
|
403
|
+
extensionEntryPath: deps.extensionEntryPath,
|
|
404
|
+
inheritedEnv,
|
|
405
|
+
pinetEnv,
|
|
406
|
+
nickname: buildNickname(ctx),
|
|
407
|
+
});
|
|
408
|
+
const launcherPath = path.join(resolveLauncherDir(), `pinet-wake-${ctx.reservationNonce}.sh`);
|
|
409
|
+
let launched = false;
|
|
410
|
+
try {
|
|
411
|
+
writeFile(launcherPath, script, 0o700);
|
|
412
|
+
// No `-k`: only a dead pane is respawned (guarded above); a live pane is
|
|
413
|
+
// never force-killed.
|
|
414
|
+
const result = await runner.run("tmux", [
|
|
415
|
+
...tmuxSocketArgs(spec.tmuxSocket),
|
|
416
|
+
"respawn-pane",
|
|
417
|
+
"-t",
|
|
418
|
+
spec.tmuxTarget,
|
|
419
|
+
launcherPath,
|
|
420
|
+
]);
|
|
421
|
+
if (result.code !== 0)
|
|
422
|
+
return { launched: false, handle: null };
|
|
423
|
+
launched = true;
|
|
424
|
+
// Bind the launched attempt to its exact process generation and carry it
|
|
425
|
+
// INSIDE the handle, so retry cleanup can prove THIS process (not a reused
|
|
426
|
+
// pid) is gone with no shared registry. If the pid/token cannot be
|
|
427
|
+
// captured, the handle carries no generation and the attempt-scoped probes
|
|
428
|
+
// fail closed (orchestrator quarantines as ambiguous).
|
|
429
|
+
const pid = await panePid(runner, addr);
|
|
430
|
+
const generationToken = pid != null ? await readGenerationToken(runner, pid) : null;
|
|
431
|
+
const base = {
|
|
432
|
+
reservationNonce: ctx.reservationNonce,
|
|
433
|
+
tmuxTarget: spec.tmuxTarget,
|
|
434
|
+
pid,
|
|
435
|
+
};
|
|
436
|
+
if (pid != null && generationToken != null) {
|
|
437
|
+
const handle = {
|
|
438
|
+
...base,
|
|
439
|
+
generation: {
|
|
440
|
+
pid,
|
|
441
|
+
generationToken,
|
|
442
|
+
tmuxSocket: spec.tmuxSocket,
|
|
443
|
+
tmuxTarget: spec.tmuxTarget,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
return { launched: true, handle };
|
|
447
|
+
}
|
|
448
|
+
return { launched: true, handle: base };
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
// Guaranteed cleanup of the secret-bearing launcher: on the happy path
|
|
452
|
+
// the script self-deletes (`rm -f "$0"` before exec), so unlinking here
|
|
453
|
+
// could race the pane shell's open — only remove the orphan on the
|
|
454
|
+
// FAILURE path (where the launcher never exec'd).
|
|
455
|
+
if (!launched)
|
|
456
|
+
unlink(launcherPath);
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Resolve the canonical git-remote-derived `owner/repo` VCS identity for a repo
|
|
463
|
+
* root at spawn time. This is the ONLY identity the repo allowlist authorizes
|
|
464
|
+
* against — derived from the runtime's actual `origin` remote, never from the
|
|
465
|
+
* filesystem directory name. Returns null when no remote is resolvable (the
|
|
466
|
+
* fail-closed authorization gate then refuses).
|
|
467
|
+
*/
|
|
468
|
+
export async function resolveVcsIdentity(repoRoot, runner = createExecFileRunner()) {
|
|
469
|
+
const result = await runner.run("git", ["-C", repoRoot, "remote", "get-url", "origin"]);
|
|
470
|
+
if (result.code !== 0)
|
|
471
|
+
return null;
|
|
472
|
+
return deriveVcsIdentity(result.stdout.trim());
|
|
473
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { AgentRuntimeSpecInput } from "@pinet/broker-core/types";
|
|
2
|
+
/** Single-quote a value for safe embedding in a POSIX shell launcher script. */
|
|
3
|
+
export declare function shellQuote(value: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Derive a canonical `owner/repo` VCS identity from a git remote URL. Supports
|
|
6
|
+
* scp-style (`git@github.com:owner/repo.git`), URL (`https://host/owner/repo`,
|
|
7
|
+
* `ssh://git@host/owner/repo`, `git://host/owner/repo`), and bare `owner/repo`
|
|
8
|
+
* forms. Returns null when no `owner/repo` can be derived.
|
|
9
|
+
*
|
|
10
|
+
* This is the ONLY identity the repo allowlist authorizes against, and it is
|
|
11
|
+
* derived from the runtime's actual git REMOTE — never from filesystem directory
|
|
12
|
+
* names — so distinct roots that merely share their final path segments (or a
|
|
13
|
+
* worktree vs. its clone) never collapse onto, or diverge from, one authorization
|
|
14
|
+
* identity.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deriveVcsIdentity(remoteUrl: string | null | undefined): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Parse resident set size (bytes) from `ps -o rss= -p <pid>` output. macOS/Linux
|
|
19
|
+
* `ps` reports RSS in kibibytes; returns null when no numeric value is present
|
|
20
|
+
* (e.g. the process already exited).
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseRssBytesFromPs(psOutput: string | null | undefined): number | null;
|
|
23
|
+
/**
|
|
24
|
+
* Build the broker-resolvable, redaction-safe session resume reference for a
|
|
25
|
+
* worker from its stable id. A worker's stable id embeds the absolute path to
|
|
26
|
+
* its Pi session `.jsonl` (`<host>:session:<path>`); the resume ref is stored as
|
|
27
|
+
* `session:<path>` so `redactRuntimeSpec` digests it to `session:#<fingerprint>`
|
|
28
|
+
* on every operator/JSON surface while the broker can still recover the path to
|
|
29
|
+
* respawn `pi --session <path>`. Returns null for stable ids without a session
|
|
30
|
+
* path (e.g. `cwd:`/`leaf:` kinds), which are not resumable.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sessionResumeRefFromStableId(stableId: string | null | undefined): string | null;
|
|
33
|
+
/** Recover the absolute session `.jsonl` path from a `session:<path>` resume ref. */
|
|
34
|
+
export declare function resumePathFromSessionRef(sessionResumeRef: string | null | undefined): string | null;
|
|
35
|
+
/** The broker-issued wake fence a respawned runtime must echo back to register. */
|
|
36
|
+
export interface WakeFence {
|
|
37
|
+
wakeLeaseId: string;
|
|
38
|
+
fenceToken: number;
|
|
39
|
+
reservedGeneration: number;
|
|
40
|
+
reservationNonce: string;
|
|
41
|
+
}
|
|
42
|
+
export interface WakeFenceEnvInput extends WakeFence {
|
|
43
|
+
correlationId: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Environment variables the respawn launcher exports so the woken runtime can
|
|
47
|
+
* present its single-use wake fence on registration. Ordinary (non-wake) spawns
|
|
48
|
+
* never set these, so ordinary registration stays fence-free and backward
|
|
49
|
+
* compatible.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildWakeFenceEnv(input: WakeFenceEnvInput): Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Parse a wake fence from a woken worker's environment (the boundary between the
|
|
54
|
+
* respawn launcher and the follower's register RPC). Returns null unless ALL
|
|
55
|
+
* required fields are present and well-formed — the numeric fence token and
|
|
56
|
+
* reserved generation must be canonical positive decimal safe integers — so a
|
|
57
|
+
* partial/garbled environment fails closed to an ordinary (fence-free)
|
|
58
|
+
* registration rather than a malformed fenced one.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseWakeFenceEnv(env: Record<string, string | undefined>): WakeFence | null;
|
|
61
|
+
/**
|
|
62
|
+
* The broker-KNOWN, spawn-authored facts that compose a durable runtime spec.
|
|
63
|
+
*
|
|
64
|
+
* Every operational field here (the tmux socket/session/target the broker will
|
|
65
|
+
* later kill and respawn into, the repo/cwd it runs `ps`/`kill`/`git` against)
|
|
66
|
+
* MUST come from the broker's own spawn record — NEVER from worker-reported
|
|
67
|
+
* registration metadata. A worker that could name another worker's tmux pane
|
|
68
|
+
* would otherwise get the broker to checkpoint/kill/respawn THAT pane on its
|
|
69
|
+
* behalf. The only field the worker's identity contributes is its stable id
|
|
70
|
+
* (which embeds its own session path); authorization uses solely the
|
|
71
|
+
* broker-derived {@link vcsIdentity}.
|
|
72
|
+
*/
|
|
73
|
+
interface SpawnAuthoredRuntimeFactsBase {
|
|
74
|
+
agentId: string;
|
|
75
|
+
stableId: string;
|
|
76
|
+
brokerOwnerId: string;
|
|
77
|
+
cwd: string;
|
|
78
|
+
repoRoot: string;
|
|
79
|
+
worktreePath: string;
|
|
80
|
+
extensionEntryPath: string;
|
|
81
|
+
/** Environment variable NAMES (never values) the launcher exports. */
|
|
82
|
+
envAllowlist: string[];
|
|
83
|
+
configFingerprint: string;
|
|
84
|
+
expectedUser: string;
|
|
85
|
+
launchSource: string;
|
|
86
|
+
/** Broker-derived from the runtime's git remote — the ONLY authz identity. */
|
|
87
|
+
vcsIdentity: string | null;
|
|
88
|
+
}
|
|
89
|
+
export type SpawnAuthoredRuntimeFacts = SpawnAuthoredRuntimeFactsBase & ({
|
|
90
|
+
/** Optional only for compatibility with existing tmux-authored callers. */
|
|
91
|
+
runtimeKind?: "tmux";
|
|
92
|
+
tmuxSocket: string;
|
|
93
|
+
tmuxSession: string;
|
|
94
|
+
tmuxTarget: string;
|
|
95
|
+
} | {
|
|
96
|
+
runtimeKind: "herdr";
|
|
97
|
+
herdrSession: string;
|
|
98
|
+
herdrConfigDir: string;
|
|
99
|
+
herdrPaneId: string;
|
|
100
|
+
herdrShellPid: number;
|
|
101
|
+
});
|
|
102
|
+
/**
|
|
103
|
+
* Compose a durable {@link AgentRuntimeSpecInput} from broker-known spawn facts.
|
|
104
|
+
*
|
|
105
|
+
* Fails closed (returns null) when the identity is not a resumable Pi session
|
|
106
|
+
* (`sessionResumeRef` cannot be derived) or any operational locator the broker
|
|
107
|
+
* must act on (tmux socket/session/target, repo root) is missing — a spec that
|
|
108
|
+
* could not be safely hibernated/woken is never recorded. `expectedHost` is
|
|
109
|
+
* taken from the stable id's host prefix; `argv` mirrors the resume launch
|
|
110
|
+
* (`pi -e <entry> --session <path>`) for provenance; the resume path itself is
|
|
111
|
+
* only ever recovered from the redaction-safe `sessionResumeRef`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function buildRuntimeSpecInput(facts: SpawnAuthoredRuntimeFacts): AgentRuntimeSpecInput | null;
|
|
114
|
+
export interface ResumeLauncherInput {
|
|
115
|
+
repoPath: string;
|
|
116
|
+
/** Absolute path to the Pi session `.jsonl` to resume. */
|
|
117
|
+
sessionPath: string;
|
|
118
|
+
extensionEntryPath: string;
|
|
119
|
+
/** Inherited env keys to re-export if present in the broker environment. */
|
|
120
|
+
inheritedEnv: Record<string, string | undefined>;
|
|
121
|
+
/** PINET_* launch env plus the wake-fence env for this attempt. */
|
|
122
|
+
pinetEnv: Record<string, string>;
|
|
123
|
+
nickname: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the launcher script a wake attempt runs via `tmux respawn-pane` to bring
|
|
127
|
+
* back the fenced runtime. It re-establishes the repo cwd and launch environment,
|
|
128
|
+
* exports the wake fence, and resumes the exact session with `pi --session
|
|
129
|
+
* <path>` (no startup prompt — the session already carries the worker's context;
|
|
130
|
+
* injecting a prompt would append a spurious user turn). The woken Pi therefore
|
|
131
|
+
* re-registers under the SAME stable id (same session path) and presents the
|
|
132
|
+
* fence for atomic generation acceptance.
|
|
133
|
+
*
|
|
134
|
+
* The script is secret-bearing (it exports mesh/Slack credential VALUES), so it
|
|
135
|
+
* removes its own file (`rm -f -- "$0"`) as its FIRST executable action — before
|
|
136
|
+
* `cd` and before exporting ANY secret. Under `set -euo pipefail` a later failure
|
|
137
|
+
* (e.g. a missing/unmounted repo making `cd` exit) therefore cannot leave a
|
|
138
|
+
* secret-bearing file behind, closing the “tmux accepted launch, launcher began,
|
|
139
|
+
* pre-delete setup failed” retention window. The launcher fd stays open across the
|
|
140
|
+
* unlink (POSIX keeps the inode until the fd closes), so `exec pi` still runs from
|
|
141
|
+
* the now-unnamed inode even though the secrets are exported after the unlink. The
|
|
142
|
+
* broker separately unlinks on the failure path (where the script never ran) and
|
|
143
|
+
* materializes launchers only in a private, owner-only directory.
|
|
144
|
+
*/
|
|
145
|
+
export declare function buildResumeLauncherScript(input: ResumeLauncherInput): string;
|
|
146
|
+
export {};
|