@proagentstore/cli 0.4.35 → 0.4.37
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/dist/browser-runner/coding/engine-acts.js +199 -0
- package/dist/browser-runner/coding/headless.js +102 -1
- package/dist/browser-runner/coding/runtime.js +33 -5
- package/dist/browser-runner/runner.js +6 -2
- package/dist/browser-runner/test-job-server.js +11 -0
- package/dist/index.js +32 -5
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Engine actually DID — the consequential-act record (#294).
|
|
3
|
+
*
|
|
4
|
+
* A delegated run recorded its objective and its terminal outcome and nothing in between. Live,
|
|
5
|
+
* run `73ffc073` merged its own PRs to `main` unattended and the supervisor view said "done". For
|
|
6
|
+
* a read-only goal that is fine; for a goal that merges to the trunk it is trust, not review.
|
|
7
|
+
*
|
|
8
|
+
* ── WHERE THE SIGNAL COMES FROM, AND WHY IT IS NOT THE OTHER ONE ──
|
|
9
|
+
*
|
|
10
|
+
* The obvious alternative was a repo-state delta: snapshot branch/HEAD/refs before and after a run
|
|
11
|
+
* and diff them. #276 already reads `git status --short --branch` through the runner, so it is
|
|
12
|
+
* nearly free. It was rejected on a fact, not a preference: **a pull request is not a git object.**
|
|
13
|
+
* `gh pr create` changes nothing in the local repository at all, and `gh pr merge` changes a ref on
|
|
14
|
+
* GitHub that the local checkout does not learn about until somebody fetches. So the delta design
|
|
15
|
+
* cannot satisfy the one thing the issue asks for by name — "a run that opens or merges a PR leaves
|
|
16
|
+
* a record naming that act" — and would report a clean tree on `main` for the exact run that
|
|
17
|
+
* prompted the issue.
|
|
18
|
+
*
|
|
19
|
+
* What it CAN see (branch moved, tree dirty) is already reported by #276, so building it here would
|
|
20
|
+
* have duplicated the covered half and missed the uncovered one.
|
|
21
|
+
*
|
|
22
|
+
* The worry the issue actually raises about this side — "parsing terminal output is unreliable, the
|
|
23
|
+
* model's prose is not an audit trail" — does not apply to what is parsed here. This does not read
|
|
24
|
+
* the Engine's prose. Claude Code's stream-json protocol emits a `tool_use` block per tool
|
|
25
|
+
* invocation carrying the literal command string it is about to run, and a `tool_result` carrying
|
|
26
|
+
* whether it failed. `{"command":"gh pr merge 42 --squash"}` is a protocol event, the same class of
|
|
27
|
+
* fact as the `result` event #267 takes its cost figures from. It is what the Engine RAN, not what
|
|
28
|
+
* it later said about what it ran.
|
|
29
|
+
*
|
|
30
|
+
* ── THE HONEST GAP ──
|
|
31
|
+
*
|
|
32
|
+
* Only a stream-json engine (Claude Code) produces these. A raw Codex/Grok session emits nothing
|
|
33
|
+
* parseable, so it records no acts — the same gap, for the same reason, as engine usage (#267). An
|
|
34
|
+
* empty act list therefore means "nothing observed", never "nothing happened", and every consumer
|
|
35
|
+
* has to say so rather than render it as an all-clear.
|
|
36
|
+
*/
|
|
37
|
+
/** Acts whose consequences reach outside the machine, or cannot be walked back locally. */
|
|
38
|
+
const IRREVERSIBLE = new Set([
|
|
39
|
+
"pr.merge",
|
|
40
|
+
"push.trunk",
|
|
41
|
+
"push.force",
|
|
42
|
+
"branch.delete",
|
|
43
|
+
"reset.hard",
|
|
44
|
+
"clean",
|
|
45
|
+
"file.delete",
|
|
46
|
+
"release.publish",
|
|
47
|
+
"package.publish",
|
|
48
|
+
"repo.delete",
|
|
49
|
+
"deploy",
|
|
50
|
+
]);
|
|
51
|
+
/** Hard cap on the recorded command text. Long enough to read, short enough not to be a log dump. */
|
|
52
|
+
const MAX_COMMAND = 400;
|
|
53
|
+
/**
|
|
54
|
+
* Strip anything that looks like a credential before the command is stored or shown.
|
|
55
|
+
*
|
|
56
|
+
* A command line is the single most common place a token is pasted in the open
|
|
57
|
+
* (`GH_TOKEN=… git push`, `https://x-access-token:…@github.com/…`), and this record travels from
|
|
58
|
+
* the user's machine into D1 and then into a supervisor's model prompt. Redacting at the point of
|
|
59
|
+
* capture is the only place that covers all three.
|
|
60
|
+
*/
|
|
61
|
+
export function redactCommand(command) {
|
|
62
|
+
let s = String(command ?? "");
|
|
63
|
+
// Credentials embedded in a clone/push URL.
|
|
64
|
+
s = s.replace(/(https?:\/\/)[^/\s@]+@/g, "$1***@");
|
|
65
|
+
// Known token shapes, whatever they are assigned to.
|
|
66
|
+
s = s.replace(/\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]{10,}/g, "$1***");
|
|
67
|
+
s = s.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "sk-***");
|
|
68
|
+
s = s.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox-***");
|
|
69
|
+
// Anything ASSIGNED to a secret-shaped name, whatever the value looks like.
|
|
70
|
+
s = s.replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|KEY|PAT))=(\S+)/gi, "$1=***");
|
|
71
|
+
return s.trim().slice(0, MAX_COMMAND);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Split a shell command into the pieces that each run something.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately aggressive — `&&`, `||`, `;`, `|` and newlines all split, regardless of quoting.
|
|
77
|
+
* Over-splitting costs nothing (a fragment simply matches no rule); under-splitting loses an act,
|
|
78
|
+
* and the real-world shape of the case this exists for is exactly a compound line:
|
|
79
|
+
* `cd repo && git push -u origin fix && gh pr create --fill && gh pr merge --squash`.
|
|
80
|
+
*/
|
|
81
|
+
export function splitSegments(command) {
|
|
82
|
+
return String(command ?? "")
|
|
83
|
+
.split(/\|\||&&|[;|\n]/)
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
/** A token that is exactly the trunk, not a branch merely containing the word (`feature/main-fix`). */
|
|
88
|
+
const TRUNK_TOKEN = /(?:^|\s)(?:HEAD:)?(?:refs\/heads\/)?(?:main|master)(?:\s|$)/;
|
|
89
|
+
function prRef(segment) {
|
|
90
|
+
// `gh pr merge 42 --squash`, `gh pr merge --squash 42`, or a full URL.
|
|
91
|
+
const url = segment.match(/https?:\/\/\S*?\/pull\/(\d+)/);
|
|
92
|
+
if (url)
|
|
93
|
+
return `#${url[1]}`;
|
|
94
|
+
const num = segment.match(/\bgh\s+pr\s+(?:merge|create)\b(?:\s+-{1,2}\S+(?:=\S+)?)*\s+(\d+)\b/);
|
|
95
|
+
return num ? `#${num[1]}` : null;
|
|
96
|
+
}
|
|
97
|
+
function pushTarget(segment) {
|
|
98
|
+
// `git push [flags] <remote> <refspec>` — take the first two non-flag words after `push`.
|
|
99
|
+
const after = segment.split(/\bgit\s+push\b/)[1] ?? "";
|
|
100
|
+
const words = after.split(/\s+/).filter((w) => w && !w.startsWith("-"));
|
|
101
|
+
return words.length ? words.slice(0, 2).join(" ") : null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Classify ONE shell segment. Returns null for ordinary work, which is nearly everything.
|
|
105
|
+
*
|
|
106
|
+
* The rules are matched in order of consequence, so `git push --force origin main` is recorded as
|
|
107
|
+
* the force-push it is rather than as an ordinary push.
|
|
108
|
+
*/
|
|
109
|
+
export function classifySegment(segment) {
|
|
110
|
+
const s = String(segment ?? "");
|
|
111
|
+
const hit = (kind, target = null) => ({
|
|
112
|
+
kind,
|
|
113
|
+
target,
|
|
114
|
+
irreversible: IRREVERSIBLE.has(kind),
|
|
115
|
+
});
|
|
116
|
+
if (/\bgh\s+pr\s+merge\b/.test(s))
|
|
117
|
+
return hit("pr.merge", prRef(s));
|
|
118
|
+
if (/\bgh\s+pr\s+create\b/.test(s))
|
|
119
|
+
return hit("pr.open", prRef(s));
|
|
120
|
+
if (/\bgh\s+release\s+create\b/.test(s))
|
|
121
|
+
return hit("release.publish");
|
|
122
|
+
if (/\bgh\s+repo\s+delete\b/.test(s))
|
|
123
|
+
return hit("repo.delete");
|
|
124
|
+
if (/\bgit\s+push\b/.test(s)) {
|
|
125
|
+
if (/(?:^|\s)(?:--force|--force-with-lease|-f)(?:=|\s|$)/.test(s))
|
|
126
|
+
return hit("push.force", pushTarget(s));
|
|
127
|
+
// `git push origin --delete x` and the older `git push origin :x` both delete a REMOTE
|
|
128
|
+
// branch — the one push that destroys rather than adds.
|
|
129
|
+
if (/(?:^|\s)--delete(?:\s|$)/.test(s) || /\s:\S+/.test(s))
|
|
130
|
+
return hit("branch.delete", pushTarget(s));
|
|
131
|
+
if (TRUNK_TOKEN.test(s.split(/\bgit\s+push\b/)[1] ?? ""))
|
|
132
|
+
return hit("push.trunk", pushTarget(s));
|
|
133
|
+
return hit("push", pushTarget(s));
|
|
134
|
+
}
|
|
135
|
+
// `-d` as well as `-D`: a supervisor asking "what did it delete" does not care that one of them
|
|
136
|
+
// refused on unmerged commits.
|
|
137
|
+
if (/\bgit\s+branch\s+(?:-\S*[dD]\b|--delete\b)/.test(s))
|
|
138
|
+
return hit("branch.delete");
|
|
139
|
+
if (/\bgit\s+reset\s+(?:\S+\s+)*--hard\b/.test(s))
|
|
140
|
+
return hit("reset.hard");
|
|
141
|
+
if (/\bgit\s+clean\b[^\n]*(?:^|\s)-\S*[fdx]/.test(s))
|
|
142
|
+
return hit("clean");
|
|
143
|
+
// Only a RECURSIVE or FORCED rm. Plain `rm scratch.txt` is housekeeping, and recording it would
|
|
144
|
+
// bury the acts that matter under noise — the record is only useful if it stays dense.
|
|
145
|
+
if (/\brm\s+(?:\S+\s+)*-\S*[rf]/.test(s))
|
|
146
|
+
return hit("file.delete");
|
|
147
|
+
if (/\b(?:npm|pnpm|yarn|bun)\s+publish\b/.test(s))
|
|
148
|
+
return hit("package.publish");
|
|
149
|
+
if (/\bwrangler\s+(?:\S+\s+)*deploy\b/.test(s))
|
|
150
|
+
return hit("deploy");
|
|
151
|
+
if (/\b(?:vercel|netlify)\s+(?:\S+\s+)*deploy\b/.test(s))
|
|
152
|
+
return hit("deploy");
|
|
153
|
+
if (/\bvercel\b[^\n]*--prod\b/.test(s))
|
|
154
|
+
return hit("deploy");
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Classify a whole command into zero or more acts.
|
|
159
|
+
*
|
|
160
|
+
* `id` is the engine's `tool_use` id; a compound command yielding two acts gets `id:0`/`id:1` so
|
|
161
|
+
* each still has its own stable dedup key. Every act carries the FULL original command as evidence,
|
|
162
|
+
* not just the segment that matched — a supervisor reading "force-pushed" needs to see what else
|
|
163
|
+
* was on that line.
|
|
164
|
+
*/
|
|
165
|
+
export function classifyCommand(id, command, at = new Date().toISOString()) {
|
|
166
|
+
const full = redactCommand(command);
|
|
167
|
+
if (!full)
|
|
168
|
+
return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const seg of splitSegments(command)) {
|
|
171
|
+
const c = classifySegment(seg);
|
|
172
|
+
if (!c)
|
|
173
|
+
continue;
|
|
174
|
+
out.push({
|
|
175
|
+
id: `${id}:${out.length}`,
|
|
176
|
+
kind: c.kind,
|
|
177
|
+
command: full,
|
|
178
|
+
target: c.target,
|
|
179
|
+
irreversible: c.irreversible,
|
|
180
|
+
ok: null,
|
|
181
|
+
at,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Pull the command string out of a `tool_use` block's input.
|
|
188
|
+
*
|
|
189
|
+
* Keyed on the INPUT SHAPE rather than the tool name: Claude Code calls it `Bash`, but a session
|
|
190
|
+
* launched with a custom agent/tool set can present the same shell capability under another name,
|
|
191
|
+
* and matching the name would silently record nothing for it. Anything handing a tool a `command`
|
|
192
|
+
* string is running a command.
|
|
193
|
+
*/
|
|
194
|
+
export function commandFromToolInput(input) {
|
|
195
|
+
if (!input || typeof input !== "object")
|
|
196
|
+
return null;
|
|
197
|
+
const cmd = input.command;
|
|
198
|
+
return typeof cmd === "string" && cmd.trim() ? cmd : null;
|
|
199
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { classifyCommand, commandFromToolInput } from "./engine-acts.js";
|
|
2
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
3
4
|
/**
|
|
4
5
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
@@ -33,6 +34,16 @@ import { resolveEngineAuth } from "./engine-auth.js";
|
|
|
33
34
|
* (refusing new records) would lose the turns that just happened rather than ancient ones.
|
|
34
35
|
*/
|
|
35
36
|
const MAX_PENDING_USAGE = 200;
|
|
37
|
+
/**
|
|
38
|
+
* How many un-drained consequential acts a session holds (#294).
|
|
39
|
+
*
|
|
40
|
+
* Smaller than the usage cap because acts are rare by construction — an ordinary turn produces
|
|
41
|
+
* none — so filling this means something has gone very wrong (a loop force-pushing repeatedly),
|
|
42
|
+
* and in that case the FIRST ones are the ones that explain it. So this cap drops the NEWEST rather
|
|
43
|
+
* than the oldest, the opposite of the usage queue: a spend record is fungible and the recent ones
|
|
44
|
+
* matter most, whereas the merge that started an incident is the one you cannot afford to lose.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_PENDING_ACTS = 100;
|
|
36
47
|
export class HeadlessSession {
|
|
37
48
|
config;
|
|
38
49
|
/**
|
|
@@ -66,6 +77,20 @@ export class HeadlessSession {
|
|
|
66
77
|
stopped = false;
|
|
67
78
|
/** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
|
|
68
79
|
pendingUsage = [];
|
|
80
|
+
/**
|
|
81
|
+
* Consequential acts observed but not yet handed to the cloud (#294). Drained by
|
|
82
|
+
* {@link takeActs}.
|
|
83
|
+
*/
|
|
84
|
+
pendingActs = [];
|
|
85
|
+
/**
|
|
86
|
+
* Acts whose `tool_result` has not come back yet, keyed by the engine's `tool_use` id.
|
|
87
|
+
*
|
|
88
|
+
* They are held here rather than published immediately so the record can say whether the
|
|
89
|
+
* command SUCCEEDED. A `gh pr merge` that failed on a branch-protection rule, published as a
|
|
90
|
+
* merge, would put an unattended merge to `main` in the audit trail that never happened — which
|
|
91
|
+
* damages the record exactly as much as missing a real one.
|
|
92
|
+
*/
|
|
93
|
+
awaitingResult = new Map();
|
|
69
94
|
/** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
|
|
70
95
|
usageSeq = 0;
|
|
71
96
|
/**
|
|
@@ -77,6 +102,18 @@ export class HeadlessSession {
|
|
|
77
102
|
* The queue is in memory and dies with the process, so a salt can never cause a double-count.
|
|
78
103
|
*/
|
|
79
104
|
usageRunId = Math.random().toString(36).slice(2, 10);
|
|
105
|
+
/** Fallback counter for an act whose `tool_use` block carries no id of its own. */
|
|
106
|
+
actSeq = 0;
|
|
107
|
+
/**
|
|
108
|
+
* Per-PROCESS salt for act ids, for the same reason as {@link usageRunId}.
|
|
109
|
+
*
|
|
110
|
+
* Claude Code's `tool_use` ids are unique within a conversation, not across restarts. Without a
|
|
111
|
+
* salt, a resumed session could re-emit an id the cloud has already written, and the
|
|
112
|
+
* conflict-ignoring insert would silently drop the SECOND act — a real merge, discarded because
|
|
113
|
+
* an older one happened to share an id. The queue is in memory and dies with the process, so a
|
|
114
|
+
* salt can never cause a double-write.
|
|
115
|
+
*/
|
|
116
|
+
actsRunId = Math.random().toString(36).slice(2, 10);
|
|
80
117
|
/**
|
|
81
118
|
* The engine binary could not be spawned (ENOENT, not executable).
|
|
82
119
|
*
|
|
@@ -456,13 +493,16 @@ export class HeadlessSession {
|
|
|
456
493
|
}
|
|
457
494
|
else if (block.type === "tool_use") {
|
|
458
495
|
this.push(`⚙ ${String(block.name ?? "tool")} ${shortInput(block.input)}`); // ⚙
|
|
496
|
+
this.noteAct(block);
|
|
459
497
|
}
|
|
460
498
|
}
|
|
461
499
|
break;
|
|
462
500
|
case "user": // tool results come back as a synthetic user message
|
|
463
501
|
for (const block of ev.message?.content ?? []) {
|
|
464
|
-
if (block.type === "tool_result")
|
|
502
|
+
if (block.type === "tool_result") {
|
|
465
503
|
this.push(` ↳ ${toolResult(block.content)}`); // ↳
|
|
504
|
+
this.settleAct(block);
|
|
505
|
+
}
|
|
466
506
|
}
|
|
467
507
|
break;
|
|
468
508
|
case "result": {
|
|
@@ -477,6 +517,11 @@ export class HeadlessSession {
|
|
|
477
517
|
if (this.pendingUsage.length > MAX_PENDING_USAGE)
|
|
478
518
|
this.pendingUsage.shift();
|
|
479
519
|
}
|
|
520
|
+
// The turn ended, so no further `tool_result` is coming for anything still waiting.
|
|
521
|
+
// Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
|
|
522
|
+
// never saw whether it worked" is a materially different claim from silence, and
|
|
523
|
+
// silence is what a supervisor would read as "it did nothing".
|
|
524
|
+
this.flushAwaitingActs();
|
|
480
525
|
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
481
526
|
break;
|
|
482
527
|
}
|
|
@@ -490,6 +535,62 @@ export class HeadlessSession {
|
|
|
490
535
|
push(line) {
|
|
491
536
|
this.transcript.push(line);
|
|
492
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* A `tool_use` block just went past — record it if the command it carries is consequential
|
|
540
|
+
* (#294). Nearly every call classifies to nothing and costs one regex sweep.
|
|
541
|
+
*/
|
|
542
|
+
noteAct(block) {
|
|
543
|
+
const command = commandFromToolInput(block.input);
|
|
544
|
+
if (!command)
|
|
545
|
+
return;
|
|
546
|
+
const toolUseId = typeof block.id === "string" && block.id ? block.id : `${this.config.id}:${this.actSeq++}`;
|
|
547
|
+
const acts = classifyCommand(`${this.actsRunId}:${toolUseId}`, command);
|
|
548
|
+
if (!acts.length)
|
|
549
|
+
return;
|
|
550
|
+
this.awaitingResult.set(toolUseId, acts);
|
|
551
|
+
}
|
|
552
|
+
/** The matching `tool_result` arrived — stamp the outcome and publish. */
|
|
553
|
+
settleAct(block) {
|
|
554
|
+
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
555
|
+
const acts = this.awaitingResult.get(id);
|
|
556
|
+
if (!acts)
|
|
557
|
+
return;
|
|
558
|
+
this.awaitingResult.delete(id);
|
|
559
|
+
const ok = block.is_error !== true;
|
|
560
|
+
for (const a of acts)
|
|
561
|
+
this.publishAct({ ...a, ok });
|
|
562
|
+
}
|
|
563
|
+
/** Publish everything still waiting, with an unknown outcome. */
|
|
564
|
+
flushAwaitingActs() {
|
|
565
|
+
for (const acts of this.awaitingResult.values()) {
|
|
566
|
+
for (const a of acts)
|
|
567
|
+
this.publishAct(a);
|
|
568
|
+
}
|
|
569
|
+
this.awaitingResult.clear();
|
|
570
|
+
}
|
|
571
|
+
publishAct(act) {
|
|
572
|
+
// Drops the NEWEST at the cap — see MAX_PENDING_ACTS for why this queue is the opposite
|
|
573
|
+
// way round from the usage one.
|
|
574
|
+
if (this.pendingActs.length >= MAX_PENDING_ACTS)
|
|
575
|
+
return;
|
|
576
|
+
this.pendingActs.push(act);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Hand over the consequential acts seen since the last drain, and forget them (#294).
|
|
580
|
+
*
|
|
581
|
+
* Drained rather than re-reported for the same reason as {@link takeUsage}: a 3s capture poll
|
|
582
|
+
* must not re-send the same merge forever. The cloud's insert is keyed on
|
|
583
|
+
* {@link EngineActRecord.id}, so the genuine race — the console's poll and the Pilot's own
|
|
584
|
+
* capture draining at the same moment — still cannot write the act twice.
|
|
585
|
+
*
|
|
586
|
+
* A raw engine returns an empty array, always. Nothing parses its stdout, so it has nothing to
|
|
587
|
+
* hand over; an empty list here means "not observed", never "nothing happened".
|
|
588
|
+
*/
|
|
589
|
+
takeActs() {
|
|
590
|
+
const out = this.pendingActs;
|
|
591
|
+
this.pendingActs = [];
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
493
594
|
/**
|
|
494
595
|
* Hand over the measured spend since the last drain, and forget it (#267).
|
|
495
596
|
*
|
|
@@ -110,7 +110,7 @@ export class CodingRuntime {
|
|
|
110
110
|
// exactly the question asked about a session that just stopped.
|
|
111
111
|
authResolved: session.authResolved,
|
|
112
112
|
engineRuntime: session.engineRuntime,
|
|
113
|
-
...(opts.drainUsage ? { usage: session.takeUsage() } : {}),
|
|
113
|
+
...(opts.drainUsage ? { usage: session.takeUsage(), acts: session.takeActs() } : {}),
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
/** Perform one action, then return the fresh snapshot (non-blocking, like browser act). */
|
|
@@ -143,12 +143,17 @@ export class CodingRuntime {
|
|
|
143
143
|
end(sessionId) {
|
|
144
144
|
const session = this.sessions.get(sessionId);
|
|
145
145
|
const usage = session ? session.takeUsage() : [];
|
|
146
|
+
// Acts drain here too, and for a sharper version of the same reason (#294): the LAST thing a
|
|
147
|
+
// coding run does is very often the consequential one — push, open the PR, merge it — and it
|
|
148
|
+
// happens after the final capture poll. Discarding the tail would systematically lose exactly
|
|
149
|
+
// the acts this record exists for.
|
|
150
|
+
const acts = session ? session.takeActs() : [];
|
|
146
151
|
if (session) {
|
|
147
152
|
session.stop();
|
|
148
153
|
this.sessions.delete(sessionId);
|
|
149
154
|
}
|
|
150
155
|
this.takeovers.delete(sessionId);
|
|
151
|
-
return { ok: true, usage };
|
|
156
|
+
return { ok: true, usage, acts };
|
|
152
157
|
}
|
|
153
158
|
list() {
|
|
154
159
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
@@ -220,12 +225,35 @@ export class CodingRuntime {
|
|
|
220
225
|
isUnderTakeover(sessionId) {
|
|
221
226
|
return this.takeovers.has(sessionId);
|
|
222
227
|
}
|
|
223
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Stop every session (runner shutdown).
|
|
230
|
+
*
|
|
231
|
+
* Each `stop()` is isolated (#274 lineage). `stop()` kills a child process, and killing an
|
|
232
|
+
* already-dead or wedged one throws — so a single bad session used to abort the loop, leaving
|
|
233
|
+
* every LATER session's engine running and skipping `sessions.clear()` entirely. The caller
|
|
234
|
+
* (`LocalRunner.close`) wraps this in a `catch {}`, so that escape was silent: the runner
|
|
235
|
+
* reported a clean shutdown while orphaned CLI processes kept editing the user's repo with
|
|
236
|
+
* nothing left holding their ids. Same shape as the `browserContext.close()` leak — a throw
|
|
237
|
+
* on the teardown path is what MAKES the leak, so every session is stopped independently and
|
|
238
|
+
* the maps always clear.
|
|
239
|
+
*/
|
|
224
240
|
closeAll() {
|
|
225
|
-
|
|
226
|
-
|
|
241
|
+
const failures = [];
|
|
242
|
+
for (const s of this.sessions.values()) {
|
|
243
|
+
try {
|
|
244
|
+
s.stop();
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
failures.push(e);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
227
250
|
this.sessions.clear();
|
|
228
251
|
this.takeovers.clear();
|
|
252
|
+
if (failures.length) {
|
|
253
|
+
// Cleared the state, then report: teardown completed as far as it could, but the
|
|
254
|
+
// operator needs to know a child process may have survived it.
|
|
255
|
+
throw new AggregateError(failures, `${failures.length} coding session(s) failed to stop`);
|
|
256
|
+
}
|
|
229
257
|
}
|
|
230
258
|
/** True if any session this runtime owns still has a live agent process. */
|
|
231
259
|
hasLiveSessions() {
|
|
@@ -193,8 +193,12 @@ export class LocalRunner {
|
|
|
193
193
|
try {
|
|
194
194
|
this.coding.closeAll();
|
|
195
195
|
}
|
|
196
|
-
catch {
|
|
197
|
-
//
|
|
196
|
+
catch (e) {
|
|
197
|
+
// A stuck coding session must not block the browser teardown below — but it must not
|
|
198
|
+
// vanish either. `closeAll` now stops every session independently and only throws to
|
|
199
|
+
// report which ones refused, so reaching here means a child process may have outlived
|
|
200
|
+
// the runner. Silence here is what let that read as a clean shutdown.
|
|
201
|
+
console.warn(`[runner] ${e instanceof Error ? e.message : String(e)} — a CLI process may still be running; check \`ps\` if a repo keeps changing`);
|
|
198
202
|
}
|
|
199
203
|
await this.mcp?.stop().catch(() => undefined);
|
|
200
204
|
this.mcp = null;
|
|
@@ -21,6 +21,17 @@ export async function startTestJobServer(port = 0) {
|
|
|
21
21
|
async close() {
|
|
22
22
|
await new Promise((resolve, reject) => {
|
|
23
23
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
24
|
+
// `close()` stops ACCEPTING connections; it does not end the open ones, and
|
|
25
|
+
// its callback does not fire until the last socket goes away. A Playwright
|
|
26
|
+
// browser that navigated here keeps its connection alive for reuse, so the
|
|
27
|
+
// callback waited on a socket only the browser would ever close — an
|
|
28
|
+
// UNBOUNDED wait, not a slow one.
|
|
29
|
+
//
|
|
30
|
+
// That is why this could not be fixed by raising a budget, and two attempts
|
|
31
|
+
// tried: bc3f2b3 bounded `mcp.stop()`, f3e9c53 raised `hookTimeout`. The hook
|
|
32
|
+
// still burned its whole budget, red-gated CI, and blocked the API deploy on
|
|
33
|
+
// main for unrelated commits that happened to land next.
|
|
34
|
+
server.closeAllConnections();
|
|
24
35
|
});
|
|
25
36
|
},
|
|
26
37
|
};
|
package/dist/index.js
CHANGED
|
@@ -533,7 +533,14 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
533
533
|
writeError("No agent.json found. Run `pags init` first.");
|
|
534
534
|
process.exit(1);
|
|
535
535
|
}
|
|
536
|
-
|
|
536
|
+
let manifest;
|
|
537
|
+
try {
|
|
538
|
+
manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
|
|
539
|
+
} catch (e) {
|
|
540
|
+
writeError(`agent.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
537
544
|
const slug = manifest.id;
|
|
538
545
|
if (!slug) {
|
|
539
546
|
writeError("agent.json missing id");
|
|
@@ -592,8 +599,12 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
592
599
|
cwd: dir,
|
|
593
600
|
stdio: "inherit"
|
|
594
601
|
});
|
|
595
|
-
} catch {
|
|
596
|
-
|
|
602
|
+
} catch (e) {
|
|
603
|
+
writeError(`
|
|
604
|
+
Push failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
605
|
+
writeError(" Nothing was published \u2014 fix the push (pull/rebase, or check your GitHub auth) and retry.\n");
|
|
606
|
+
process.exit(1);
|
|
607
|
+
return;
|
|
597
608
|
}
|
|
598
609
|
}
|
|
599
610
|
writeLine("\n Registering agent in store...");
|
|
@@ -874,10 +885,23 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
874
885
|
writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
|
|
875
886
|
writeLine(" No cloudflared needed. Ctrl+C to disconnect.");
|
|
876
887
|
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
888
|
+
let heartbeatFailing = false;
|
|
877
889
|
const heartbeat = () => {
|
|
878
890
|
const timer = setTimeout(async () => {
|
|
891
|
+
let failure = null;
|
|
879
892
|
for (const id of [...attached.keys()]) {
|
|
880
|
-
|
|
893
|
+
try {
|
|
894
|
+
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode });
|
|
895
|
+
} catch (e) {
|
|
896
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (failure && !heartbeatFailing) {
|
|
900
|
+
heartbeatFailing = true;
|
|
901
|
+
writeError(`Heartbeat failed: ${failure} \u2014 the console will show this machine as OFFLINE until it recovers. The relay itself is still connected; don't run \`pags up --force\` elsewhere.`);
|
|
902
|
+
} else if (!failure && heartbeatFailing) {
|
|
903
|
+
heartbeatFailing = false;
|
|
904
|
+
writeLine("Heartbeat recovered \u2014 this machine reads as online again.");
|
|
881
905
|
}
|
|
882
906
|
heartbeat();
|
|
883
907
|
}, 3e4);
|
|
@@ -1499,7 +1523,10 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1499
1523
|
stdio: "inherit",
|
|
1500
1524
|
env: process.env
|
|
1501
1525
|
});
|
|
1502
|
-
} catch {
|
|
1526
|
+
} catch (e) {
|
|
1527
|
+
const status = e.status;
|
|
1528
|
+
writeLine(` Restart failed${typeof status === "number" ? ` (exit ${status})` : ""} \u2014 the runner is NOT running. Run 'pags up' again.`);
|
|
1529
|
+
process.exit(typeof status === "number" && status !== 0 ? status : 1);
|
|
1503
1530
|
}
|
|
1504
1531
|
process.exit(0);
|
|
1505
1532
|
}
|