@join-pantheon/cli 0.29.0 → 0.30.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/README.md +45 -22
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +197 -14
- package/dist/claude-confine.js +283 -0
- package/dist/cli.js +725 -119
- package/dist/client.js +37 -2
- package/dist/command-state.js +79 -0
- package/dist/config.js +211 -10
- package/dist/doctor.js +103 -13
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +210 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +24 -0
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +507 -38
- package/dist/mcp-supervisor.js +25 -6
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +25 -10
- package/dist/network.js +39 -8
- package/dist/notify.js +17 -6
- package/dist/pending.js +8 -14
- package/dist/persona.js +21 -0
- package/dist/profile.js +4 -1
- package/dist/quickstart.js +88 -15
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1193 -185
- package/dist/roster-file.js +169 -23
- package/dist/run-failure.js +83 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +105 -36
- package/dist/setup-guide.js +28 -9
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +11 -2
- package/dist/update-check.js +2 -0
- package/dist/which.js +69 -12
- package/dist/worker-policy.js +137 -3
- package/dist/worker.js +702 -81
- package/helper/macos/menubar.swift +15 -1
- package/package.json +2 -2
package/dist/worker.js
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { spawn as procSpawn } from "node:child_process";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { chmodSync, mkdirSync, writeFileSync, existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { detectClaudeFeatures, describeFeatures, ensureRunTmp, NO_CLAUDE_FEATURES, runTmpDir } from "./claude-confine.js";
|
|
7
|
+
import { snapshotHome, restoreHome, describeChange, trustWrite } from "./home-guard.js";
|
|
6
8
|
import { PantheonClient } from "./client.js";
|
|
7
9
|
import { createHash } from "node:crypto";
|
|
8
|
-
import { headlessSpawn, materializeSpawnFiles, chooseAgent, describeChoice, unattendedRefusal, quarantineRepoAgentConfig, AGENT_EDIT_SUFFIX, REPO_AGENT_CONFIG, } from "./agents.js";
|
|
10
|
+
import { headlessSpawn, materializeSpawnFiles, chooseAgent, describeChoice, unattendedRefusal, quarantineRepoAgentConfig, AGENT_EDIT_SUFFIX, REPO_AGENT_CONFIG, COMMAND_GRANT_SCOPE, repoCommands, REPO_COMMANDS_FILE, claudeCommandRules, } from "./agents.js";
|
|
9
11
|
import { agentSpawn, CmdLineUnsafe } from "./which.js";
|
|
10
12
|
import { VERSION } from "./version.js";
|
|
11
|
-
import { applyAnswer, loadPolicy, mayAsk, needsSelfUpgrade, recordAsk, savePolicy, useGrant, } from "./worker-policy.js";
|
|
13
|
+
import { applyAnswer, CAPABILITY_ARGS, CARRY_REASON, carryOverWakers, dropLedgerGrants, isApprovedWaker, OLD_RELAY_WAKE, relayKnowsAdmitters, loadPolicy, mayAsk, needsSelfUpgrade, parseGrantAnswer, recordAsk, savePolicy, useGrant, WOKEN_RUN_ENV, } from "./worker-policy.js";
|
|
12
14
|
import { agentHookStates, repairHook } from "./notify.js";
|
|
13
15
|
import { fence, assess } from "./untrusted.js";
|
|
14
16
|
import { allowed, recordRun } from "./spend.js";
|
|
15
17
|
import { loadContinued, markContinued } from "./continued.js";
|
|
16
|
-
import { loadProfile, reservedDecisionsNote } from "./profile.js";
|
|
18
|
+
import { loadProfile, reservedDecisionsNote, saveProfile, wakeMode } from "./profile.js";
|
|
17
19
|
import { clip } from "./brief.js";
|
|
18
20
|
import { composeWorker } from "./notice.js";
|
|
19
21
|
import { diagnoseRun, gitProbe, workNeedsCommands } from "./run-failure.js";
|
|
20
22
|
import { installedTools, preferredTool } from "./notifier.js";
|
|
21
23
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
24
|
+
import { CONFIG_PATH, loadAll, updateMembershipToken } from "./config.js";
|
|
22
25
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
23
26
|
import { proxyEnvFor } from "./network.js";
|
|
27
|
+
import { signedRead } from "./reads.js";
|
|
28
|
+
import { commandState } from "./command-state.js";
|
|
24
29
|
export function heldNote() {
|
|
25
30
|
return `
|
|
26
31
|
|
|
@@ -50,6 +55,14 @@ function configuredSshCommand(cwd) {
|
|
|
50
55
|
sshCommandSeen.set(cwd, set);
|
|
51
56
|
return set;
|
|
52
57
|
}
|
|
58
|
+
export const AGENT_ENV_SECRETS = ["PANTHEON_TOKEN", "PANTHEON_PRIVKEY"];
|
|
59
|
+
export function agentBaseEnv(base, proxy) {
|
|
60
|
+
const env = { ...base, ...proxy };
|
|
61
|
+
for (const k of AGENT_ENV_SECRETS)
|
|
62
|
+
delete env[k];
|
|
63
|
+
env[WOKEN_RUN_ENV] = "1";
|
|
64
|
+
return env;
|
|
65
|
+
}
|
|
53
66
|
const GIT_TIMEOUT_MS = 60_000;
|
|
54
67
|
export const WORKTREE_ADD_TIMEOUT_MS = 15 * 60_000;
|
|
55
68
|
export const FETCH_TIMEOUT_MS = 90_000;
|
|
@@ -171,10 +184,10 @@ function planWorktree(project, scratch, goal, log, branch, base, fetch) {
|
|
|
171
184
|
const worktree = join(scratch, goal);
|
|
172
185
|
if (!isRepo(project)) {
|
|
173
186
|
if (existsSync(join(worktree, ".git")))
|
|
174
|
-
return { done: { branch, worktree, baseRef: "", baseSha: "", fetched: null } };
|
|
187
|
+
return { done: { branch, worktree, baseRef: "", baseSha: "", tipSha: "", fetched: null } };
|
|
175
188
|
mkdirSync(worktree, { recursive: true });
|
|
176
189
|
log(` scratch ${worktree} — ${project} is not a git repo, so there is no worktree and nothing to deliver`);
|
|
177
|
-
return { done: { branch: "", worktree, baseRef: "", baseSha: "", fetched: null } };
|
|
190
|
+
return { done: { branch: "", worktree, baseRef: "", baseSha: "", tipSha: "", fetched: null } };
|
|
178
191
|
}
|
|
179
192
|
const reuse = settleWorktree(project, worktree, log);
|
|
180
193
|
if (!reuse)
|
|
@@ -205,11 +218,21 @@ function planWorktree(project, scratch, goal, log, branch, base, fetch) {
|
|
|
205
218
|
catch (e) {
|
|
206
219
|
throw new Error(`${e.message.split("\n")[0]}${oldGitNote()}`);
|
|
207
220
|
}
|
|
208
|
-
const
|
|
221
|
+
const tipSha = baseSha;
|
|
222
|
+
if (reuse) {
|
|
223
|
+
try {
|
|
224
|
+
baseSha = git(worktree, "merge-base", "HEAD", tipSha) || tipSha;
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
const result = { branch, worktree, baseRef, baseSha, tipSha, fetched };
|
|
209
229
|
if (reuse)
|
|
210
230
|
return { done: result };
|
|
211
231
|
const has = git(project, "branch", "--list", "--", branch).length > 0;
|
|
212
|
-
return { add: has ? ["worktree", "add", "--", worktree, branch] : ["worktree", "add", "-b", branch, "--", worktree, baseRef], result };
|
|
232
|
+
return { add: worktreeAddArgs(has ? ["worktree", "add", "--", worktree, branch] : ["worktree", "add", "-b", branch, "--", worktree, baseRef]), result };
|
|
233
|
+
}
|
|
234
|
+
export function worktreeAddArgs(add, os = process.platform) {
|
|
235
|
+
return os === "win32" ? ["-c", "core.longpaths=true", ...add] : add;
|
|
213
236
|
}
|
|
214
237
|
function finishWorktree(r, log) {
|
|
215
238
|
const mark = checkoutMark(r.worktree);
|
|
@@ -237,10 +260,11 @@ export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/
|
|
|
237
260
|
return finishWorktree(p.result, log);
|
|
238
261
|
}
|
|
239
262
|
export async function prepareWorktreeAsync(project, scratch, goal, log, branch = `pantheon/${goal}`, base = null, opts = {}) {
|
|
240
|
-
const
|
|
241
|
-
const
|
|
263
|
+
const inRepo = isRepo(project) && safeSegment(goal) !== null;
|
|
264
|
+
const reusing = inRepo && settleWorktree(project, join(scratch, goal), log);
|
|
265
|
+
const fetched = inRepo ? await fetchOrigin(project) : undefined;
|
|
242
266
|
if (fetched && !fetched.ok)
|
|
243
|
-
log(` fetch origin not fetched (${fetched.why}) — cutting from the refs this checkout already has`);
|
|
267
|
+
log(` fetch origin not fetched (${fetched.why}) — ${reusing ? "the base branch is read as this checkout last saw it" : "cutting from the refs this checkout already has"}`);
|
|
244
268
|
const p = planWorktree(project, scratch, goal, log, branch, base, fetched ?? { ok: false, why: "not fetched" });
|
|
245
269
|
if ("done" in p)
|
|
246
270
|
return p.done;
|
|
@@ -269,6 +293,14 @@ export function branchName(space, goalId, title) {
|
|
|
269
293
|
const slug = (title ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
|
|
270
294
|
return `pantheon/${space}/${slug ? `${slug}-${goalId.slice(-4)}` : goalId}`;
|
|
271
295
|
}
|
|
296
|
+
export function commandNote(worktree, rev, log = () => { }) {
|
|
297
|
+
const repo = repoCommands(worktree, rev);
|
|
298
|
+
for (const r of repo.refused)
|
|
299
|
+
log(` commands: ignored "${r.line}" in ${REPO_COMMANDS_FILE} — ${r.why}`);
|
|
300
|
+
return `\n\nCOMMANDS FOR THIS RUN: git by subcommand (status, diff, log, add, commit, push, fetch, checkout, …), the repo's test/build/run scripts through npm/pnpm/yarn/bun, make, cargo, go, pytest, mvn/gradle and dotnet` +
|
|
301
|
+
(repo.ok.length ? `, and this repo's own list (${REPO_COMMANDS_FILE}): ${repo.ok.join(", ")}` : "") +
|
|
302
|
+
`. Anything else is refused — do not try to get around it. If a command you needed was refused, say so in your reply, and end your final message with one line per command: BLOCKED-COMMAND: <the command>`;
|
|
303
|
+
}
|
|
272
304
|
export function baseNote(baseRef, baseSha, fetched = null) {
|
|
273
305
|
if (!baseSha)
|
|
274
306
|
return "";
|
|
@@ -284,20 +316,52 @@ YOUR BRANCH STARTS AT ${baseSha.slice(0, 12)} (${baseRef}, ${fresh}).
|
|
|
284
316
|
another branch's unmerged work. When you DELIVER, name this base too (pantheon_send base=${baseSha.slice(0, 12)})
|
|
285
317
|
so your peer can check that nothing else came with it.`;
|
|
286
318
|
}
|
|
287
|
-
const RUN_DROPPINGS = [".pantheon-task.md", ".pantheon-run.log", ".pantheon-mcp.json", ".pantheon/",
|
|
319
|
+
const RUN_DROPPINGS = [".pantheon-task.md", ".pantheon-run.log", ".pantheon-mcp.json", ".pantheon/*", "!.pantheon/commands", "!.pantheon/personas/",
|
|
320
|
+
`*${AGENT_EDIT_SUFFIX}`, `*${AGENT_EDIT_SUFFIX}-*`];
|
|
321
|
+
const RUN_DROPPINGS_OLD = [".pantheon/"];
|
|
288
322
|
export function excludeRunFiles(worktree) {
|
|
289
323
|
try {
|
|
290
324
|
const common = git(worktree, "rev-parse", "--git-common-dir");
|
|
291
325
|
const file = resolve(worktree, common, "info", "exclude");
|
|
292
|
-
const
|
|
326
|
+
const had = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
327
|
+
const cur = had.split("\n").filter((l) => !RUN_DROPPINGS_OLD.includes(l)).join("\n");
|
|
293
328
|
const missing = RUN_DROPPINGS.filter((l) => !cur.split("\n").includes(l));
|
|
294
|
-
if (!missing.length)
|
|
329
|
+
if (!missing.length && cur === had)
|
|
295
330
|
return;
|
|
296
331
|
mkdirSync(resolve(worktree, common, "info"), { recursive: true });
|
|
297
|
-
writeFileSync(file, `${cur.replace(/\n?$/, "\n")}${missing.join("\n")}\n`);
|
|
332
|
+
writeFileSync(file, missing.length ? `${cur.replace(/\n?$/, "\n")}${missing.join("\n")}\n` : cur);
|
|
298
333
|
}
|
|
299
334
|
catch { }
|
|
300
335
|
}
|
|
336
|
+
export function gitOut(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
337
|
+
return new Promise((res) => {
|
|
338
|
+
let child;
|
|
339
|
+
try {
|
|
340
|
+
child = procSpawn("git", args, { cwd, env: gitEnv(process.env, proxyEnvFor(), cwd), stdio: ["ignore", "pipe", "ignore"] });
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
res(null);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
let out = "";
|
|
347
|
+
child.stdout?.on("data", (b) => { if (out.length < 100_000)
|
|
348
|
+
out += b.toString(); });
|
|
349
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); res(null); }, timeoutMs);
|
|
350
|
+
child.on("error", () => { clearTimeout(timer); res(null); });
|
|
351
|
+
child.on("close", (code) => { clearTimeout(timer); res(code === 0 ? out.trim() : null); });
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
export async function onRemote(cwd, branch, commit) {
|
|
355
|
+
if (!validBase(branch))
|
|
356
|
+
return false;
|
|
357
|
+
const line = await gitOut(cwd, ["ls-remote", "origin", `refs/heads/${branch}`], FETCH_TIMEOUT_MS);
|
|
358
|
+
const tip = line?.split(/\s+/)[0] ?? "";
|
|
359
|
+
if (!tip)
|
|
360
|
+
return false;
|
|
361
|
+
if (tip.startsWith(commit.toLowerCase()) || commit.toLowerCase().startsWith(tip))
|
|
362
|
+
return true;
|
|
363
|
+
return (await gitOut(cwd, ["merge-base", "--is-ancestor", commit, tip])) !== null;
|
|
364
|
+
}
|
|
301
365
|
export const MASS_DELETE_SHARE = 0.5;
|
|
302
366
|
export const asksForMassDelete = (text) => /\b(?:delete|remove|wipe|drop|rm)\b[^\n]{0,40}\b(?:all|every|entire|whole|most)\b/i.test(text);
|
|
303
367
|
const GIT_HOOK_NAMES = [
|
|
@@ -335,12 +399,16 @@ if [ -n "$PANTHEON_ORIG_HOOKS" ] && [ -x "$PANTHEON_ORIG_HOOKS/pre-commit" ]; th
|
|
|
335
399
|
exit 0
|
|
336
400
|
`;
|
|
337
401
|
const PRE_PUSH = `#!/bin/sh
|
|
338
|
-
# Pantheon: a woken run
|
|
402
|
+
# Pantheon: a woken run pushes its own branch and nothing else — never another
|
|
403
|
+
# branch (HEAD:main), never a deletion — and not the deletion of most of the repo.
|
|
339
404
|
${MASS_CHECK}
|
|
340
405
|
input=$(cat)
|
|
341
406
|
printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
|
|
342
407
|
[ -z "$lsha" ] && continue
|
|
343
|
-
|
|
408
|
+
if [ -z "$PANTHEON_BRANCH" ] || [ "$rref" != "refs/heads/$PANTHEON_BRANCH" ]; then
|
|
409
|
+
echo "pantheon: a woken run may push only its own branch ($PANTHEON_BRANCH), not $rref" >&2; exit 1
|
|
410
|
+
fi
|
|
411
|
+
case "$lsha" in 0000000*) echo "pantheon: a woken run may not delete a branch on the remote" >&2; exit 1;; esac
|
|
344
412
|
pantheon_mass_check "$lsha" push pushed || exit 1
|
|
345
413
|
done || exit 1
|
|
346
414
|
if [ -n "$PANTHEON_ORIG_HOOKS" ] && [ -x "$PANTHEON_ORIG_HOOKS/pre-push" ]; then printf '%s\\n' "$input" | "$PANTHEON_ORIG_HOOKS/pre-push" "$@"; exit $?; fi
|
|
@@ -390,7 +458,18 @@ function owedLine(kind) {
|
|
|
390
458
|
default: return `${printable(kind)} — the message above`;
|
|
391
459
|
}
|
|
392
460
|
}
|
|
393
|
-
export function
|
|
461
|
+
export function deliveryStep(branch, canPush, ref) {
|
|
462
|
+
const r = ref ? ` ref ${printable(ref)},` : "";
|
|
463
|
+
return canPush
|
|
464
|
+
? `If you wrote code: commit it on this branch, push it (git push origin ${branch} — this branch, never any other), then
|
|
465
|
+
pantheon_send DELIVERED with${r} branch ${branch} and the commit sha, so your human and your peer can review it.
|
|
466
|
+
If the push is refused, send the DELIVERED anyway: the worker pushes this branch after you finish, and corrects
|
|
467
|
+
the DELIVERED on the ledger if it cannot.`
|
|
468
|
+
: `This run cannot commit or push, so never send DELIVERED — your peer could not fetch it. If you wrote code,
|
|
469
|
+
pantheon_send NOTE with${r || " a ref,"} saying what you changed and that it waits on branch ${branch} on this machine
|
|
470
|
+
for your human to review and push. If the request asked for code, that NOTE is your reply.`;
|
|
471
|
+
}
|
|
472
|
+
export function buildBrief(o, trigger, branch, persona, canPush = false) {
|
|
394
473
|
const placed = branch
|
|
395
474
|
? `in an isolated git worktree on branch ${branch}`
|
|
396
475
|
: `in a scratch directory (your human's workspace is not a git repo, so there is no branch and nothing can be DELIVERED from here)`;
|
|
@@ -414,12 +493,11 @@ DO THIS
|
|
|
414
493
|
1. pantheon_claim with obligation id ${printable(o.id)}. If the claim is REFUSED, another session has it —
|
|
415
494
|
stop immediately and say nothing.
|
|
416
495
|
2. pantheon_status and, if there is a goal, pantheon_history to understand the context.
|
|
417
|
-
3. Do the work here. You may read and edit files in this worktree. Do NOT merge, do NOT push,
|
|
496
|
+
3. Do the work here. You may read and edit files in this worktree. Do NOT merge, ${branch && canPush ? `do NOT push any branch but ${branch}` : "do NOT push"},
|
|
418
497
|
do NOT touch any checkout other than this one.
|
|
419
498
|
4. Reply with pantheon_send — ANSWER an ASK, ACCEPT or OBJECT a PROPOSE, and so on.
|
|
420
499
|
5. ${branch
|
|
421
|
-
?
|
|
422
|
-
review it when they are back.`
|
|
500
|
+
? deliveryStep(branch, canPush, o.ref)
|
|
423
501
|
: `Do not promise code. There is no repo here, so nothing you write can be fetched by your
|
|
424
502
|
peer — say what you would do and leave the writing to your human.`}
|
|
425
503
|
6. If this genuinely needs your human — money, scope, anything irreversible, or one of their
|
|
@@ -428,7 +506,7 @@ DO THIS
|
|
|
428
506
|
There is no human in this session. Do not ask for approval; you will not get an answer.
|
|
429
507
|
Keep going until you have replied, then finish.`;
|
|
430
508
|
}
|
|
431
|
-
export function buildContinuationBrief(act, trigger, branch, persona) {
|
|
509
|
+
export function buildContinuationBrief(act, trigger, branch, persona, canPush = false) {
|
|
432
510
|
const placed = branch
|
|
433
511
|
? `in an isolated git worktree on branch ${branch}`
|
|
434
512
|
: `in a scratch directory (your human's workspace is not a git repo, so nothing can be DELIVERED from here)`;
|
|
@@ -464,7 +542,8 @@ DO THIS
|
|
|
464
542
|
already responded after this ${kind}, STOP and send nothing; it was handled live.
|
|
465
543
|
2. ${what}
|
|
466
544
|
3. Prefer finishing over widening: do not open a new question unless the goal cannot advance
|
|
467
|
-
without one. Every act you send may wake an agent on another machine
|
|
545
|
+
without one. Every act you send may wake an agent on another machine.${branch ? `
|
|
546
|
+
${deliveryStep(branch, canPush, null)}` : ""}
|
|
468
547
|
4. If this genuinely needs your human — money, scope, anything irreversible, or one of their
|
|
469
548
|
stated red lines — pantheon_send ESCALATE and stop.
|
|
470
549
|
|
|
@@ -473,6 +552,98 @@ Act, reply if a reply is owed, then finish.`;
|
|
|
473
552
|
}
|
|
474
553
|
export const TASK_POINTER = "Your task for this run is in the file .pantheon-task.md in the current directory. Read that file first and follow it: it is the whole brief.";
|
|
475
554
|
let fleetLock = null;
|
|
555
|
+
export const RUN_TIMEOUT_MS = 20 * 60_000;
|
|
556
|
+
export function killRunTree(pid, os = process.platform) {
|
|
557
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
558
|
+
return;
|
|
559
|
+
if (os === "win32") {
|
|
560
|
+
try {
|
|
561
|
+
execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", timeout: 10_000, windowsHide: true });
|
|
562
|
+
}
|
|
563
|
+
catch { }
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
process.kill(-pid, "SIGKILL");
|
|
568
|
+
}
|
|
569
|
+
catch { }
|
|
570
|
+
}
|
|
571
|
+
export const killTreeOnExit = (os = process.platform) => os !== "win32";
|
|
572
|
+
const liveRuns = new Set();
|
|
573
|
+
let reaperInstalled = false;
|
|
574
|
+
function installRunReaper() {
|
|
575
|
+
if (reaperInstalled)
|
|
576
|
+
return;
|
|
577
|
+
reaperInstalled = true;
|
|
578
|
+
const reap = () => { for (const pid of liveRuns)
|
|
579
|
+
killRunTree(pid); };
|
|
580
|
+
process.on("exit", reap);
|
|
581
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) {
|
|
582
|
+
process.on(sig, () => { reap(); process.exit(sig === "SIGTERM" ? 143 : 129); });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
export const RUN_RECORD = "worker-run.json";
|
|
586
|
+
function writeRunRecord(dir, r) {
|
|
587
|
+
try {
|
|
588
|
+
mkdirSync(dir, { recursive: true });
|
|
589
|
+
writeFileSync(join(dir, RUN_RECORD), JSON.stringify(r) + "\n", { mode: 0o600 });
|
|
590
|
+
}
|
|
591
|
+
catch { }
|
|
592
|
+
}
|
|
593
|
+
function clearRunRecord(dir, pid) {
|
|
594
|
+
try {
|
|
595
|
+
const f = join(dir, RUN_RECORD);
|
|
596
|
+
const r = JSON.parse(readFileSync(f, "utf8"));
|
|
597
|
+
if (r.pid === pid)
|
|
598
|
+
rmSync(f, { force: true });
|
|
599
|
+
}
|
|
600
|
+
catch { }
|
|
601
|
+
}
|
|
602
|
+
export function killRecordedRun(dir, now = Date.now()) {
|
|
603
|
+
const f = join(dir, RUN_RECORD);
|
|
604
|
+
let r;
|
|
605
|
+
try {
|
|
606
|
+
r = JSON.parse(readFileSync(f, "utf8"));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
rmSync(f, { force: true });
|
|
612
|
+
if (!r || !Number.isInteger(r.pid) || typeof r.at !== "number" || !Number.isFinite(r.at) || r.at > now + 60_000)
|
|
613
|
+
return null;
|
|
614
|
+
if (now - r.at > RUN_TIMEOUT_MS + 5 * 60_000)
|
|
615
|
+
return null;
|
|
616
|
+
if (!recordedRunAlive(r.pid))
|
|
617
|
+
return null;
|
|
618
|
+
killRunTree(r.pid);
|
|
619
|
+
return `stopped the woken run in progress (pid ${r.pid}, ${r.space}) and everything it started`;
|
|
620
|
+
}
|
|
621
|
+
export function recordedRunAlive(pid, os = process.platform, probe = (p) => { process.kill(p, 0); }) {
|
|
622
|
+
try {
|
|
623
|
+
probe(os === "win32" ? pid : -pid);
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
export function runInFlight(dir, now = Date.now()) {
|
|
631
|
+
let r;
|
|
632
|
+
try {
|
|
633
|
+
r = JSON.parse(readFileSync(join(dir, RUN_RECORD), "utf8"));
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
if (!r || !Number.isInteger(r.pid) || typeof r.at !== "number" || !Number.isFinite(r.at))
|
|
639
|
+
return null;
|
|
640
|
+
if (now - r.at > RUN_TIMEOUT_MS + 5 * 60_000)
|
|
641
|
+
return null;
|
|
642
|
+
if (!recordedRunAlive(r.pid))
|
|
643
|
+
return null;
|
|
644
|
+
return { pid: r.pid, at: r.at };
|
|
645
|
+
}
|
|
646
|
+
export const EXIT_GRACE_MS = 3_000;
|
|
476
647
|
const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
|
|
477
648
|
export function answerWindow(askSeq, hint, fullScanDue) {
|
|
478
649
|
if (askSeq !== null)
|
|
@@ -531,6 +702,13 @@ export class Worker {
|
|
|
531
702
|
for (const id of loadContinued(this.spaceKey(), this.opts.home))
|
|
532
703
|
this.contDone.add(id);
|
|
533
704
|
}
|
|
705
|
+
failuresOf(id) {
|
|
706
|
+
const mem = this.failedRuns.get(id);
|
|
707
|
+
if (mem !== undefined)
|
|
708
|
+
return mem;
|
|
709
|
+
this.loadContinued();
|
|
710
|
+
return this.contDone.has(`parked:${id}`) ? 2 : this.contDone.has(`failed1:${id}`) ? 1 : 0;
|
|
711
|
+
}
|
|
534
712
|
markDone(id) {
|
|
535
713
|
this.contDone.add(id);
|
|
536
714
|
markContinued(this.spaceKey(), id, this.opts.home);
|
|
@@ -578,17 +756,33 @@ export class Worker {
|
|
|
578
756
|
get jobs() {
|
|
579
757
|
return this.done;
|
|
580
758
|
}
|
|
581
|
-
|
|
582
|
-
runHome() {
|
|
583
|
-
if (this.runHomeDir)
|
|
584
|
-
return this.runHomeDir;
|
|
759
|
+
runHomePath() {
|
|
585
760
|
const base = this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
|
|
586
|
-
|
|
761
|
+
return join(base, "run-homes", encodeURIComponent(this.cfg.spaceId ?? this.cfg.space));
|
|
762
|
+
}
|
|
763
|
+
openRunHome() {
|
|
764
|
+
const dir = this.runHomePath();
|
|
587
765
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
588
|
-
|
|
589
|
-
this.
|
|
766
|
+
const file = join(dir, "config.json");
|
|
767
|
+
writeFileSync(file, JSON.stringify(this.cfg, null, 2) + "\n", { mode: 0o600 });
|
|
768
|
+
try {
|
|
769
|
+
chmodSync(file, 0o600);
|
|
770
|
+
}
|
|
771
|
+
catch { }
|
|
590
772
|
return dir;
|
|
591
773
|
}
|
|
774
|
+
sealRunHome(dir) {
|
|
775
|
+
const { token: _t, privateKey: _k, pendingAuth: _p, ...stub } = this.cfg;
|
|
776
|
+
try {
|
|
777
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify({ ...stub, sealed: "the token and key are written here only while a woken run holds them" }, null, 2) + "\n", { mode: 0o600 });
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
try {
|
|
781
|
+
rmSync(join(dir, "config.json"), { force: true });
|
|
782
|
+
}
|
|
783
|
+
catch { }
|
|
784
|
+
}
|
|
785
|
+
}
|
|
592
786
|
selectPersona(client, o) {
|
|
593
787
|
const personas = this.personas();
|
|
594
788
|
if (!personas.length && !this.opts.persona) {
|
|
@@ -617,7 +811,13 @@ export class Worker {
|
|
|
617
811
|
};
|
|
618
812
|
}
|
|
619
813
|
async run() {
|
|
620
|
-
const client = new PantheonClient(this.cfg,
|
|
814
|
+
const client = new PantheonClient(this.cfg, (e) => {
|
|
815
|
+
if ((e.t === "welcome" || e.t === "members") && this.client === client)
|
|
816
|
+
queueMicrotask(() => this.carryWakers(client));
|
|
817
|
+
}, {
|
|
818
|
+
role: "worker",
|
|
819
|
+
persistToken: (space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); trustWrite(CONFIG_PATH); },
|
|
820
|
+
});
|
|
621
821
|
await client.connect();
|
|
622
822
|
this.client = client;
|
|
623
823
|
const agentLabel = this.opts.agentCmd
|
|
@@ -628,11 +828,14 @@ export class Worker {
|
|
|
628
828
|
catch {
|
|
629
829
|
return "auto";
|
|
630
830
|
} })();
|
|
831
|
+
this.dropLedgerGrants();
|
|
631
832
|
this.opts.log(`worker online as ${this.cfg.member} (session ${client.session})`);
|
|
632
833
|
this.opts.log(` project ${resolve(this.opts.project)}`);
|
|
633
834
|
this.opts.log(` scratch ${resolve(this.opts.scratch)}`);
|
|
634
835
|
this.opts.log(` agent ${agentLabel}`);
|
|
635
|
-
this.opts.
|
|
836
|
+
const cmds = commandState({ home: this.opts.home, project: this.opts.project, allowBash: this.opts.allowBash });
|
|
837
|
+
this.opts.log(` policy ${this.opts.maxRunsPerHour} runs/hour, one at a time, commands ${cmds.level === "off" ? "off: edits only (bash gated)" : cmds.summary}`);
|
|
838
|
+
this.carryWakers(client);
|
|
636
839
|
const personas = this.personas();
|
|
637
840
|
if (this.opts.persona && !personaByName(personas, this.opts.persona)) {
|
|
638
841
|
client.close();
|
|
@@ -652,6 +855,12 @@ export class Worker {
|
|
|
652
855
|
this.writeState("watching", "waiting for work");
|
|
653
856
|
while (!this.stopped) {
|
|
654
857
|
const arrived = await client.next(5000);
|
|
858
|
+
if (client.refused && !this.refusedSaid) {
|
|
859
|
+
this.refusedSaid = true;
|
|
860
|
+
this.opts.log(`! the relay refused this machine (${client.refused}): ${String(client.lastError ?? "").replace(/^[A-Z_]+: /, "")} — it keeps trying, every ${Math.round(PantheonClient.REFUSED_RETRY_MS / 60_000)} minutes after the first ${Math.round(PantheonClient.REFUSED_GRACE_MS / 60_000)}`);
|
|
861
|
+
}
|
|
862
|
+
else if (!client.refused)
|
|
863
|
+
this.refusedSaid = false;
|
|
655
864
|
if (this.opts.continuations !== false)
|
|
656
865
|
this.enqueueContinuations(client, arrived);
|
|
657
866
|
const fixed = repairHook(this.opts.home);
|
|
@@ -679,17 +888,25 @@ export class Worker {
|
|
|
679
888
|
.filter((o) => o.kind !== "HUMAN" && !o.claimed_by)
|
|
680
889
|
.filter((o) => Date.now() - o.created_at >= grace)
|
|
681
890
|
.filter((o) => {
|
|
682
|
-
if (
|
|
891
|
+
if (this.failuresOf(o.id) < 2)
|
|
683
892
|
return true;
|
|
684
893
|
if (!this.skipLogged.has(`parked:${o.id}`)) {
|
|
685
894
|
this.skipLogged.add(`parked:${o.id}`);
|
|
686
|
-
this.opts.log(`- parking ${o.kind} ${o.id}:
|
|
895
|
+
this.opts.log(`- parking ${o.kind} ${o.id}: its run failed in a way another run would repeat — it stays owed for a live session or your human (see the last .pantheon-run.log)`);
|
|
687
896
|
}
|
|
688
897
|
return false;
|
|
689
898
|
});
|
|
690
899
|
let candidate;
|
|
691
900
|
let persona;
|
|
692
901
|
for (const o of aged) {
|
|
902
|
+
const refusedWake = await this.wakeRefusal(client, o.ref);
|
|
903
|
+
if (refusedWake) {
|
|
904
|
+
if (!this.skipLogged.has(`wake:${o.id}`)) {
|
|
905
|
+
this.skipLogged.add(`wake:${o.id}`);
|
|
906
|
+
this.opts.log(`- not taking ${o.kind} ${o.id}: ${refusedWake.why} — it waits for a live session`);
|
|
907
|
+
}
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
693
910
|
const sel = this.selectPersona(client, o);
|
|
694
911
|
if (sel.skip) {
|
|
695
912
|
if (!this.skipLogged.has(o.id)) {
|
|
@@ -705,7 +922,19 @@ export class Worker {
|
|
|
705
922
|
let continuation;
|
|
706
923
|
if (!candidate) {
|
|
707
924
|
const pick = this.nextContinuation(client);
|
|
708
|
-
|
|
925
|
+
const refusedWake = pick ? await this.wakeRefusal(client, pick.continuation.id, { from: pick.continuation.from, role: pick.continuation.role }) : null;
|
|
926
|
+
if (pick && refusedWake?.transient) {
|
|
927
|
+
this.requeueContinuation(pick.continuation, 30_000);
|
|
928
|
+
if (!this.skipLogged.has(`wait:${pick.continuation.id}`)) {
|
|
929
|
+
this.skipLogged.add(`wait:${pick.continuation.id}`);
|
|
930
|
+
this.opts.log(`- holding ${pick.continuation.act} ${pick.continuation.id}: ${refusedWake.why} — asking again shortly`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
else if (pick && refusedWake) {
|
|
934
|
+
this.markDone(pick.continuation.id);
|
|
935
|
+
this.opts.log(`- not continuing from ${pick.continuation.act} ${pick.continuation.id}: ${refusedWake.why}`);
|
|
936
|
+
}
|
|
937
|
+
else if (pick)
|
|
709
938
|
({ continuation, persona } = pick);
|
|
710
939
|
}
|
|
711
940
|
if (!candidate && !continuation) {
|
|
@@ -800,13 +1029,15 @@ export class Worker {
|
|
|
800
1029
|
await this.reportRefused(client, job.obligation, job.diagnosis, !!candidate);
|
|
801
1030
|
}
|
|
802
1031
|
else if (candidate && !job.ok) {
|
|
803
|
-
const
|
|
1032
|
+
const attempts = this.failuresOf(candidate.id) + 1;
|
|
1033
|
+
const failures = job.diagnosis?.cause === "no_commands" ? 2 : attempts;
|
|
804
1034
|
this.failedRuns.set(candidate.id, failures);
|
|
1035
|
+
this.markDone(failures >= 2 ? `parked:${candidate.id}` : `failed1:${candidate.id}`);
|
|
805
1036
|
if (this.failedRuns.size > 200) {
|
|
806
1037
|
this.failedRuns.delete(this.failedRuns.keys().next().value);
|
|
807
1038
|
}
|
|
808
1039
|
if (failures >= 2 && job.diagnosis)
|
|
809
|
-
await this.reportStuck(client, candidate, job.diagnosis);
|
|
1040
|
+
await this.reportStuck(client, candidate, job.diagnosis, attempts);
|
|
810
1041
|
}
|
|
811
1042
|
this.running = false;
|
|
812
1043
|
if (fleetLock === this)
|
|
@@ -819,6 +1050,23 @@ export class Worker {
|
|
|
819
1050
|
}
|
|
820
1051
|
stop() {
|
|
821
1052
|
this.stopped = true;
|
|
1053
|
+
for (const pid of this.runPids)
|
|
1054
|
+
killRunTree(pid);
|
|
1055
|
+
}
|
|
1056
|
+
runPids = new Set();
|
|
1057
|
+
trackRun(pid) {
|
|
1058
|
+
this.runPids.add(pid);
|
|
1059
|
+
liveRuns.add(pid);
|
|
1060
|
+
installRunReaper();
|
|
1061
|
+
writeRunRecord(this.stateDir(), { pid, member: this.cfg.member, space: this.cfg.space, at: Date.now() });
|
|
1062
|
+
}
|
|
1063
|
+
untrackRun(pid) {
|
|
1064
|
+
this.runPids.delete(pid);
|
|
1065
|
+
liveRuns.delete(pid);
|
|
1066
|
+
clearRunRecord(this.stateDir(), pid);
|
|
1067
|
+
}
|
|
1068
|
+
stateDir() {
|
|
1069
|
+
return this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
|
|
822
1070
|
}
|
|
823
1071
|
hooksInstalledHere() {
|
|
824
1072
|
try {
|
|
@@ -836,7 +1084,7 @@ export class Worker {
|
|
|
836
1084
|
?? `(the triggering act ${printable(o.ref)} is not in this session's buffer; the relay's one-line summary of it follows)\n${o.body}`;
|
|
837
1085
|
const act = client.seenAct(o.ref);
|
|
838
1086
|
const needs = workNeedsCommands(o.kind, trigger, o.kind === "REVISE" && act?.ref ? client.seenAct(act.ref)?.act : act?.act);
|
|
839
|
-
return this.runJob(client, o, persona, policy, (branch) => buildBrief(o, trigger, branch, persona), true, needs, trigger);
|
|
1087
|
+
return this.runJob(client, o, persona, policy, (branch, canPush) => buildBrief(o, trigger, branch, persona, canPush), true, needs, trigger);
|
|
840
1088
|
}
|
|
841
1089
|
async handleContinuation(client, act, persona, policy) {
|
|
842
1090
|
this.markDone(act.id);
|
|
@@ -850,7 +1098,7 @@ export class Worker {
|
|
|
850
1098
|
this.client?.reportWorkerState("running", `${act.act} ${act.id}`);
|
|
851
1099
|
const trigger = client.recent(act.id) ?? `${act.act} from ${act.from} id=${act.id}\n${act.body}`;
|
|
852
1100
|
const needs = workNeedsCommands("CONTINUE", trigger, act.act);
|
|
853
|
-
return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false, needs, trigger);
|
|
1101
|
+
return this.runJob(client, o, persona, policy, (branch, canPush) => buildContinuationBrief(act, trigger, branch, persona, canPush), false, needs, trigger);
|
|
854
1102
|
}
|
|
855
1103
|
commandGrant(persona) {
|
|
856
1104
|
if (persona?.allowBash === false)
|
|
@@ -899,30 +1147,50 @@ export class Worker {
|
|
|
899
1147
|
if (this.opts.allowBash && !policy.allowBash && persona) {
|
|
900
1148
|
this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
|
|
901
1149
|
}
|
|
902
|
-
|
|
903
|
-
let baseRef = "", baseSha = "";
|
|
1150
|
+
let allowBash = policy.allowBash || this.commandGrant(persona);
|
|
1151
|
+
let baseRef = "", baseSha = "", tipSha = "";
|
|
904
1152
|
let fetched = null;
|
|
905
1153
|
try {
|
|
906
|
-
({ branch, worktree, baseRef, baseSha, fetched } = await prepareWorktreeAsync(this.opts.project, this.opts.scratch, goal, this.opts.log, branch, this.repoBase()));
|
|
1154
|
+
({ branch, worktree, baseRef, baseSha, tipSha, fetched } = await prepareWorktreeAsync(this.opts.project, this.opts.scratch, goal, this.opts.log, branch, this.repoBase()));
|
|
907
1155
|
}
|
|
908
1156
|
catch (e) {
|
|
909
1157
|
const detail = `could not prepare a worktree: ${e.message}`;
|
|
910
1158
|
this.opts.log(`! ${detail}`);
|
|
911
1159
|
return { obligation: o, branch, worktree, ok: false, detail };
|
|
912
1160
|
}
|
|
1161
|
+
const agentId = this.opts.agentCmd ? "claude-code" : choice?.backend.id;
|
|
1162
|
+
const binary = this.opts.agentCmd ?? choice?.binary ?? "claude";
|
|
1163
|
+
const features = agentId === "claude-code"
|
|
1164
|
+
? (this.opts.claudeFeatures ?? (this.opts.agentCmd ? NO_CLAUDE_FEATURES : detectClaudeFeatures(binary)))
|
|
1165
|
+
: null;
|
|
1166
|
+
if (features) {
|
|
1167
|
+
const said = describeFeatures(features);
|
|
1168
|
+
if (said !== this.lastFeaturesSaid) {
|
|
1169
|
+
this.opts.log(` claude ${said}`);
|
|
1170
|
+
this.lastFeaturesSaid = said;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (allowBash && features?.unknown) {
|
|
1174
|
+
allowBash = false;
|
|
1175
|
+
this.opts.log(` bash off for this run — \`${binary} --help\` did not answer, so its sandbox and deny rules cannot be confirmed`);
|
|
1176
|
+
}
|
|
1177
|
+
const holdRepoConfig = !allowBash && !(features?.settingSources);
|
|
1178
|
+
const canPush = allowBash && !!branch;
|
|
913
1179
|
const reserved = reservedDecisionsNote(loadProfile(this.cfg));
|
|
914
|
-
const brief = makeBrief(branch) + baseNote(baseRef, baseSha, fetched) +
|
|
1180
|
+
const brief = makeBrief(branch, canPush) + baseNote(baseRef, baseSha, fetched) +
|
|
1181
|
+
(allowBash ? (agentId === "claude-code" ? commandNote(worktree, tipSha || baseSha, (m) => this.opts.log(m)) : "") : holdRepoConfig ? heldNote() : "") +
|
|
1182
|
+
(reserved ? `\n\n${reserved}` : "");
|
|
915
1183
|
const startedAt = Date.now();
|
|
916
1184
|
writeFileSync(join(worktree, ".pantheon-task.md"), brief);
|
|
917
1185
|
let command;
|
|
918
|
-
const agentId = this.opts.agentCmd ? "claude-code" : choice?.backend.id;
|
|
919
1186
|
let args;
|
|
920
|
-
let env =
|
|
1187
|
+
let env = agentBaseEnv(process.env, proxyEnvFor());
|
|
921
1188
|
let sp;
|
|
1189
|
+
let bashRules = [];
|
|
922
1190
|
let fenced = null;
|
|
1191
|
+
const runHome = this.openRunHome();
|
|
923
1192
|
try {
|
|
924
|
-
|
|
925
|
-
if (!allowBash) {
|
|
1193
|
+
if (holdRepoConfig) {
|
|
926
1194
|
fenced = quarantineRepoAgentConfig(worktree, runHome);
|
|
927
1195
|
if (fenced.moved.length)
|
|
928
1196
|
this.opts.log(` config moved the repo's agent config aside for this edits-only run: ${fenced.moved.join(", ")}`);
|
|
@@ -942,11 +1210,20 @@ export class Worker {
|
|
|
942
1210
|
this.opts.log(` config ${l}`);
|
|
943
1211
|
}
|
|
944
1212
|
const backend = this.opts.agentCmd ? { id: "claude-code", label: "Claude Code", binaries: ["claude"] } : choice.backend;
|
|
945
|
-
const
|
|
1213
|
+
const personHome = this.opts.personHome ?? homedir();
|
|
1214
|
+
if (features && allowBash && !ensureRunTmp(runTmpDir(runHome))) {
|
|
1215
|
+
throw new Error(`the run's temp dir ${runTmpDir(runHome)} is not this user's own folder (another user's, or a link) — a command run is not started with it`);
|
|
1216
|
+
}
|
|
946
1217
|
const plan = headlessSpawn({
|
|
947
1218
|
backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome,
|
|
948
|
-
...(baseSha ? { base: baseSha } : {}), goal,
|
|
1219
|
+
...(baseSha ? { base: baseSha } : {}), ...(tipSha ? { commandsRev: tipSha } : {}), goal,
|
|
1220
|
+
...(features ? { claude: { features, pantheonHome: this.stateDir(), home: personHome, userSettings: join(personHome, ".claude", "settings.json") } } : {}),
|
|
949
1221
|
});
|
|
1222
|
+
for (const n of plan.notes ?? [])
|
|
1223
|
+
if (!this.notesSaid.has(n)) {
|
|
1224
|
+
this.notesSaid.add(n);
|
|
1225
|
+
this.opts.log(` confine ${n}`);
|
|
1226
|
+
}
|
|
950
1227
|
materializeSpawnFiles(worktree, plan);
|
|
951
1228
|
for (const f of plan.writeFiles ?? []) {
|
|
952
1229
|
const rel = isAbsolute(f.path) ? relative(worktree, f.path) : f.path;
|
|
@@ -954,6 +1231,7 @@ export class Worker {
|
|
|
954
1231
|
fenced?.wrote(rel.split("\\").join("/"));
|
|
955
1232
|
}
|
|
956
1233
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
1234
|
+
bashRules = allowBash && agentId === "claude-code" ? claudeCommandRules(repoCommands(worktree, tipSha || baseSha).ok) : [];
|
|
957
1235
|
if (allowBash && baseSha)
|
|
958
1236
|
env = { ...env, ...massDeleteGuardEnv(runHome, worktree, asksForMassDelete(trigger), env) };
|
|
959
1237
|
if (!this.opts.agentCmd)
|
|
@@ -973,23 +1251,28 @@ export class Worker {
|
|
|
973
1251
|
if (fenced)
|
|
974
1252
|
for (const l of fenced.restore().lines)
|
|
975
1253
|
this.opts.log(` config ${l}`);
|
|
1254
|
+
this.sealRunHome(runHome);
|
|
976
1255
|
const detail = `could not plan the run: ${e.message}`;
|
|
977
1256
|
this.opts.log(`! ${detail}`);
|
|
978
|
-
recordRun({ at: Date.now(), goal: o.goal, obligation: o.id, ok: false, triggeredBy:
|
|
1257
|
+
recordRun({ at: Date.now(), goal: o.goal, obligation: o.id, ok: false, triggeredBy: (await this.actOf(client, o.ref))?.from ?? "unknown", summary: clip(o.body), space: this.spaceKey(), member: this.cfg.member }, this.opts.home);
|
|
979
1258
|
return { obligation: o, branch, worktree, ok: false, detail };
|
|
980
1259
|
}
|
|
981
1260
|
let detail;
|
|
982
1261
|
let transcript = "";
|
|
1262
|
+
const homeBefore = snapshotHome(this.stateDir());
|
|
983
1263
|
try {
|
|
984
1264
|
detail = await new Promise((res) => {
|
|
985
1265
|
let child;
|
|
986
1266
|
try {
|
|
987
|
-
child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options });
|
|
1267
|
+
child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, detached: process.platform !== "win32" });
|
|
988
1268
|
}
|
|
989
1269
|
catch (e) {
|
|
990
1270
|
res(`could not start "${command}": ${e.message}`);
|
|
991
1271
|
return;
|
|
992
1272
|
}
|
|
1273
|
+
const pid = child.pid;
|
|
1274
|
+
if (pid)
|
|
1275
|
+
this.trackRun(pid);
|
|
993
1276
|
let head = "", tail = "";
|
|
994
1277
|
const take = (b) => {
|
|
995
1278
|
const s = b.toString();
|
|
@@ -999,9 +1282,46 @@ export class Worker {
|
|
|
999
1282
|
};
|
|
1000
1283
|
child.stdout.on("data", take);
|
|
1001
1284
|
child.stderr.on("data", take);
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1285
|
+
let timedOut = false;
|
|
1286
|
+
let dead = false;
|
|
1287
|
+
const markDead = () => { if (dead)
|
|
1288
|
+
return; dead = true; if (pid)
|
|
1289
|
+
this.untrackRun(pid); };
|
|
1290
|
+
let settled = false;
|
|
1291
|
+
let exitGrace = null;
|
|
1292
|
+
let exitCode = null;
|
|
1293
|
+
const limit = this.opts.runTimeoutMs ?? RUN_TIMEOUT_MS;
|
|
1294
|
+
const finish = (code, signal) => {
|
|
1295
|
+
if (settled)
|
|
1296
|
+
return;
|
|
1297
|
+
settled = true;
|
|
1298
|
+
clearTimeout(kill);
|
|
1299
|
+
if (exitGrace)
|
|
1300
|
+
clearTimeout(exitGrace);
|
|
1301
|
+
markDead();
|
|
1302
|
+
transcript = `${head}\n${tail}`;
|
|
1303
|
+
res(`exit ${code ?? signal}${timedOut ? ` (stopped after ${Math.round(limit / 1000)}s)` : ""}\n${tail.slice(-4000)}`);
|
|
1304
|
+
};
|
|
1305
|
+
const kill = setTimeout(() => { if (dead)
|
|
1306
|
+
return; timedOut = true; if (pid)
|
|
1307
|
+
killRunTree(pid); }, limit);
|
|
1308
|
+
child.on("exit", (code, signal) => {
|
|
1309
|
+
clearTimeout(kill);
|
|
1310
|
+
if (pid && !dead && killTreeOnExit())
|
|
1311
|
+
killRunTree(pid);
|
|
1312
|
+
markDead();
|
|
1313
|
+
const c = child;
|
|
1314
|
+
exitCode = { code, signal };
|
|
1315
|
+
exitGrace = setTimeout(() => { try {
|
|
1316
|
+
c.stdout.destroy();
|
|
1317
|
+
c.stderr.destroy();
|
|
1318
|
+
}
|
|
1319
|
+
catch { } finish(code, signal); }, this.opts.exitGraceMs ?? EXIT_GRACE_MS);
|
|
1320
|
+
});
|
|
1321
|
+
child.on("close", (code, signal) => finish(exitCode?.code ?? code, exitCode?.signal ?? signal));
|
|
1322
|
+
child.on("error", (e) => { if (settled)
|
|
1323
|
+
return; settled = true; clearTimeout(kill); if (exitGrace)
|
|
1324
|
+
clearTimeout(exitGrace); markDead(); res(`could not start "${command}": ${e.message}`); });
|
|
1005
1325
|
});
|
|
1006
1326
|
}
|
|
1007
1327
|
finally {
|
|
@@ -1010,29 +1330,39 @@ export class Worker {
|
|
|
1010
1330
|
for (const l of r.lines)
|
|
1011
1331
|
this.opts.log(` config ${l}`);
|
|
1012
1332
|
}
|
|
1333
|
+
this.sealRunHome(runHome);
|
|
1334
|
+
this.restoreHomeAfterRun(homeBefore, o);
|
|
1013
1335
|
}
|
|
1014
1336
|
const runLog = join(worktree, ".pantheon-run.log");
|
|
1015
1337
|
let extra = "";
|
|
1338
|
+
let massDelete = false;
|
|
1016
1339
|
if (allowBash && baseSha) {
|
|
1017
1340
|
const d = deletedSinceBase(worktree, baseSha);
|
|
1018
1341
|
if (d && d.total >= 4 && d.gone > d.total * MASS_DELETE_SHARE) {
|
|
1342
|
+
massDelete = true;
|
|
1019
1343
|
extra = `\n! this branch deletes ${d.gone} of the ${d.total} files tracked at its base — do not merge it without looking`;
|
|
1020
1344
|
this.opts.log(`! ${o.id}: the branch ${branch} deletes ${d.gone} of the ${d.total} files tracked at ${baseSha.slice(0, 7)} — do not merge it without looking`);
|
|
1021
1345
|
}
|
|
1022
1346
|
}
|
|
1023
1347
|
writeFileSync(runLog, `obligation ${o.id}\nbranch ${branch}\n\n${detail}\n${extra}`);
|
|
1024
1348
|
const exited = /^exit 0/.test(detail);
|
|
1025
|
-
const
|
|
1349
|
+
const sent = client.mineSince(startedAt, o.goal);
|
|
1350
|
+
const repliedWith = sent.find((a) => ["DELIVERED", "ANSWER", "ESCALATE", "ACCEPT", "OBJECT", "PROPOSE", "CONTRACT", "CLOSE", "WITHDRAW"].includes(a.act) || (a.act === "NOTE" && !!a.ref));
|
|
1351
|
+
const replied = requireReply ? client.repliedTo(o) || !!repliedWith : true;
|
|
1352
|
+
if (requireReply && replied && !client.repliedTo(o) && repliedWith) {
|
|
1353
|
+
this.opts.log(` reply ${repliedWith.act} ${repliedWith.id} answers ${o.id}, though the relay still lists it as owed${repliedWith.ref ? "" : " (the act named no ref)"}`);
|
|
1354
|
+
}
|
|
1026
1355
|
const ok = exited && replied;
|
|
1027
|
-
|
|
1356
|
+
await this.checkDeliveries(client, sent, { branch, worktree, canPush: canPush && !massDelete, goal: o.goal ?? null });
|
|
1357
|
+
const triggeredBy = (await this.actOf(client, o.ref))?.from ?? "unknown";
|
|
1028
1358
|
const summary = clip(o.body);
|
|
1029
1359
|
recordRun({
|
|
1030
1360
|
at: Date.now(), goal: o.goal, obligation: o.id, ok,
|
|
1031
|
-
triggeredBy, summary,
|
|
1361
|
+
triggeredBy, summary, space: this.spaceKey(), member: this.cfg.member,
|
|
1032
1362
|
}, this.opts.home);
|
|
1033
|
-
const sent = client.mineSince(startedAt, o.goal);
|
|
1034
1363
|
const quiet = ok && !requireReply && sent.length === 0;
|
|
1035
|
-
const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, transcript, runLog, needsCommands, ...(agentId ? { agent: agentId } : {})
|
|
1364
|
+
const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, transcript, runLog, needsCommands, ...(agentId ? { agent: agentId } : {}),
|
|
1365
|
+
...(agentId === "claude-code" && allowBash ? { allowedRules: bashRules } : {}), ...(baseRef ? { baseRef } : {}) }, this.opts.probe ?? gitProbe);
|
|
1036
1366
|
if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
|
|
1037
1367
|
try {
|
|
1038
1368
|
const reply = sent[sent.length - 1] ?? null;
|
|
@@ -1045,6 +1375,92 @@ export class Worker {
|
|
|
1045
1375
|
: `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
|
|
1046
1376
|
return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
|
|
1047
1377
|
}
|
|
1378
|
+
lastFeaturesSaid = "";
|
|
1379
|
+
notesSaid = new Set();
|
|
1380
|
+
restoreHomeAfterRun(before, o) {
|
|
1381
|
+
let changes = [];
|
|
1382
|
+
try {
|
|
1383
|
+
changes = restoreHome(before);
|
|
1384
|
+
}
|
|
1385
|
+
catch (e) {
|
|
1386
|
+
this.opts.log(`! could not check Pantheon's own files after the run: ${e.message}`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
if (!changes.length)
|
|
1390
|
+
return;
|
|
1391
|
+
for (const c of changes)
|
|
1392
|
+
this.opts.log(`${c.what === "reported" ? " " : "!"} ${o.id}: ${describeChange(c)}`);
|
|
1393
|
+
const taken = changes.filter((c) => c.what !== "reported");
|
|
1394
|
+
if (taken.length && this.opts.notify) {
|
|
1395
|
+
try {
|
|
1396
|
+
this.opts.notify({
|
|
1397
|
+
kind: "worker", who: "Your worker", heading: "A woken run tried to widen what the next run may do", context: this.cfg.space,
|
|
1398
|
+
body: `${taken.map(describeChange).join(" ")} None of it took effect.`,
|
|
1399
|
+
ttl: 0, sound: true,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
catch { }
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
async checkDeliveries(client, sent, run) {
|
|
1406
|
+
const deliveries = sent.filter((a) => a.act === "DELIVERED");
|
|
1407
|
+
if (!deliveries.length || !run.branch)
|
|
1408
|
+
return;
|
|
1409
|
+
const origin = await gitOut(this.opts.project, ["remote", "get-url", "origin"]);
|
|
1410
|
+
if (origin === null)
|
|
1411
|
+
return;
|
|
1412
|
+
for (const d of deliveries) {
|
|
1413
|
+
const m = (d.meta ?? {});
|
|
1414
|
+
const br = typeof m.branch === "string" ? m.branch : "";
|
|
1415
|
+
const commit = typeof m.commit === "string" && /^[0-9a-f]{7,64}$/i.test(m.commit) ? m.commit : "";
|
|
1416
|
+
if (!br || !commit)
|
|
1417
|
+
continue;
|
|
1418
|
+
if (typeof m.remote === "string" && m.remote && m.remote !== origin)
|
|
1419
|
+
continue;
|
|
1420
|
+
if (await onRemote(run.worktree, br, commit))
|
|
1421
|
+
continue;
|
|
1422
|
+
let pushed = false;
|
|
1423
|
+
if (run.canPush && br === run.branch && validBase(br)
|
|
1424
|
+
&& (await gitOut(run.worktree, ["merge-base", "--is-ancestor", commit, `refs/heads/${br}`])) !== null) {
|
|
1425
|
+
const r = await gitOut(run.worktree, ["push", "--quiet", "origin", `refs/heads/${br}:refs/heads/${br}`], FETCH_TIMEOUT_MS);
|
|
1426
|
+
pushed = r !== null && await onRemote(run.worktree, br, commit);
|
|
1427
|
+
if (pushed)
|
|
1428
|
+
this.opts.log(`* pushed ${br} for the run: its DELIVERED ${d.id} named ${commit.slice(0, 7)}, which origin did not have`);
|
|
1429
|
+
}
|
|
1430
|
+
if (pushed)
|
|
1431
|
+
continue;
|
|
1432
|
+
const why = br !== run.branch ? `it names ${br}, not this run's own branch ${run.branch}, which the worker will not push`
|
|
1433
|
+
: !run.canPush ? "this run was not allowed to run commands, so nothing could be pushed"
|
|
1434
|
+
: "the worker could not push it either";
|
|
1435
|
+
this.opts.log(`! DELIVERED ${d.id} names ${br} at ${commit.slice(0, 7)}, which is not on origin — ${why}`);
|
|
1436
|
+
try {
|
|
1437
|
+
await client.send({
|
|
1438
|
+
act: "NOTE", goal: d.goal ?? run.goal, ref: d.id, after_seq: Math.max(client.readSeq, client.seq),
|
|
1439
|
+
...(d.to?.length ? { to: d.to } : {}),
|
|
1440
|
+
summary: clip(`Correction: that delivery cannot be fetched yet — ${br} at ${commit.slice(0, 7)} is not on origin`, 190),
|
|
1441
|
+
body: [
|
|
1442
|
+
`Correction to DELIVERED ${d.id}: ${br} at ${commit.slice(0, 12)} is not on origin, so \`git fetch origin ${br}\` will not find it.`,
|
|
1443
|
+
`Why: ${why}. The work is on this machine, on that branch; ${this.cfg.member}'s human has been told and will push it or say what happened.`,
|
|
1444
|
+
`Do not ACCEPT it until the branch is fetchable.`,
|
|
1445
|
+
].join("\n"),
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
catch (e) {
|
|
1449
|
+
this.opts.log(`! could not correct the unpushed DELIVERED on the ledger: ${e.message}`);
|
|
1450
|
+
}
|
|
1451
|
+
if (this.opts.notify) {
|
|
1452
|
+
try {
|
|
1453
|
+
this.opts.notify({
|
|
1454
|
+
kind: "worker", who: "Your worker", heading: "A delivery was not pushed", context: this.cfg.space,
|
|
1455
|
+
body: `A woken run sent DELIVERED for ${br} at ${commit.slice(0, 7)}, but origin does not have it (${why}). Your teammate was told. Push it yourself: git push origin ${br}`,
|
|
1456
|
+
ttl: 0, sound: false,
|
|
1457
|
+
fix: { label: "Push it", dir: run.worktree, lines: [`git push origin ${br}`], why: `DELIVERED ${d.id} is not on origin` },
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
catch { }
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1048
1464
|
refusalsNotified = new Set();
|
|
1049
1465
|
async reportRefused(client, o, d, owed) {
|
|
1050
1466
|
if (this.opts.notify && !this.refusalsNotified.has(d.says)) {
|
|
@@ -1078,7 +1494,7 @@ export class Worker {
|
|
|
1078
1494
|
this.opts.log(`! could not tell the teammate that ${o.id} is waiting: ${e.message}`);
|
|
1079
1495
|
}
|
|
1080
1496
|
}
|
|
1081
|
-
async reportStuck(client, o, d) {
|
|
1497
|
+
async reportStuck(client, o, d, attempts) {
|
|
1082
1498
|
const key = `stuck:${o.id}`;
|
|
1083
1499
|
const space = this.spaceKey();
|
|
1084
1500
|
if (loadContinued(space, this.opts.home).has(key))
|
|
@@ -1087,7 +1503,7 @@ export class Worker {
|
|
|
1087
1503
|
const asking = !!d.capability && mayAsk(policy, d.capability);
|
|
1088
1504
|
const what = clip(o.body, 120);
|
|
1089
1505
|
const lines = [
|
|
1090
|
-
|
|
1506
|
+
`${attempts === 1 ? "A run" : `${attempts === 2 ? "Two" : attempts} runs`} on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
|
|
1091
1507
|
``,
|
|
1092
1508
|
`WHY IT STOPPED. ${d.says}`,
|
|
1093
1509
|
`WHAT UNBLOCKS IT. ${d.fix}`,
|
|
@@ -1097,13 +1513,13 @@ export class Worker {
|
|
|
1097
1513
|
`It will not be retried unattended — a live session here, or you, has to move it.`,
|
|
1098
1514
|
];
|
|
1099
1515
|
if (asking) {
|
|
1100
|
-
lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push.
|
|
1516
|
+
lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. What allowing it permits: ${COMMAND_GRANT_SCOPE}`, `A teammate this machine lets wake it can start such a run.`, `Answer it on this machine — the menu bar, or: pantheon approvals answer <this-decision-id> 'allow always'`, `(or: pantheon policy grant run-commands). An answer sent from anywhere else settles this decision but grants nothing.`);
|
|
1101
1517
|
}
|
|
1102
1518
|
try {
|
|
1103
1519
|
const sent = await client.send({
|
|
1104
|
-
act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
|
|
1105
|
-
summary: clip(`Stuck on you: ${d.says}
|
|
1106
|
-
body: lines.join("\n"),
|
|
1520
|
+
act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: Math.max(client.readSeq, client.seq),
|
|
1521
|
+
summary: clip(this.ledgerPaths(`Stuck on you: ${d.says}`), 190),
|
|
1522
|
+
body: this.ledgerPaths(lines.join("\n")),
|
|
1107
1523
|
});
|
|
1108
1524
|
markContinued(space, key, this.opts.home);
|
|
1109
1525
|
if (asking && d.capability) {
|
|
@@ -1116,6 +1532,31 @@ export class Worker {
|
|
|
1116
1532
|
this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
|
|
1117
1533
|
}
|
|
1118
1534
|
}
|
|
1535
|
+
ledgerPaths(text) {
|
|
1536
|
+
const swaps = [];
|
|
1537
|
+
const proj = resolve(this.opts.project);
|
|
1538
|
+
swaps.push([`${proj}${sep}`, ""], [proj, "the repo's own checkout"]);
|
|
1539
|
+
const scratch = resolve(this.opts.scratch);
|
|
1540
|
+
if (!scratch.startsWith(`${proj}${sep}`))
|
|
1541
|
+
swaps.push([`${scratch}${sep}`, "<worktrees>/"]);
|
|
1542
|
+
const home = this.opts.personHome ?? homedir();
|
|
1543
|
+
if (home && home !== sep)
|
|
1544
|
+
swaps.push([`${home}${sep}`, "~/"], [home, "~"]);
|
|
1545
|
+
const win = process.platform === "win32";
|
|
1546
|
+
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1547
|
+
let out = text;
|
|
1548
|
+
for (const [from, to] of swaps) {
|
|
1549
|
+
let real = from;
|
|
1550
|
+
try {
|
|
1551
|
+
real = realpathSync(from.endsWith(sep) ? from.slice(0, -1) : from) + (from.endsWith(sep) ? sep : "");
|
|
1552
|
+
}
|
|
1553
|
+
catch { }
|
|
1554
|
+
for (const v of new Set([from, real, from.replace(/\\/g, "/"), real.replace(/\\/g, "/")])) {
|
|
1555
|
+
out = out.replace(new RegExp(esc(v), win ? "gi" : "g"), () => to);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return out;
|
|
1559
|
+
}
|
|
1119
1560
|
enqueueContinuations(client, arrived) {
|
|
1120
1561
|
this.loadContinued();
|
|
1121
1562
|
for (const r of arrived) {
|
|
@@ -1177,17 +1618,23 @@ export class Worker {
|
|
|
1177
1618
|
let changed = false;
|
|
1178
1619
|
for (const cap of Object.keys(policy.asks)) {
|
|
1179
1620
|
const open = policy.asks[cap];
|
|
1180
|
-
if (!open)
|
|
1621
|
+
if (!open || this.ledgerAllowSeen.has(open.act))
|
|
1181
1622
|
continue;
|
|
1182
1623
|
const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act && a.from === this.cfg.member);
|
|
1183
1624
|
const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
|
|
1184
1625
|
if (body === null)
|
|
1185
1626
|
continue;
|
|
1186
|
-
const
|
|
1187
|
-
if (
|
|
1627
|
+
const verdict = parseGrantAnswer(body);
|
|
1628
|
+
if (verdict === "deny") {
|
|
1629
|
+
applyAnswer(policy, open.act, body);
|
|
1188
1630
|
this.askAt.delete(open.act);
|
|
1189
1631
|
changed = true;
|
|
1190
|
-
this.opts.log(`* grant request answered: ${
|
|
1632
|
+
this.opts.log(`* grant request answered on the ledger: ${cap} -> deny`);
|
|
1633
|
+
}
|
|
1634
|
+
else if (verdict) {
|
|
1635
|
+
this.ledgerAllowSeen.add(open.act);
|
|
1636
|
+
this.opts.log(`! an ANSWER on the ledger says "${verdict}" to this worker's ${cap} request (${open.act}) — NOT applied: ` +
|
|
1637
|
+
`a grant takes effect only from this machine. To grant it: pantheon policy grant ${CAPABILITY_ARGS[cap]}`);
|
|
1191
1638
|
}
|
|
1192
1639
|
}
|
|
1193
1640
|
if (changed)
|
|
@@ -1224,7 +1671,7 @@ export class Worker {
|
|
|
1224
1671
|
}
|
|
1225
1672
|
if (result.ok && !result.alreadyCurrent) {
|
|
1226
1673
|
await client.send({
|
|
1227
|
-
act: "NOTE", goal: null, after_seq: client.readSeq,
|
|
1674
|
+
act: "NOTE", goal: null, after_seq: Math.max(client.readSeq, client.seq),
|
|
1228
1675
|
summary: `self-upgraded pantheon ${result.from} -> ${result.to}; worker restarting on the new build`,
|
|
1229
1676
|
body: `This machine's worker upgraded pantheon ${result.from} -> ${result.to} (fast-forward pull + rebuild, granted by its human) and is restarting on the new build. Agent windows on this machine opened on 0.24 or later switch to the new version by themselves; a window opened on an older pantheon needs one reconnect (/mcp → pantheon → Reconnect, or reopen it).`,
|
|
1230
1677
|
}).catch((e) => this.opts.log(`! could not report the upgrade on the ledger: ${e.message}`));
|
|
@@ -1240,14 +1687,15 @@ export class Worker {
|
|
|
1240
1687
|
if (mayAsk(policy, "self_upgrade")) {
|
|
1241
1688
|
try {
|
|
1242
1689
|
const sent = await client.send({
|
|
1243
|
-
act: "ESCALATE", goal: null, after_seq: client.readSeq,
|
|
1690
|
+
act: "ESCALATE", goal: null, after_seq: Math.max(client.readSeq, client.seq),
|
|
1244
1691
|
summary: `Allow this worker to keep pantheon updated? It runs ${VERSION}, the team's relay ${relayPkg}. Answer "allow always", "allow once", or "deny".`,
|
|
1245
1692
|
body: [
|
|
1246
1693
|
`Allow this worker to keep pantheon updated? Answer "allow always", "allow once", or "deny".`,
|
|
1247
1694
|
``,
|
|
1248
1695
|
`Your worker is asking for a capability, not reporting a problem. The team's relay runs pantheon ${relayPkg}; this machine runs ${VERSION}, so until it upgrades it can miss or mishandle teammate requests.`,
|
|
1249
1696
|
`The upgrade is mechanical and reversible: fast-forward pull of the existing checkout, rebuild, restart its own services, then report versions on the ledger. Nothing else is authorized by this grant.`,
|
|
1250
|
-
`
|
|
1697
|
+
`Answer it on this machine — the menu bar, or: pantheon approvals answer <this-decision-id> 'allow always' (or: pantheon policy grant self-upgrade).`,
|
|
1698
|
+
`An answer sent from anywhere else settles this decision for the team but grants nothing.`,
|
|
1251
1699
|
].join("\n"),
|
|
1252
1700
|
});
|
|
1253
1701
|
recordAsk(policy, "self_upgrade", sent.id);
|
|
@@ -1260,21 +1708,196 @@ export class Worker {
|
|
|
1260
1708
|
}
|
|
1261
1709
|
}
|
|
1262
1710
|
askAt = new Map();
|
|
1711
|
+
ledgerAllowSeen = new Set();
|
|
1263
1712
|
lastFullScan = 0;
|
|
1264
|
-
async
|
|
1713
|
+
async fetchActById(id) {
|
|
1265
1714
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
|
|
1268
|
-
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
|
|
1715
|
+
const res = await signedRead(this.cfg, "/act", { id }, { timeoutMs: 3000 });
|
|
1269
1716
|
if (!res.ok)
|
|
1270
1717
|
return null;
|
|
1271
1718
|
const j = await res.json();
|
|
1272
|
-
return
|
|
1719
|
+
return j.act ?? null;
|
|
1273
1720
|
}
|
|
1274
1721
|
catch {
|
|
1275
1722
|
return null;
|
|
1276
1723
|
}
|
|
1277
1724
|
}
|
|
1725
|
+
async actSeq(id) {
|
|
1726
|
+
const a = await this.fetchActById(id);
|
|
1727
|
+
return typeof a?.seq === "number" ? a.seq : null;
|
|
1728
|
+
}
|
|
1729
|
+
senders = new Map();
|
|
1730
|
+
async actOf(client, id) {
|
|
1731
|
+
const seen = client.seenAct(id);
|
|
1732
|
+
if (seen?.from)
|
|
1733
|
+
return { from: seen.from, ...(seen.role ? { role: seen.role } : {}) };
|
|
1734
|
+
const kept = this.senders.get(id);
|
|
1735
|
+
if (kept)
|
|
1736
|
+
return kept;
|
|
1737
|
+
const a = await this.fetchActById(id);
|
|
1738
|
+
if (typeof a?.from !== "string")
|
|
1739
|
+
return null;
|
|
1740
|
+
const hit = { from: a.from, ...(typeof a.role === "string" ? { role: a.role } : {}) };
|
|
1741
|
+
if (this.senders.size > 500)
|
|
1742
|
+
this.senders.delete(this.senders.keys().next().value);
|
|
1743
|
+
this.senders.set(id, hit);
|
|
1744
|
+
return hit;
|
|
1745
|
+
}
|
|
1746
|
+
carryWakers(client) {
|
|
1747
|
+
if (this.stopped)
|
|
1748
|
+
return;
|
|
1749
|
+
const team = this.cfg.spaceId ?? this.cfg.space;
|
|
1750
|
+
let mode = "approved";
|
|
1751
|
+
let choice;
|
|
1752
|
+
let fromProfile = false;
|
|
1753
|
+
let p;
|
|
1754
|
+
let result;
|
|
1755
|
+
try {
|
|
1756
|
+
p = loadPolicy(this.opts.home);
|
|
1757
|
+
if (p.wakersCarried?.[team])
|
|
1758
|
+
return;
|
|
1759
|
+
const profile = loadProfile(this.cfg);
|
|
1760
|
+
mode = this.opts.wakeMode ?? wakeMode(profile);
|
|
1761
|
+
if (this.opts.wakeChoice !== undefined)
|
|
1762
|
+
choice = this.opts.wakeChoice === "unset" ? undefined : this.opts.wakeChoice;
|
|
1763
|
+
else {
|
|
1764
|
+
fromProfile = true;
|
|
1765
|
+
choice = profile.agent.wake_default;
|
|
1766
|
+
if (choice === undefined && this.joinedBefore030())
|
|
1767
|
+
choice = "unset-before-0.30";
|
|
1768
|
+
}
|
|
1769
|
+
result = carryOverWakers(p, team, this.cfg.member, client.members, choice);
|
|
1770
|
+
}
|
|
1771
|
+
catch (e) {
|
|
1772
|
+
this.opts.log(` wake could not carry over who may wake this machine: ${e.message}`);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (result === null)
|
|
1776
|
+
return;
|
|
1777
|
+
if (result === "deferred") {
|
|
1778
|
+
if (p.wakersDeferred?.[team])
|
|
1779
|
+
return;
|
|
1780
|
+
(p.wakersDeferred ??= {})[team] = Date.now();
|
|
1781
|
+
try {
|
|
1782
|
+
savePolicy(p, this.opts.home);
|
|
1783
|
+
}
|
|
1784
|
+
catch (e) {
|
|
1785
|
+
this.opts.log(` wake could not save the worker policy: ${e.message}`);
|
|
1786
|
+
}
|
|
1787
|
+
const why = OLD_RELAY_WAKE;
|
|
1788
|
+
this.opts.log(` wake ${why}`);
|
|
1789
|
+
if (this.opts.notify && mode === "approved") {
|
|
1790
|
+
try {
|
|
1791
|
+
this.opts.notify({ kind: "worker", who: "Your worker", heading: "Who can wake this machine", context: this.cfg.space, body: `${why}.`, ttl: 0, sound: false });
|
|
1792
|
+
}
|
|
1793
|
+
catch { }
|
|
1794
|
+
}
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
savePolicy(p, this.opts.home);
|
|
1799
|
+
}
|
|
1800
|
+
catch (e) {
|
|
1801
|
+
this.opts.log(` wake could not record who may wake this machine: ${e.message} — trying again later`);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (fromProfile && choice === "unset-before-0.30") {
|
|
1805
|
+
try {
|
|
1806
|
+
const prof = loadProfile(this.cfg);
|
|
1807
|
+
if (prof.agent.wake_default === undefined)
|
|
1808
|
+
saveProfile({ ...prof, agent: { ...prof.agent, wake_default: "approved" } });
|
|
1809
|
+
mode = this.opts.wakeMode ?? "approved";
|
|
1810
|
+
}
|
|
1811
|
+
catch (e) {
|
|
1812
|
+
this.opts.log(` wake could not record the wake setting: ${e.message}`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const carried = result;
|
|
1816
|
+
const names = new Set(carried.map((c) => c.member));
|
|
1817
|
+
const dropped = client.members.filter((m) => m.admitted && m.may_wake && m.member !== this.cfg.member && !names.has(m.member)).map((m) => m.member);
|
|
1818
|
+
const who = carried.map((c) => `${c.member} (${CARRY_REASON[c.why]})`).join(", ");
|
|
1819
|
+
const allow = "a person here allows them with: pantheon wake allow <name>";
|
|
1820
|
+
if (mode !== "approved") {
|
|
1821
|
+
const today = mode === "anyone" ? "anyone the team lets wake workers may wake this machine (you chose \"anyone\")" : "this machine is set not to be woken";
|
|
1822
|
+
this.opts.log(` wake ${today}${carried.length ? `; kept for if you choose "approved": ${who}` : ""}`);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
const now = carried.length ? `${who} can wake this machine` : "nobody can wake this machine yet";
|
|
1826
|
+
const before = dropped.length ? `; ${dropped.join(", ")} could before, through someone else's approval, and no longer can — ${allow}` : "";
|
|
1827
|
+
const setting = fromProfile && choice === "unset-before-0.30" ? "; this machine now wakes only for the people listed (pantheon wake list; pantheon setup to change)" : "";
|
|
1828
|
+
this.opts.log(` wake ${now}${before}${setting}`);
|
|
1829
|
+
if (this.opts.notify && (carried.length || dropped.length)) {
|
|
1830
|
+
try {
|
|
1831
|
+
this.opts.notify({
|
|
1832
|
+
kind: "worker", who: "Your worker", heading: "Who can wake this machine", context: this.cfg.space,
|
|
1833
|
+
body: `${now}. ${dropped.length ? `${dropped.join(", ")} could before and no longer can — ${allow}.` : `Anyone else: ${allow.replace(/^a person here allows them with: /, "")}.`}`,
|
|
1834
|
+
ttl: 0, sound: false,
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
catch { }
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
joinedBefore030() {
|
|
1841
|
+
try {
|
|
1842
|
+
const f = loadAll();
|
|
1843
|
+
const m = Object.values(f?.spaces ?? {}).find((x) => (this.cfg.spaceId && x.id === this.cfg.spaceId) || (x.name === this.cfg.space && x.relay === this.cfg.relay));
|
|
1844
|
+
return !!m && !m.since;
|
|
1845
|
+
}
|
|
1846
|
+
catch {
|
|
1847
|
+
return false;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
refusedSaid = false;
|
|
1851
|
+
oldRelaySaid = false;
|
|
1852
|
+
dropLedgerGrants() {
|
|
1853
|
+
try {
|
|
1854
|
+
const p = loadPolicy(this.opts.home);
|
|
1855
|
+
const gone = dropLedgerGrants(p);
|
|
1856
|
+
if (!gone.length)
|
|
1857
|
+
return;
|
|
1858
|
+
savePolicy(p, this.opts.home);
|
|
1859
|
+
const names = gone.map((c) => CAPABILITY_ARGS[c]);
|
|
1860
|
+
const body = `a ${names.join(" and ")} permission recorded from a message was removed — only a person on this machine grants one now; grant it here with ${names.map((n) => `pantheon policy grant ${n}`).join(" / ")}, or answer the worker's request when it asks`;
|
|
1861
|
+
this.opts.log(` policy ${body}`);
|
|
1862
|
+
if (this.opts.notify) {
|
|
1863
|
+
try {
|
|
1864
|
+
this.opts.notify({ kind: "worker", who: "Your worker", heading: "A permission was removed", context: this.cfg.space, body: `${body}.`, ttl: 0, sound: false });
|
|
1865
|
+
}
|
|
1866
|
+
catch { }
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
catch (e) {
|
|
1870
|
+
this.opts.log(` policy could not check the grants recorded here: ${e.message}`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
async wakeRefusal(client, ref, known) {
|
|
1874
|
+
const mode = this.opts.wakeMode ?? wakeMode(loadProfile(this.cfg));
|
|
1875
|
+
if (mode === "no")
|
|
1876
|
+
return { why: "this machine is set not to be woken by teammates (pantheon setup to change it)" };
|
|
1877
|
+
const sender = known?.from ? { from: known.from, role: known.role ?? undefined } : await this.actOf(client, ref);
|
|
1878
|
+
if (sender?.from === this.cfg.member && sender.role === "worker")
|
|
1879
|
+
return { why: "it was sent by a woken run of this member, and a woken run never starts another" };
|
|
1880
|
+
if (mode === "anyone")
|
|
1881
|
+
return null;
|
|
1882
|
+
if (sender?.from === this.cfg.member)
|
|
1883
|
+
return null;
|
|
1884
|
+
if (!sender?.from)
|
|
1885
|
+
return { why: "cannot tell who sent it, and only teammates a person here approved may wake this machine", transient: true };
|
|
1886
|
+
const from = sender.from;
|
|
1887
|
+
const pol = loadPolicy(this.opts.home);
|
|
1888
|
+
if (isApprovedWaker(pol, [this.cfg.spaceId, this.cfg.space], from))
|
|
1889
|
+
return null;
|
|
1890
|
+
if (!pol.wakersCarried?.[this.cfg.spaceId ?? this.cfg.space] && !relayKnowsAdmitters(client.members, this.cfg.member)) {
|
|
1891
|
+
if (!this.oldRelaySaid) {
|
|
1892
|
+
this.oldRelaySaid = true;
|
|
1893
|
+
this.opts.log(` wake ${OLD_RELAY_WAKE}`);
|
|
1894
|
+
}
|
|
1895
|
+
return null;
|
|
1896
|
+
}
|
|
1897
|
+
const teamSays = client.members.find((m) => m.member === from)?.may_wake;
|
|
1898
|
+
return { why: `${from} is not approved to wake this machine (the team's may_wake is not enough) — a person here can: pantheon wake allow ${from}, or choose wake "anyone" in pantheon setup` +
|
|
1899
|
+
(teamSays === false ? `; the team does not let ${from} wake workers either (a member runs: pantheon admit ${from} --may-wake)` : "") };
|
|
1900
|
+
}
|
|
1278
1901
|
async fetchGrantAnswer(askActId, since = 0) {
|
|
1279
1902
|
let seq = this.askAt.get(askActId) ?? null;
|
|
1280
1903
|
if (seq === null) {
|
|
@@ -1290,9 +1913,7 @@ export class Worker {
|
|
|
1290
1913
|
}
|
|
1291
1914
|
const from = answerWindow(seq, since, due);
|
|
1292
1915
|
try {
|
|
1293
|
-
const
|
|
1294
|
-
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
|
|
1295
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
1916
|
+
const res = await signedRead(this.cfg, "/history", { since: from }, { timeoutMs: 3000 });
|
|
1296
1917
|
const j = await res.json();
|
|
1297
1918
|
const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId && a.from === this.cfg.member);
|
|
1298
1919
|
return ans ? ans.body : null;
|