@proagentstore/cli 0.4.49 → 0.4.51
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.
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the LAST completed engine turn ended (#545).
|
|
3
|
+
*
|
|
4
|
+
* The exit code was already known on this side and spent entirely on prose: `runOneShot`'s close
|
|
5
|
+
* handler pushed `[codex exited with code 1]` into the transcript and set no field, so a production
|
|
6
|
+
* Codex session whose every turn exited 1 — three times, the reason printed each time — reported
|
|
7
|
+
* `alive: true, ready: true, runState: "idle"`, and the Pilot spent fifteen minutes and three BYOK
|
|
8
|
+
* decisions rediscovering it from the pane.
|
|
9
|
+
*
|
|
10
|
+
* OUTCOME AND LIVENESS ARE TWO FACTS, and this module owns only the first. Nothing here touches
|
|
11
|
+
* `alive`: a one-shot session has no process between turns, a failing turn does not make the
|
|
12
|
+
* session unable to take another, and conflating the two once killed every delegated goal on
|
|
13
|
+
* codex/grok/gemini at iteration 0 (see `HeadlessSession.alive`). The failure is REPORTED here;
|
|
14
|
+
* what to make of one is the cloud's judgement, in `workers/api/src/lib/coding-turn-outcome.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Its own module, beside `engine-usage.ts` / `engine-acts.ts` / `engine-auth.ts`, for the reason
|
|
17
|
+
* they are: the RULE that turns a process exit into a verdict is worth testing without spawning a
|
|
18
|
+
* process, and it is the part someone will later be tempted to change.
|
|
19
|
+
*/
|
|
20
|
+
/** Longest engine line carried as {@link EngineTurnReport.detail}. The pane holds the rest. */
|
|
21
|
+
export const MAX_TURN_DETAIL = 240;
|
|
22
|
+
/**
|
|
23
|
+
* A one-shot turn's process has exited — say what that means, and nothing more.
|
|
24
|
+
*
|
|
25
|
+
* A SIGNAL outranks the code because a signalled process's code is null and the kill is ours: the
|
|
26
|
+
* 15-minute wedge ceiling and `interrupt()` both land here, and counting either as an engine
|
|
27
|
+
* failure would let three slow builds read as a broken CLI.
|
|
28
|
+
*/
|
|
29
|
+
export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()) {
|
|
30
|
+
const detail = lastLine.trim().slice(0, MAX_TURN_DETAIL);
|
|
31
|
+
return {
|
|
32
|
+
verdict: signal !== null ? "killed" : code === 0 ? "ok" : "failed",
|
|
33
|
+
exitCode: code,
|
|
34
|
+
signal,
|
|
35
|
+
at: now,
|
|
36
|
+
...(detail ? { detail } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A stream-json turn ended with a `result` event — the structured path's analogue of an exit code.
|
|
41
|
+
*
|
|
42
|
+
* Without it the field would exist for three engines and silently not for the flagship, which is
|
|
43
|
+
* the shape of gap that makes a platform-wide claim ("we notice a failed turn") false in the one
|
|
44
|
+
* case that runs most.
|
|
45
|
+
*/
|
|
46
|
+
export function turnReportFromResult(isError, detail = "", now = Date.now()) {
|
|
47
|
+
const text = detail.trim().slice(0, MAX_TURN_DETAIL);
|
|
48
|
+
return {
|
|
49
|
+
verdict: isError ? "failed" : "ok",
|
|
50
|
+
exitCode: null,
|
|
51
|
+
signal: null,
|
|
52
|
+
at: now,
|
|
53
|
+
...(text ? { detail: text } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { classifyCommand, commandFromToolInput, fillTargetFromResult } from "./engine-acts.js";
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
|
+
import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
4
5
|
/**
|
|
5
6
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
6
7
|
* REMOVE rather than "set to empty".
|
|
@@ -88,6 +89,16 @@ export class HeadlessSession {
|
|
|
88
89
|
turnStartedAt = 0;
|
|
89
90
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
90
91
|
stopped = false;
|
|
92
|
+
/** How the last COMPLETED turn ended (#545). Null until one has. See {@link EngineTurnReport}. */
|
|
93
|
+
turnReport = null;
|
|
94
|
+
/**
|
|
95
|
+
* The engine's own last output line of the turn in flight (#545).
|
|
96
|
+
*
|
|
97
|
+
* Recorded as it is written, so the failure detail is the engine's sentence rather than
|
|
98
|
+
* something parsed back out of a rendered pane. Reset when a turn starts, so a report can never
|
|
99
|
+
* carry a previous turn's line.
|
|
100
|
+
*/
|
|
101
|
+
turnLastLine = "";
|
|
91
102
|
/** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
|
|
92
103
|
pendingUsage = [];
|
|
93
104
|
/**
|
|
@@ -216,6 +227,15 @@ export class HeadlessSession {
|
|
|
216
227
|
return !this.stopped && !this.spawnFailed;
|
|
217
228
|
return this.procAlive;
|
|
218
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* How the last completed turn ended (#545) — NOT whether the session can take another.
|
|
232
|
+
*
|
|
233
|
+
* Read by the snapshot and carried to the cloud. Null means no turn has completed on this
|
|
234
|
+
* session; it is never a claim that a turn went well.
|
|
235
|
+
*/
|
|
236
|
+
get lastTurn() {
|
|
237
|
+
return this.turnReport;
|
|
238
|
+
}
|
|
219
239
|
/** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
|
|
220
240
|
get procAlive() {
|
|
221
241
|
return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
|
|
@@ -397,6 +417,9 @@ export class HeadlessSession {
|
|
|
397
417
|
* preset decides what runs and what it costs; the platform does not need to know the engine.
|
|
398
418
|
*/
|
|
399
419
|
runOneShot(text) {
|
|
420
|
+
// Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
|
|
421
|
+
// this turn produced (#545).
|
|
422
|
+
this.turnLastLine = "";
|
|
400
423
|
const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
|
|
401
424
|
cwd: this.config.workDir,
|
|
402
425
|
env: mergeEnv(process.env, this.config.env),
|
|
@@ -450,7 +473,7 @@ export class HeadlessSession {
|
|
|
450
473
|
}
|
|
451
474
|
}, maxTurnMs);
|
|
452
475
|
ceiling.unref();
|
|
453
|
-
proc.on("close", (code) => {
|
|
476
|
+
proc.on("close", (code, signal) => {
|
|
454
477
|
clearTimeout(ceiling); // cleared before the staleness guard: the timer belongs to THIS process
|
|
455
478
|
// A non-zero exit is the engine's own failure (bad flags, not signed in) and the
|
|
456
479
|
// operator needs to see it — silently going idle is how "stdin is not a terminal"
|
|
@@ -465,6 +488,14 @@ export class HeadlessSession {
|
|
|
465
488
|
// kill-tmux.
|
|
466
489
|
if (this.proc !== proc)
|
|
467
490
|
return;
|
|
491
|
+
// THE EXIT CODE STOPS BEING ONLY PROSE HERE (#545). Recorded after the staleness guard
|
|
492
|
+
// on purpose: a turn aborted by its successor (see the kill above) must not overwrite
|
|
493
|
+
// the report of the turn that replaced it — the loser's outcome is about a turn nobody
|
|
494
|
+
// is waiting on any more.
|
|
495
|
+
//
|
|
496
|
+
// A signal means WE ended it (the wedge ceiling, an interrupt), which is the `killed`
|
|
497
|
+
// verdict: evidence about this platform's timers, not about the engine's health.
|
|
498
|
+
this.turnReport = turnReportFromExit(code, signal, this.turnLastLine);
|
|
468
499
|
this.run = "idle";
|
|
469
500
|
this.proc = null;
|
|
470
501
|
});
|
|
@@ -536,8 +567,12 @@ export class HeadlessSession {
|
|
|
536
567
|
/** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
|
|
537
568
|
pushRaw(line) {
|
|
538
569
|
const clean = stripAnsi(line);
|
|
539
|
-
if (clean.trim())
|
|
570
|
+
if (clean.trim()) {
|
|
540
571
|
this.push(clean);
|
|
572
|
+
// The engine's own words, kept for the turn's report (#545) — captured on the way in,
|
|
573
|
+
// never scraped back out of the rendered pane.
|
|
574
|
+
this.turnLastLine = clean.trim();
|
|
575
|
+
}
|
|
541
576
|
if (this.transcript.length > 4000)
|
|
542
577
|
this.transcript = this.transcript.slice(-3000);
|
|
543
578
|
}
|
|
@@ -576,8 +611,14 @@ export class HeadlessSession {
|
|
|
576
611
|
}
|
|
577
612
|
break;
|
|
578
613
|
case "result": {
|
|
579
|
-
|
|
580
|
-
|
|
614
|
+
const failure = ev.is_error ? String(ev.result ?? ev.subtype ?? "failed") : "";
|
|
615
|
+
if (failure)
|
|
616
|
+
this.push(`[error] ${failure}`);
|
|
617
|
+
// The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
|
|
618
|
+
// per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
|
|
619
|
+
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
620
|
+
// this the field would exist for three engines and silently not for the flagship.
|
|
621
|
+
this.turnReport = turnReportFromResult(ev.is_error === true, failure);
|
|
581
622
|
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
582
623
|
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
583
624
|
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
@@ -290,7 +290,16 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
290
290
|
try {
|
|
291
291
|
size = statSync(abs).size;
|
|
292
292
|
}
|
|
293
|
-
catch {
|
|
293
|
+
catch {
|
|
294
|
+
// Benign, and traced rather than assumed (#291). `size` stays `undefined`, which JSON
|
|
295
|
+
// drops, so the entry reaches the model as `{path, type: "file"}` — the same listing a
|
|
296
|
+
// successful stat produces, because `repo-local.ts`'s renderer prints `e.path` and has
|
|
297
|
+
// never shown a size at all. The test is whether the fallback can be mistaken for a
|
|
298
|
+
// real answer: `size: 0` would be a claim about an empty file; an absent field is not
|
|
299
|
+
// a claim. The entry itself is still emitted because `readdir` saw it, and the one
|
|
300
|
+
// case where that is already stale — deleted between the readdir and this stat —
|
|
301
|
+
// degrades into an honest error from `repo_read_file`, not into a wrong listing.
|
|
302
|
+
}
|
|
294
303
|
entries.push({ path: rel, type: "file", size });
|
|
295
304
|
}
|
|
296
305
|
}
|
|
@@ -119,10 +119,18 @@ export function switchRepoBranch(workDir, branch) {
|
|
|
119
119
|
// CONFIRM, do not assume. The exit code says the command ran; only reading HEAD back says where
|
|
120
120
|
// the checkout actually is, and that is the only thing the cloud is allowed to report as done.
|
|
121
121
|
const after = currentBranch(workDir);
|
|
122
|
-
|
|
122
|
+
// And the same rule for the tree: `null` when git would not answer, never `false`. Nothing in
|
|
123
|
+
// the cloud reads this field today — `repo-policy-act.ts` acts on `refused`, `error` and its own
|
|
124
|
+
// independent read — so this is prophylactic rather than a live defect, and it is recorded that
|
|
125
|
+
// way. What makes it worth changing anyway is that the value is a CLAIM and the next reader
|
|
126
|
+
// inherits it: `dirty: false` off a failed `git status` says "clean" in the one field whose
|
|
127
|
+
// whole job is to say whether anything came across. Absent is degraded; manufactured is wrong.
|
|
128
|
+
let dirtyAfter;
|
|
123
129
|
try {
|
|
124
130
|
dirtyAfter = isDirty(workDir);
|
|
125
131
|
}
|
|
126
|
-
catch {
|
|
132
|
+
catch {
|
|
133
|
+
dirtyAfter = null;
|
|
134
|
+
}
|
|
127
135
|
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
128
136
|
}
|
|
@@ -151,6 +151,10 @@ export class CodingRuntime {
|
|
|
151
151
|
// exactly the question asked about a session that just stopped.
|
|
152
152
|
authResolved: session.authResolved,
|
|
153
153
|
engineRuntime: session.engineRuntime,
|
|
154
|
+
// Reported on EVERY capture, including one where the session is not alive: "how did the
|
|
155
|
+
// last turn end" is exactly the question asked about a session that just stopped, and
|
|
156
|
+
// the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
|
|
157
|
+
...(session.lastTurn ? { lastTurn: session.lastTurn } : {}),
|
|
154
158
|
...(opts.drainUsage ? { usage: session.takeUsage(), acts: session.takeActs() } : {}),
|
|
155
159
|
};
|
|
156
160
|
}
|
|
@@ -187,6 +191,17 @@ export class CodingRuntime {
|
|
|
187
191
|
* turn of a session very often runs after the final capture poll, and ending is where that
|
|
188
192
|
* record would otherwise be lost — silently, and only for the turns at the end of every
|
|
189
193
|
* session, which is a bias rather than noise.
|
|
194
|
+
*
|
|
195
|
+
* …and, since #554, WHO PAID for it. The spend was already returned here; the observation that
|
|
196
|
+
* makes it attributable was one field away on the session object in hand, so every closing turn
|
|
197
|
+
* of every session reached the ledger with `payer` NULL even when the credential was known. The
|
|
198
|
+
* bias is the same one the paragraph above describes, which is why the omission mattered: it
|
|
199
|
+
* did not lose a random sample of turns, it lost the last turn of every session.
|
|
200
|
+
*
|
|
201
|
+
* `null` is a REAL answer here, not a default. `end()` tolerates a `sessionId` it has never
|
|
202
|
+
* heard of, and the honest report for a session this runner does not have is that it cannot say
|
|
203
|
+
* what the engine authenticated with — not a guess derived from the preset (see
|
|
204
|
+
* `usage-payer.ts`, and the alternative #554 rejected).
|
|
190
205
|
*/
|
|
191
206
|
end(sessionId) {
|
|
192
207
|
const session = this.sessions.get(sessionId);
|
|
@@ -196,12 +211,16 @@ export class CodingRuntime {
|
|
|
196
211
|
// happens after the final capture poll. Discarding the tail would systematically lose exactly
|
|
197
212
|
// the acts this record exists for.
|
|
198
213
|
const acts = session ? session.takeActs() : [];
|
|
214
|
+
// Read BEFORE `stop()`: `authResolved` is a live getter over the merged spawn env
|
|
215
|
+
// (`headless.ts`), so it must be taken while the session is still the object that spawned
|
|
216
|
+
// the process rather than after it has been torn down and dropped from the map.
|
|
217
|
+
const authResolved = session ? session.authResolved : null;
|
|
199
218
|
if (session) {
|
|
200
219
|
session.stop();
|
|
201
220
|
this.sessions.delete(sessionId);
|
|
202
221
|
}
|
|
203
222
|
this.takeovers.delete(sessionId);
|
|
204
|
-
return { ok: true, usage, acts };
|
|
223
|
+
return { ok: true, usage, acts, authResolved };
|
|
205
224
|
}
|
|
206
225
|
list() {
|
|
207
226
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|