@prohost/cli 0.2.3 → 0.4.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/CHANGELOG.md +106 -0
- package/README.md +33 -1
- package/dist/agent/api.d.ts +53 -5
- package/dist/agent/api.js +51 -11
- package/dist/agent/claude.d.ts +53 -1
- package/dist/agent/claude.js +165 -20
- package/dist/agent/command.d.ts +24 -0
- package/dist/agent/command.js +89 -13
- package/dist/agent/contract.d.ts +52 -0
- package/dist/agent/contract.js +58 -0
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +77 -4
- package/dist/agent/exec.js +201 -18
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/prompt.d.ts +22 -0
- package/dist/agent/prompt.js +87 -2
- package/dist/agent/run.d.ts +51 -3
- package/dist/agent/run.js +392 -39
- package/dist/index.js +9 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/run.js
CHANGED
|
@@ -16,26 +16,61 @@
|
|
|
16
16
|
* * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
|
|
17
17
|
* attempt is mirrored to this socket. Without dedupe a retry would execute
|
|
18
18
|
* the command a second time and post a second reply.
|
|
19
|
+
*
|
|
20
|
+
* Runs can be long — hours — so every run this process has accepted and not yet
|
|
21
|
+
* reported is heartbeated (see `heartbeat.ts`), including the ones still queued
|
|
22
|
+
* behind the executing one. That keeps the server's lease alive and is the only
|
|
23
|
+
* way a stop pressed in the UI reaches a machine that accepts no connections.
|
|
19
24
|
*/
|
|
20
25
|
import os from 'node:os';
|
|
21
26
|
import { spawnSync } from 'node:child_process';
|
|
22
27
|
import WebSocket from 'ws';
|
|
23
28
|
import { completeRun, postReply } from './api.js';
|
|
24
|
-
import { claudeCommand, isClaudeExec, looksLikeResumeFailure,
|
|
29
|
+
import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
25
30
|
import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
26
31
|
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
27
32
|
import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
|
|
28
|
-
import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
|
|
33
|
+
import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
|
|
29
34
|
import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
|
|
30
35
|
import { execAgent } from './exec.js';
|
|
36
|
+
import { HeartbeatLoop } from './heartbeat.js';
|
|
31
37
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
32
38
|
import { RunStore } from './runstore.js';
|
|
33
39
|
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
34
40
|
import { verifySignature } from './signature.js';
|
|
35
41
|
import { isWebhookEvent } from '../forward.js';
|
|
36
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
37
|
-
/**
|
|
38
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Default ceiling on one agent invocation: none.
|
|
45
|
+
*
|
|
46
|
+
* The wall clock kept being raised — five minutes, then two hours — because it
|
|
47
|
+
* was answering the wrong question. "How long has this run been going?" does
|
|
48
|
+
* not distinguish a wedged child from a productive one; it only distinguishes
|
|
49
|
+
* long work from short, and killing long work is precisely the failure the two
|
|
50
|
+
* previous defaults each produced in production. What the harness can actually
|
|
51
|
+
* observe is whether the child is *doing* anything, which is what
|
|
52
|
+
* {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
|
|
53
|
+
*
|
|
54
|
+
* So a run is now bounded by three things, none of them a fixed duration:
|
|
55
|
+
* the inactivity watchdog (default 15 minutes of total silence), the server's
|
|
56
|
+
* lease on the run (heartbeats stop, the row is reaped), and the Stop button in
|
|
57
|
+
* ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
|
|
58
|
+
* remains fully supported for anyone who wants a hard ceiling as well.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
61
|
+
/**
|
|
62
|
+
* Default silence window before a run is treated as stalled.
|
|
63
|
+
*
|
|
64
|
+
* Fifteen minutes is chosen against what real work looks like, not against
|
|
65
|
+
* what feels responsive: an agent mid-task emits stream events, tool calls and
|
|
66
|
+
* log lines continuously, so the gaps in a healthy run are seconds. The long
|
|
67
|
+
* tail that legitimately goes quiet is a single slow tool call — a test suite,
|
|
68
|
+
* a build, a big fetch — and fifteen minutes clears those comfortably while
|
|
69
|
+
* still catching the failure this exists for, a child wedged on a stdin prompt
|
|
70
|
+
* that heartbeats look perfectly healthy for and that burns the operator's
|
|
71
|
+
* tokens and their agent's availability indefinitely.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
|
|
39
74
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
40
75
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
41
76
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
@@ -66,8 +101,18 @@ function backoffDelay(attempt) {
|
|
|
66
101
|
const cap = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
|
|
67
102
|
return Math.floor(Math.random() * cap);
|
|
68
103
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Backoff schedule for retrying a completion the server didn't accept.
|
|
106
|
+
*
|
|
107
|
+
* Roughly ten minutes in total, up from thirteen seconds. The old schedule was
|
|
108
|
+
* sized for a five-minute run: losing the completion meant losing five minutes
|
|
109
|
+
* of work, and a laptop that has been offline for thirteen seconds is usually
|
|
110
|
+
* offline for longer. Now a run can be four hours of an agent's work, which
|
|
111
|
+
* makes waiting out a wifi drop overwhelmingly the better bet — and the run
|
|
112
|
+
* keeps being heartbeated for the whole retry window, so the row it is trying
|
|
113
|
+
* to close stays alive rather than being reaped underneath the retries.
|
|
114
|
+
*/
|
|
115
|
+
const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000, 180_000, 240_000];
|
|
71
116
|
/**
|
|
72
117
|
* Complete a run, retrying transient failures.
|
|
73
118
|
*
|
|
@@ -338,6 +383,25 @@ function defaultProbe(command, cwd) {
|
|
|
338
383
|
});
|
|
339
384
|
return { status: result.status, stdout: result.stdout ?? '' };
|
|
340
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* What a killed plain command salvages: the last thing it printed.
|
|
388
|
+
*
|
|
389
|
+
* The streaming reader salvages the last thing the *agent* said, having parsed
|
|
390
|
+
* it out of the event stream. There is no parsing to do for a plain `--exec`
|
|
391
|
+
* command — its stdout is its reply — so its rolling tail is the same evidence
|
|
392
|
+
* reached the other way: what the run had produced before it stopped producing
|
|
393
|
+
* anything.
|
|
394
|
+
*
|
|
395
|
+
* Bounded to the same {@link MAX_SALVAGE_CHARS} for the same reason: the
|
|
396
|
+
* completion `error` this ends up in is capped server-side, and it has a stderr
|
|
397
|
+
* tail to carry as well.
|
|
398
|
+
*/
|
|
399
|
+
function partialStdout(tail) {
|
|
400
|
+
const text = tail.trim();
|
|
401
|
+
if (!text)
|
|
402
|
+
return undefined;
|
|
403
|
+
return text.length > MAX_SALVAGE_CHARS ? text.slice(-MAX_SALVAGE_CHARS) : text;
|
|
404
|
+
}
|
|
341
405
|
/**
|
|
342
406
|
* Run the agent command once and turn its output into a reply.
|
|
343
407
|
*
|
|
@@ -347,35 +411,151 @@ function defaultProbe(command, cwd) {
|
|
|
347
411
|
* tell a failed turn from a successful one. A plain command's behaviour here is
|
|
348
412
|
* byte-for-byte what it was before this function existed.
|
|
349
413
|
*/
|
|
350
|
-
async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
414
|
+
async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
351
415
|
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
352
416
|
// must not let one run take twice what the operator allowed. Both attempts
|
|
353
417
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
354
418
|
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
355
419
|
const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
420
|
+
// `--timeout 0`, and the default: no wall-clock ceiling at all. The run then
|
|
421
|
+
// ends when the agent ends it, when the watchdog below finds it silent, when
|
|
422
|
+
// somebody cancels it, or when the server stops renewing its lease — all of
|
|
423
|
+
// which are better judges of "stuck" than a fixed clock.
|
|
424
|
+
const capped = budgetMs > 0;
|
|
425
|
+
// Not shared between attempts the way the wall clock is: the watchdog bounds
|
|
426
|
+
// silence, and a fresh attempt starts a fresh silence.
|
|
427
|
+
const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
356
428
|
const startedAt = Date.now();
|
|
357
429
|
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
430
|
+
const timeLeft = () => !capped || remainingMs() > 0;
|
|
431
|
+
// Which surface's session this turn belongs to, and `undefined` on a dry run
|
|
432
|
+
// — which is what makes every `remember`/`forget` below a no-op there.
|
|
433
|
+
// `--dry-run` promises to make no writes, and a resumed turn is a write: both
|
|
434
|
+
// brains record it in their transcript whether or not we post the reply, so
|
|
435
|
+
// remembering the session would leave the next *real* run continuing from an
|
|
436
|
+
// answer nobody saw. Resolved once, up here, because the streaming reader
|
|
437
|
+
// records it mid-run rather than at the end of the turn.
|
|
438
|
+
const sessionKey = options.dryRun ? undefined : sessionKeyFor(run);
|
|
439
|
+
let recordedSession;
|
|
440
|
+
/** The reader for the attempt currently running, if this brain streams. */
|
|
441
|
+
let stream;
|
|
442
|
+
/**
|
|
443
|
+
* Rolling tail of a plain command's stdout — the last thing it printed.
|
|
444
|
+
*
|
|
445
|
+
* Kept as the output arrives rather than sliced off the captured `stdout` at
|
|
446
|
+
* the end, because capture is bounded at {@link MAX_OUTPUT_CHARS} from the
|
|
447
|
+
* *front*: a chatty command that overran that and then stalled would
|
|
448
|
+
* otherwise salvage half a megabyte of stale opening output and call it the
|
|
449
|
+
* unfinished answer.
|
|
450
|
+
*/
|
|
451
|
+
let tail = '';
|
|
452
|
+
/**
|
|
453
|
+
* Act on what the stream has said so far.
|
|
454
|
+
*
|
|
455
|
+
* The session id is written to the ledger **as soon as it is announced**,
|
|
456
|
+
* which is a deliberate departure from "record it once the reply is durable".
|
|
457
|
+
* That rule protects against continuing a session whose answer nobody saw;
|
|
458
|
+
* it cannot protect a run that is killed at hour two, because that run never
|
|
459
|
+
* reaches the end where the recording used to happen — and losing the id
|
|
460
|
+
* loses the only handle on two hours of the agent's context. The rejected-
|
|
461
|
+
* reply case is handled where it happens instead, by forgetting again.
|
|
462
|
+
*/
|
|
463
|
+
const observe = (reader) => {
|
|
464
|
+
const id = reader.sessionId();
|
|
465
|
+
if (id && id !== recordedSession) {
|
|
466
|
+
recordedSession = id;
|
|
467
|
+
runtime.sessions.remember(sessionKey, id);
|
|
468
|
+
}
|
|
469
|
+
const activity = reader.activity();
|
|
470
|
+
if (activity)
|
|
471
|
+
control.report(activity);
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Undo an eagerly recorded Claude session after an ordinary failed turn.
|
|
475
|
+
*
|
|
476
|
+
* Timeouts and cancellations deliberately keep their session so the next
|
|
477
|
+
* delivery can continue a long-running turn whose context would otherwise be
|
|
478
|
+
* lost. A command or parser failure has no such continuity guarantee: its
|
|
479
|
+
* answer was never made durable, so resuming it would skip an unseen turn.
|
|
480
|
+
*/
|
|
481
|
+
const forgetRecordedSession = () => {
|
|
482
|
+
if (!recordedSession)
|
|
483
|
+
return;
|
|
484
|
+
runtime.sessions.forget(sessionKey);
|
|
485
|
+
recordedSession = undefined;
|
|
486
|
+
};
|
|
487
|
+
const exec = (command) => {
|
|
488
|
+
// A fresh reader per attempt: a retry without `--resume` is a different
|
|
489
|
+
// session, and inheriting the failed attempt's id would resume nothing.
|
|
490
|
+
const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
|
|
491
|
+
stream = reader;
|
|
492
|
+
tail = '';
|
|
493
|
+
return execAgent({
|
|
494
|
+
command,
|
|
495
|
+
prompt,
|
|
496
|
+
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
497
|
+
idleTimeoutMs: idleMs,
|
|
498
|
+
cwd: runtime.workdir,
|
|
499
|
+
env: mcp.execEnv,
|
|
500
|
+
spawnImpl: options.spawnImpl,
|
|
501
|
+
cancelSignal: control.signal,
|
|
502
|
+
onStdout: reader
|
|
503
|
+
? (chunk) => {
|
|
504
|
+
reader.push(chunk);
|
|
505
|
+
observe(reader);
|
|
506
|
+
}
|
|
507
|
+
: runtime.brain === 'plain'
|
|
508
|
+
? (chunk) => {
|
|
509
|
+
tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
|
|
510
|
+
}
|
|
511
|
+
: undefined,
|
|
512
|
+
});
|
|
513
|
+
};
|
|
366
514
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
367
515
|
// rather than what the last attempt took.
|
|
368
516
|
const elapsed = () => Date.now() - startedAt;
|
|
369
517
|
if (mcp.error) {
|
|
370
518
|
return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
|
|
371
519
|
}
|
|
372
|
-
const failure = (result) =>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
520
|
+
const failure = (result) => {
|
|
521
|
+
// A killed run has no reply, but it usually has most of one. Reporting the
|
|
522
|
+
// tail of what the agent was writing turns "no output for 15 minutes" into
|
|
523
|
+
// something the person waiting can act on — and it is the only trace left
|
|
524
|
+
// of however many hours of work. A stalled run is the case this matters
|
|
525
|
+
// most for: the last thing it said is the best clue to what it stalled on.
|
|
526
|
+
// A plain command has no stream to salvage a parsed answer from, but its
|
|
527
|
+
// stdout *is* its answer by contract — so the tail of what it managed to
|
|
528
|
+
// print is the same evidence, reached the other way. Without this a stalled
|
|
529
|
+
// plain run reports nothing but the fact that it stalled.
|
|
530
|
+
//
|
|
531
|
+
// Deliberately NOT extended to the shaped brains. Both are run with
|
|
532
|
+
// structured output (`--output-format stream-json`, `--json`), whose
|
|
533
|
+
// stream carries the agent's reasoning and the raw output of every local
|
|
534
|
+
// command and tool call it made — which `readClaudeStream`/
|
|
535
|
+
// `parseCodexOutput` exist to filter out. This string is persisted in the
|
|
536
|
+
// completion and echoed into a workspace thread, so tailing that stream
|
|
537
|
+
// raw would publish local shell output, and whatever was in it, to
|
|
538
|
+
// everyone who can read the conversation. A shaped brain whose parser
|
|
539
|
+
// salvaged nothing salvages nothing.
|
|
540
|
+
const killed = result.timedOut || result.idleTimedOut || result.cancelled;
|
|
541
|
+
const salvaged = killed
|
|
542
|
+
? (stream?.salvage() ?? (runtime.brain === 'plain' ? partialStdout(tail) : undefined))
|
|
543
|
+
: undefined;
|
|
544
|
+
// The stderr tail yields room when there is a salvaged answer to carry: the
|
|
545
|
+
// completion `error` is capped server-side, and of the two, what the agent
|
|
546
|
+
// was saying beats the last 500 characters of its logging.
|
|
547
|
+
const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
|
|
548
|
+
const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
|
|
549
|
+
.filter((part) => part.length > 0)
|
|
550
|
+
.join('\n');
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
reason: result.error ??
|
|
554
|
+
(result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
|
|
555
|
+
detail,
|
|
556
|
+
durationMs: elapsed(),
|
|
557
|
+
};
|
|
558
|
+
};
|
|
379
559
|
if (runtime.brain === 'plain') {
|
|
380
560
|
const result = await exec(options.exec);
|
|
381
561
|
if (!result.ok)
|
|
@@ -385,12 +565,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
385
565
|
? { ok: true, reply, durationMs: elapsed() }
|
|
386
566
|
: { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
|
|
387
567
|
}
|
|
388
|
-
|
|
389
|
-
// writes, and a resumed turn is a write: both brains record it in their
|
|
390
|
-
// transcript whether or not we post the reply, so remembering the session
|
|
391
|
-
// would leave the next *real* run continuing from an answer nobody saw.
|
|
392
|
-
const key = options.dryRun ? undefined : sessionKeyFor(run);
|
|
393
|
-
const sessionId = runtime.sessions.get(key);
|
|
568
|
+
const sessionId = runtime.sessions.get(sessionKey);
|
|
394
569
|
const codex = runtime.brain === 'codex';
|
|
395
570
|
// Same three questions for both brains — resume this thread, wire these
|
|
396
571
|
// tools, gate or don't — asked with each one's own flags.
|
|
@@ -416,13 +591,23 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
416
591
|
// Skipped rather than squeezed into a token budget: a retry with no time
|
|
417
592
|
// left would be killed on the spot and reported as a timeout, which is a
|
|
418
593
|
// worse error message than the resume failure it replaced.
|
|
419
|
-
|
|
594
|
+
timeLeft() &&
|
|
595
|
+
// A run somebody just stopped does not get started again on its way out.
|
|
596
|
+
!control.signal.aborted) {
|
|
420
597
|
log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
|
|
421
|
-
runtime.sessions.forget(
|
|
598
|
+
runtime.sessions.forget(sessionKey);
|
|
599
|
+
recordedSession = undefined;
|
|
422
600
|
result = await exec(build());
|
|
423
601
|
}
|
|
424
|
-
if (!result.ok)
|
|
602
|
+
if (!result.ok) {
|
|
603
|
+
// A run we killed keeps its session, whichever clock killed it: the
|
|
604
|
+
// transcript is real, it holds however long the agent worked, and the next
|
|
605
|
+
// delivery on that surface can resume it. Only a turn that failed on its
|
|
606
|
+
// own — a crash, an unparseable answer — has no continuity worth keeping.
|
|
607
|
+
if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
|
|
608
|
+
forgetRecordedSession();
|
|
425
609
|
return failure(result);
|
|
610
|
+
}
|
|
426
611
|
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
427
612
|
// the reply just quietly lacks whatever the tool would have found. Printing
|
|
428
613
|
// it is the difference between "the agent seems dumb" and "the agent asked
|
|
@@ -445,9 +630,15 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
445
630
|
}
|
|
446
631
|
return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
|
|
447
632
|
}
|
|
448
|
-
|
|
633
|
+
// The reply comes out of the stream's own `result` event, so a run whose
|
|
634
|
+
// stdout we watched arrives already parsed. `finish` still gets the captured
|
|
635
|
+
// stdout: a command that emitted no result event at all — a login prompt, a
|
|
636
|
+
// crash banner, a `claude` that ignored `--output-format` — is a failure, and
|
|
637
|
+
// the whole-output parser is what says so in words.
|
|
638
|
+
const parsed = (stream ?? readClaudeStream()).finish(result.stdout);
|
|
449
639
|
reportDenials(parsed.denials);
|
|
450
640
|
if (!parsed.ok) {
|
|
641
|
+
forgetRecordedSession();
|
|
451
642
|
return {
|
|
452
643
|
ok: false,
|
|
453
644
|
reason: 'the agent did not produce a usable reply',
|
|
@@ -455,7 +646,13 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
455
646
|
durationMs: elapsed(),
|
|
456
647
|
};
|
|
457
648
|
}
|
|
458
|
-
return {
|
|
649
|
+
return {
|
|
650
|
+
ok: true,
|
|
651
|
+
reply: parsed.text,
|
|
652
|
+
durationMs: elapsed(),
|
|
653
|
+
sessionId: parsed.sessionId,
|
|
654
|
+
sessionRecorded: recordedSession !== undefined,
|
|
655
|
+
};
|
|
459
656
|
}
|
|
460
657
|
/**
|
|
461
658
|
* Handle one verified run end-to-end.
|
|
@@ -463,7 +660,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
463
660
|
* Every path except a dry run — including every failure — calls the completion
|
|
464
661
|
* endpoint before returning.
|
|
465
662
|
*/
|
|
466
|
-
async function handleRun(run, options, runtime, api, log, sleep) {
|
|
663
|
+
async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
467
664
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
468
665
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
469
666
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
@@ -480,9 +677,38 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
480
677
|
// it is. See PromptContext.mcpToolPattern.
|
|
481
678
|
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
482
679
|
toolsGated: !runtime.skipPermissions,
|
|
680
|
+
// The real numbers, not a vague "there is a limit" — see PromptContext.
|
|
681
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
682
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
|
483
683
|
});
|
|
484
|
-
const result = await runTurn(run, prompt, options, runtime, mcp, log);
|
|
684
|
+
const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
|
|
485
685
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
686
|
+
// Stopped from outside. Checked before the failure branch below, because a
|
|
687
|
+
// stopped run *is* a failed command — reporting it as one would put "timed
|
|
688
|
+
// out"-shaped noise in a thread where somebody deliberately pressed stop.
|
|
689
|
+
const stopped = control.disposition();
|
|
690
|
+
if (stopped === 'resolved') {
|
|
691
|
+
// The server already closed this run: reaped after its lease lapsed, or
|
|
692
|
+
// resolved by something else. There is nothing left to post a reply to and
|
|
693
|
+
// nothing left to complete — a completion would just 409.
|
|
694
|
+
log(`${timestamp()} ⏹ ${label} was already closed server-side (${seconds}s) — nothing was posted`);
|
|
695
|
+
return 'resolved';
|
|
696
|
+
}
|
|
697
|
+
if (stopped === 'cancelled') {
|
|
698
|
+
log(`${timestamp()} ⏹ ${label} cancelled after ${seconds}s`);
|
|
699
|
+
if (options.dryRun) {
|
|
700
|
+
log(' [dry-run] would report the run as cancelled');
|
|
701
|
+
return 'skipped';
|
|
702
|
+
}
|
|
703
|
+
// The exact marker string, alone: the server words the thread notice from
|
|
704
|
+
// it, and anything wrapped around it reads as a crash instead of a stop.
|
|
705
|
+
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
706
|
+
if (!closed.ok) {
|
|
707
|
+
log(` ! could not report the cancellation (HTTP ${closed.status}) ${closed.error ?? ''}`);
|
|
708
|
+
return 'unreported';
|
|
709
|
+
}
|
|
710
|
+
return 'cancelled';
|
|
711
|
+
}
|
|
486
712
|
if (!result.ok) {
|
|
487
713
|
const { reason } = result;
|
|
488
714
|
const detail = result.detail ?? '';
|
|
@@ -533,6 +759,12 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
533
759
|
const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
|
|
534
760
|
if (!posted.ok) {
|
|
535
761
|
log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
|
|
762
|
+
// Undo the mid-run recording. The session exists and the agent's transcript
|
|
763
|
+
// holds this turn, but the conversation never received its answer, so
|
|
764
|
+
// continuing it would skip reasoning nobody can see — the same rule that
|
|
765
|
+
// used to be expressed by simply not recording until the reply landed.
|
|
766
|
+
if (result.sessionRecorded)
|
|
767
|
+
runtime.sessions.forget(sessionKeyFor(run));
|
|
536
768
|
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
|
|
537
769
|
return reported.ok ? 'failed' : 'unreported';
|
|
538
770
|
}
|
|
@@ -588,9 +820,79 @@ export async function runAgentHarness(options) {
|
|
|
588
820
|
// duplicate — the run is in flight right here. Only a run in the ledger that
|
|
589
821
|
// this process never handled is a candidate for restart recovery.
|
|
590
822
|
const handledThisSession = new Set();
|
|
823
|
+
/** Accepted, not yet reported — the executing run and everything queued. */
|
|
824
|
+
const accepted = new Map();
|
|
825
|
+
const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
|
|
591
826
|
let queue = Promise.resolve();
|
|
592
827
|
let fatal;
|
|
593
828
|
let attempt = 0;
|
|
829
|
+
/** Stop tracking a run — it has reached a terminal state, one way or another. */
|
|
830
|
+
const release = (runId) => {
|
|
831
|
+
accepted.delete(runId);
|
|
832
|
+
heartbeats.untrack(runId);
|
|
833
|
+
};
|
|
834
|
+
/**
|
|
835
|
+
* Work started from a heartbeat that the harness must still wait for on the
|
|
836
|
+
* way out, but that a heartbeat tick must not wait for.
|
|
837
|
+
*
|
|
838
|
+
* Closing out a cancelled queued run can take the whole completion-retry
|
|
839
|
+
* schedule — around ten minutes against an unreachable server — and beats do
|
|
840
|
+
* not overlap. Awaited inside the tick, one unreachable completion would stop
|
|
841
|
+
* every other run being heartbeated, and the four-hour job it was queued
|
|
842
|
+
* behind would be reaped for going quiet.
|
|
843
|
+
*/
|
|
844
|
+
const background = [];
|
|
845
|
+
/**
|
|
846
|
+
* Somebody pressed stop in ProhostAI.
|
|
847
|
+
*
|
|
848
|
+
* The two cases are genuinely different work. An executing run reports
|
|
849
|
+
* itself: the abort reaches `execAgent`, which signals the process group the
|
|
850
|
+
* same way a timeout does, and `handleRun` completes it with the marker
|
|
851
|
+
* error. A run still in the queue has nobody to report it — it will never
|
|
852
|
+
* execute, and the queue is about to skip it — so it is closed out here.
|
|
853
|
+
*/
|
|
854
|
+
const cancelAccepted = async (entry) => {
|
|
855
|
+
if (entry.disposition)
|
|
856
|
+
return;
|
|
857
|
+
entry.disposition = 'cancelled';
|
|
858
|
+
if (entry.executing) {
|
|
859
|
+
entry.controller.abort();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
release(entry.run.run_id);
|
|
863
|
+
log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was cancelled before it started`);
|
|
864
|
+
// `--dry-run` promises to make no writes, and a completion is the most
|
|
865
|
+
// terminal write there is. The executing-run path defers to the same check
|
|
866
|
+
// inside `handleRun`; this one has to make it itself, because a queued run
|
|
867
|
+
// never reaches `handleRun` at all.
|
|
868
|
+
if (options.dryRun) {
|
|
869
|
+
log(' [dry-run] would report the run as cancelled');
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const closed = await completeWithRetry(api, entry.run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
873
|
+
if (closed.ok)
|
|
874
|
+
runs.markCompleted(entry.run.run_id);
|
|
875
|
+
metrics.runsFailed += 1;
|
|
876
|
+
};
|
|
877
|
+
/**
|
|
878
|
+
* The server has already closed this run out — it was reaped, or resolved by
|
|
879
|
+
* something else. Either way nothing this machine does with it can land, so
|
|
880
|
+
* the only correct move is to stop: kill the command if one is running, and
|
|
881
|
+
* post nothing at all. A completion here would be a 409 by definition.
|
|
882
|
+
*/
|
|
883
|
+
const resolveAccepted = (entry) => {
|
|
884
|
+
if (entry.disposition)
|
|
885
|
+
return;
|
|
886
|
+
entry.disposition = 'resolved';
|
|
887
|
+
if (entry.executing) {
|
|
888
|
+
entry.controller.abort();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
release(entry.run.run_id);
|
|
892
|
+
// Terminal server-side, so a redelivery must not start it either.
|
|
893
|
+
runs.markCompleted(entry.run.run_id);
|
|
894
|
+
log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was closed server-side before it started`);
|
|
895
|
+
};
|
|
594
896
|
const exhausted = () => options.maxConnections !== undefined && metrics.connections >= options.maxConnections;
|
|
595
897
|
while (!options.signal?.aborted && fatal === undefined && !exhausted()) {
|
|
596
898
|
metrics.connections += 1;
|
|
@@ -604,8 +906,14 @@ export async function runAgentHarness(options) {
|
|
|
604
906
|
log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
|
|
605
907
|
await sleep(delay);
|
|
606
908
|
}
|
|
607
|
-
// Let an in-flight run finish reporting before we hand back metrics
|
|
909
|
+
// Let an in-flight run finish reporting before we hand back metrics — and
|
|
910
|
+
// any cancellation the heartbeat loop started and deliberately did not wait
|
|
911
|
+
// for, so the harness never exits with a run half-closed.
|
|
608
912
|
await queue;
|
|
913
|
+
// Stopped first, so no further tick can start work after the wait below has
|
|
914
|
+
// taken its snapshot of `background`.
|
|
915
|
+
heartbeats.stop();
|
|
916
|
+
await Promise.all(background);
|
|
609
917
|
if (fatal !== undefined) {
|
|
610
918
|
log(`${timestamp()} ✗ ${fatal}`);
|
|
611
919
|
metrics.fatal = fatal;
|
|
@@ -753,13 +1061,36 @@ export async function runAgentHarness(options) {
|
|
|
753
1061
|
// redelivery execute the agent a second time.
|
|
754
1062
|
runs.markStarted(run.run_id, run.completion_path);
|
|
755
1063
|
metrics.runsStarted += 1;
|
|
1064
|
+
// Heartbeat from the moment it is accepted, not from the moment it
|
|
1065
|
+
// starts. The row is `running` server-side either way, and a run that
|
|
1066
|
+
// waits an hour behind a long job is reaped exactly like one that has
|
|
1067
|
+
// been working for an hour.
|
|
1068
|
+
const entry = { run, controller: new AbortController(), executing: false };
|
|
1069
|
+
accepted.set(run.run_id, entry);
|
|
1070
|
+
heartbeats.track(run.run_id, {
|
|
1071
|
+
path: heartbeatPathFor(run),
|
|
1072
|
+
progress: () => entry.progress,
|
|
1073
|
+
// Deliberately not awaited here — see `background`.
|
|
1074
|
+
onCancelRequested: () => {
|
|
1075
|
+
background.push(cancelAccepted(entry).catch((err) => {
|
|
1076
|
+
log(`${timestamp()} ✗ closing cancelled run ${run.run_id} failed: ${String(err)}`);
|
|
1077
|
+
}));
|
|
1078
|
+
},
|
|
1079
|
+
onResolved: () => resolveAccepted(entry),
|
|
1080
|
+
});
|
|
756
1081
|
// Serialize execution but never block the socket reader: the ack and
|
|
757
1082
|
// the heartbeat have to keep flowing while the agent thinks.
|
|
758
1083
|
queue = queue.then(async () => {
|
|
1084
|
+
// Settled while it waited — cancelled, or closed server-side. Both
|
|
1085
|
+
// were dealt with where they were decided; there is nothing to run
|
|
1086
|
+
// and nothing left to report.
|
|
1087
|
+
if (entry.disposition)
|
|
1088
|
+
return;
|
|
759
1089
|
// Shutdown was requested while this sat in the queue. Don't start a
|
|
760
1090
|
// fresh command the operator is waiting to be rid of — but do close
|
|
761
1091
|
// the run out, so the server isn't left waiting on it either.
|
|
762
1092
|
if (options.signal?.aborted) {
|
|
1093
|
+
release(run.run_id);
|
|
763
1094
|
log(`${timestamp()} ⏹ run ${run.run_id.slice(0, 12)} dropped — harness is shutting down`);
|
|
764
1095
|
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: 'the harness shut down before this run started' }, log, sleep);
|
|
765
1096
|
if (closed.ok)
|
|
@@ -767,20 +1098,42 @@ export async function runAgentHarness(options) {
|
|
|
767
1098
|
metrics.runsFailed += 1;
|
|
768
1099
|
return;
|
|
769
1100
|
}
|
|
770
|
-
|
|
1101
|
+
// Set before the first `await`, so a cancellation arriving from the
|
|
1102
|
+
// heartbeat either sees a queued run (and closes it out itself) or an
|
|
1103
|
+
// executing one (and aborts it) — never both, and never neither.
|
|
1104
|
+
entry.executing = true;
|
|
1105
|
+
const control = {
|
|
1106
|
+
signal: entry.controller.signal,
|
|
1107
|
+
disposition: () => entry.disposition,
|
|
1108
|
+
report: (activity) => {
|
|
1109
|
+
entry.progress = activity;
|
|
1110
|
+
},
|
|
1111
|
+
};
|
|
1112
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep, control).catch((err) => {
|
|
771
1113
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
772
1114
|
return 'failed';
|
|
773
1115
|
});
|
|
1116
|
+
// Held until here on purpose: heartbeats keep the row alive for the
|
|
1117
|
+
// whole of `handleRun`, including the ten minutes of completion
|
|
1118
|
+
// retries at the end of it.
|
|
1119
|
+
release(run.run_id);
|
|
774
1120
|
// Both terminal outcomes reached the completion endpoint; only then
|
|
775
1121
|
// is the run settled and safe to skip on redelivery.
|
|
776
1122
|
if (outcome === 'succeeded')
|
|
777
1123
|
metrics.runsSucceeded += 1;
|
|
778
|
-
|
|
1124
|
+
// A cancellation is reported to the server as `failed`, and counted
|
|
1125
|
+
// the same way here — the run did not produce the answer it was for.
|
|
1126
|
+
if (outcome === 'failed' || outcome === 'unreported' || outcome === 'cancelled') {
|
|
779
1127
|
metrics.runsFailed += 1;
|
|
1128
|
+
}
|
|
780
1129
|
// Only a run the server acknowledged as terminal is settled; an
|
|
781
1130
|
// unreported one stays open so a redelivery can report it.
|
|
782
|
-
if (outcome === 'succeeded' ||
|
|
1131
|
+
if (outcome === 'succeeded' ||
|
|
1132
|
+
outcome === 'failed' ||
|
|
1133
|
+
outcome === 'cancelled' ||
|
|
1134
|
+
outcome === 'resolved') {
|
|
783
1135
|
runs.markCompleted(run.run_id);
|
|
1136
|
+
}
|
|
784
1137
|
});
|
|
785
1138
|
});
|
|
786
1139
|
ws.on('close', () => {
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ function printHelp() {
|
|
|
25
25
|
'Usage:',
|
|
26
26
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
27
27
|
' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
|
|
28
|
-
" prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
28
|
+
" prohost agent run --exec '<command>' [--idle-timeout <seconds>] [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
29
29
|
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] Keep the agent running",
|
|
30
30
|
' prohost agent uninstall-daemon',
|
|
31
31
|
'',
|
|
@@ -43,7 +43,12 @@ function printHelp() {
|
|
|
43
43
|
'',
|
|
44
44
|
'agent run flags:',
|
|
45
45
|
' --exec Command to run for each mention; the prompt arrives on stdin (required)',
|
|
46
|
-
' --timeout
|
|
46
|
+
' --idle-timeout Seconds a run may produce NO output at all before it is treated as',
|
|
47
|
+
' stalled and killed. Defaults to 900 (15 minutes). Any byte on stdout or',
|
|
48
|
+
' stderr resets it, so a working agent never trips it. Pass 0 to disable.',
|
|
49
|
+
' --timeout Seconds one run may take before it is killed, however busy it is.',
|
|
50
|
+
' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
|
|
51
|
+
' one. Set it if you want a hard ceiling as well as the stall watchdog.',
|
|
47
52
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
48
53
|
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
49
54
|
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
|
@@ -69,7 +74,8 @@ function printHelp() {
|
|
|
69
74
|
'',
|
|
70
75
|
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
71
76
|
' --exec The same command you would pass to `agent run` (required).',
|
|
72
|
-
' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools
|
|
77
|
+
' --workdir, --idle-timeout, --timeout, --machine, --url, --allow-unverified, --safe-tools',
|
|
78
|
+
' — passed to `agent run`.',
|
|
73
79
|
'',
|
|
74
80
|
'Global flags:',
|
|
75
81
|
' --help, -h Show this help and exit.',
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.
|
|
10
|
+
export declare const CLI_VERSION = "0.4.0";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.4.0";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.
|
|
10
|
+
export const CLI_VERSION = '0.4.0';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|