@proagentstore/cli 0.4.26 → 0.4.28
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.
|
@@ -44,6 +44,15 @@ export class HeadlessSession {
|
|
|
44
44
|
turnStartedAt = 0;
|
|
45
45
|
/** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
|
|
46
46
|
stopped = false;
|
|
47
|
+
/**
|
|
48
|
+
* The engine binary could not be spawned (ENOENT, not executable).
|
|
49
|
+
*
|
|
50
|
+
* Without this, `alive = !stopped` made a MISCONFIGURED engine indistinguishable from a
|
|
51
|
+
* healthy idle one — so `runCodingLoop`'s `if (!snap.alive)` guard, the thing that catches a
|
|
52
|
+
* bad command, became structurally unreachable for every one-shot engine and the Pilot burned
|
|
53
|
+
* all 40 BYOK decisions re-spawning a binary that isn't there.
|
|
54
|
+
*/
|
|
55
|
+
spawnFailed = false;
|
|
47
56
|
constructor(config) {
|
|
48
57
|
this.config = config;
|
|
49
58
|
this.sessionName = `pags-${config.clientType}-${config.id}`;
|
|
@@ -87,7 +96,7 @@ export class HeadlessSession {
|
|
|
87
96
|
*/
|
|
88
97
|
get alive() {
|
|
89
98
|
if (this.oneShot)
|
|
90
|
-
return !this.stopped;
|
|
99
|
+
return !this.stopped && !this.spawnFailed;
|
|
91
100
|
return this.procAlive;
|
|
92
101
|
}
|
|
93
102
|
/** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
|
|
@@ -153,6 +162,7 @@ export class HeadlessSession {
|
|
|
153
162
|
// Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
|
|
154
163
|
// after one has to bring it back or the session is permanently unusable.
|
|
155
164
|
this.stopped = false;
|
|
165
|
+
this.spawnFailed = false; // a restart is also a retry of a command that could not run
|
|
156
166
|
// A one-shot engine has nothing to start until there is a turn to run; starting it here
|
|
157
167
|
// is what produced the instant "exited with code 1".
|
|
158
168
|
if (this.oneShot) {
|
|
@@ -255,13 +265,30 @@ export class HeadlessSession {
|
|
|
255
265
|
env: mergeEnv(process.env, this.config.env),
|
|
256
266
|
stdio: ["ignore", "pipe", "pipe"],
|
|
257
267
|
});
|
|
268
|
+
// A turn already running is aborted before its replacement starts. `input()` never killed
|
|
269
|
+
// the previous process, and the raw idle heuristic declares idle after a >1.5s output
|
|
270
|
+
// pause — so a long build could be judged idle, the brain sends turn 2, and TWO engine
|
|
271
|
+
// processes edit the same repo at once.
|
|
272
|
+
if (this.procAlive) {
|
|
273
|
+
try {
|
|
274
|
+
this.proc?.kill();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* already gone */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
258
280
|
this.proc = proc;
|
|
259
281
|
// Without an 'error' listener a spawn failure (binary not on PATH) is an uncaught
|
|
260
282
|
// exception that takes the whole runner down, not just this session.
|
|
261
283
|
proc.on("error", (err) => {
|
|
284
|
+
if (this.proc !== proc)
|
|
285
|
+
return; // stale: a newer turn owns the session now
|
|
262
286
|
this.push(`[${this.config.clientType}] failed to start: ${err.message}`);
|
|
263
287
|
this.run = "idle";
|
|
264
288
|
this.proc = null;
|
|
289
|
+
// The command itself is unrunnable — report the session dead so the loop stops with a
|
|
290
|
+
// real reason instead of retrying a binary that does not exist.
|
|
291
|
+
this.spawnFailed = true;
|
|
265
292
|
});
|
|
266
293
|
proc.stdout?.on("data", (d) => this.onStdout(d.toString()));
|
|
267
294
|
proc.stderr?.on("data", (d) => this.onStdout(d.toString()));
|
|
@@ -271,13 +298,29 @@ export class HeadlessSession {
|
|
|
271
298
|
// looked like an idle session for a whole afternoon.
|
|
272
299
|
if (code)
|
|
273
300
|
this.push(`[${this.config.clientType} exited with code ${code}]`);
|
|
301
|
+
// STALENESS GUARD, matching the persistent path's. Without it, an older turn's
|
|
302
|
+
// `close` clobbered the newer one: `run = "idle"` while process B was still working
|
|
303
|
+
// (so the brain acted on a half-done turn and could double-send), and `proc = null`
|
|
304
|
+
// so stop()/interrupt()/end() could no longer kill B — ending the session left a
|
|
305
|
+
// codex process still editing the repo, invisible to `alive`, diagnostics and
|
|
306
|
+
// kill-tmux.
|
|
307
|
+
if (this.proc !== proc)
|
|
308
|
+
return;
|
|
274
309
|
this.run = "idle";
|
|
275
310
|
this.proc = null;
|
|
276
311
|
});
|
|
277
312
|
}
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
313
|
+
/**
|
|
314
|
+
* No TTY in headless mode; control is via messages. Kept for interface parity — the human
|
|
315
|
+
* takeover path can still route a keypress here.
|
|
316
|
+
*
|
|
317
|
+
* RECORDED rather than silently dropped. As a pure no-op it was indistinguishable from
|
|
318
|
+
* success: `act` returned an ordinary snapshot with an unchanged pane, so the caller could not
|
|
319
|
+
* tell "sent, nothing happened" from "never sent". The transcript is what the brain and the
|
|
320
|
+
* console both read, so the truth belongs there.
|
|
321
|
+
*/
|
|
322
|
+
key(keys) {
|
|
323
|
+
this.push(`[ignored keypress ${keys.slice(0, 40)} — this session has no terminal attached]`);
|
|
281
324
|
}
|
|
282
325
|
/** Abort the current turn (SIGINT, like Ctrl-C). The process stays usable. */
|
|
283
326
|
interrupt() {
|
|
@@ -402,11 +445,53 @@ function stamp() {
|
|
|
402
445
|
* tokenize like the command line it looks like.
|
|
403
446
|
*/
|
|
404
447
|
export function parseCommand(command) {
|
|
448
|
+
const src = command ?? "";
|
|
405
449
|
const tokens = [];
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
450
|
+
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
|
451
|
+
let i = 0;
|
|
452
|
+
while (i < src.length) {
|
|
453
|
+
while (i < src.length && isSpace(src[i]))
|
|
454
|
+
i++;
|
|
455
|
+
if (i >= src.length)
|
|
456
|
+
break;
|
|
457
|
+
const start = i;
|
|
458
|
+
let out = "";
|
|
459
|
+
let quote = null;
|
|
460
|
+
while (i < src.length) {
|
|
461
|
+
const ch = src[i];
|
|
462
|
+
if (quote) {
|
|
463
|
+
if (ch === quote)
|
|
464
|
+
quote = null;
|
|
465
|
+
else
|
|
466
|
+
out += ch;
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (ch === '"' || ch === "'") {
|
|
471
|
+
quote = ch;
|
|
472
|
+
i++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (isSpace(ch))
|
|
476
|
+
break;
|
|
477
|
+
out += ch;
|
|
478
|
+
i++;
|
|
479
|
+
}
|
|
480
|
+
if (quote) {
|
|
481
|
+
// UNTERMINATED quote → it was a literal character, not a quote. `don't` is ordinary
|
|
482
|
+
// English in a user-edited preset; treating the apostrophe as an opening quote made
|
|
483
|
+
// `--append-system-prompt don't guess` reach the engine as three broken arguments.
|
|
484
|
+
i = start;
|
|
485
|
+
let raw = "";
|
|
486
|
+
while (i < src.length && !isSpace(src[i])) {
|
|
487
|
+
raw += src[i];
|
|
488
|
+
i++;
|
|
489
|
+
}
|
|
490
|
+
tokens.push(raw);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
tokens.push(out);
|
|
494
|
+
}
|
|
410
495
|
}
|
|
411
496
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
412
497
|
}
|
|
@@ -30,6 +30,16 @@ const CAPABILITIES = [
|
|
|
30
30
|
/** How many times a task may attempt an action before handing off to a human. */
|
|
31
31
|
export const MAX_AUTONOMOUS_ATTEMPTS = 3;
|
|
32
32
|
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
|
|
33
|
+
/**
|
|
34
|
+
* Tasks steered by a remote durable Workflow rather than executed by the runner.
|
|
35
|
+
*
|
|
36
|
+
* ONE list, because the two places that need it drifted: task creation already treated
|
|
37
|
+
* `browser.task` like `job.apply_agent`, but `resumeTakeover` did not — so a human resolving a
|
|
38
|
+
* stuck browse handoff destroyed the takeover session instead of handing control back, and the
|
|
39
|
+
* workflow then polled a dead session for 15 minutes before failing a run it had already
|
|
40
|
+
* completed.
|
|
41
|
+
*/
|
|
42
|
+
const WORKFLOW_DRIVEN_TASKS = new Set(["job.apply_agent", "browser.task"]);
|
|
33
43
|
const require = createRequire(import.meta.url);
|
|
34
44
|
export class LocalRunner {
|
|
35
45
|
config;
|
|
@@ -101,7 +111,7 @@ export class LocalRunner {
|
|
|
101
111
|
// steered by the remote Workflow brain via the /browser/* endpoints — the runner
|
|
102
112
|
// never auto-executes them. The task exists for the console board, the activity
|
|
103
113
|
// trace, and takeover keying.
|
|
104
|
-
if (
|
|
114
|
+
if (WORKFLOW_DRIVEN_TASKS.has(normalized.type)) {
|
|
105
115
|
const task = {
|
|
106
116
|
id: `task_${crypto.randomUUID()}`,
|
|
107
117
|
type: normalized.type,
|
|
@@ -374,10 +384,18 @@ export class LocalRunner {
|
|
|
374
384
|
if (!task)
|
|
375
385
|
throw new RunnerInputError("Task not found");
|
|
376
386
|
const page = session.page;
|
|
377
|
-
// Agent-driven
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
387
|
+
// Agent-driven runs are steered by the remote workflow. "Resume" here just signals the
|
|
388
|
+
// human finished the stuck step (or solved a captcha); the workflow polls humanDone and
|
|
389
|
+
// continues driving. Do NOT complete/submit.
|
|
390
|
+
//
|
|
391
|
+
// `browser.task` belongs here too, and its omission made a stuck handoff UNRESOLVABLE:
|
|
392
|
+
// `humanDone` is set ONLY in this branch, so a browse task fell through to the path below,
|
|
393
|
+
// which sets `status = "completed"` and calls `endTakeover()` — destroying the session.
|
|
394
|
+
// `browserHandoffStatus` then returns `{solved:false}` forever (no session), so the
|
|
395
|
+
// workflow polled for the full 15 minutes and closed the run "failed — stuck not resolved
|
|
396
|
+
// in time", on a task it had already marked completed. The console's Resume button is
|
|
397
|
+
// agent-agnostic, so the user pressing it was what broke the run.
|
|
398
|
+
if (WORKFLOW_DRIVEN_TASKS.has(task.type)) {
|
|
381
399
|
session.humanDone = true;
|
|
382
400
|
this.addTaskEvent(task, "job.resumed", "Human finished the step — handing back to the agent");
|
|
383
401
|
return { submitted: false, resumed: true, reason: "handed back to the agent — the agent is continuing" };
|
|
@@ -387,7 +405,7 @@ export class LocalRunner {
|
|
|
387
405
|
this.addTaskEvent(task, "job.human_challenge_present", `Challenge not yet solved: ${challenge}`);
|
|
388
406
|
return { submitted: false, reason: `The ${challenge} challenge isn't solved yet — complete it in the live view, then submit again.` };
|
|
389
407
|
}
|
|
390
|
-
// Only browser.open reaches here now (
|
|
408
|
+
// Only browser.open reaches here now (workflow-driven tasks return above) — it just
|
|
391
409
|
// needs the challenge cleared, no form submit.
|
|
392
410
|
this.addTaskEvent(task, "job.resumed", "Human cleared the challenge; resuming");
|
|
393
411
|
const output = {
|
|
@@ -992,8 +1010,21 @@ export class LocalRunner {
|
|
|
992
1010
|
const page = await this.getActivePage();
|
|
993
1011
|
this.takeovers.set(taskId, { page, reason, humanDone: false });
|
|
994
1012
|
const screenshotBase64 = await captureScreenshotDataUrl(page);
|
|
995
|
-
|
|
996
|
-
|
|
1013
|
+
// The takeover is registered ABOVE, unconditionally — that is what makes the live view work.
|
|
1014
|
+
// Everything below is the task's own bookkeeping, and it used to be skipped whenever no
|
|
1015
|
+
// LOCAL task existed. The engine sign-in relay mints its own id (`signin-<sessionId>`) for
|
|
1016
|
+
// a coding session that has no runner task at all, so the handoff registered silently and
|
|
1017
|
+
// none of the status flip, the message or the screenshot ever happened: the console
|
|
1018
|
+
// reported success and offered "take over the browser", while the takeover it pointed at
|
|
1019
|
+
// carried no context. A task row is created for it here rather than requiring the caller
|
|
1020
|
+
// to have made one, so the handoff is complete for every caller.
|
|
1021
|
+
let task = this.store.getTask(taskId);
|
|
1022
|
+
if (!task) {
|
|
1023
|
+
const now = new Date().toISOString();
|
|
1024
|
+
task = { id: taskId, type: "browser.handoff", status: "needs_human", input: {}, requiresApproval: false, createdAt: now, updatedAt: now };
|
|
1025
|
+
this.store.putTask(task);
|
|
1026
|
+
}
|
|
1027
|
+
{
|
|
997
1028
|
task.status = "needs_human";
|
|
998
1029
|
task.updatedAt = new Date().toISOString();
|
|
999
1030
|
this.store.putTask(task);
|