@prohost/cli 0.2.3 → 0.3.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 +54 -0
- package/README.md +23 -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.js +11 -2
- package/dist/agent/contract.d.ts +39 -0
- package/dist/agent/contract.js +47 -0
- package/dist/agent/exec.d.ts +42 -4
- package/dist/agent/exec.js +104 -18
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +50 -2
- package/dist/agent/run.d.ts +25 -3
- package/dist/agent/run.js +312 -39
- package/dist/index.js +3 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/prompt.js
CHANGED
|
@@ -6,6 +6,50 @@
|
|
|
6
6
|
* a shell script that greps it. The reply contract is stated explicitly so an
|
|
7
7
|
* agent that would otherwise narrate its reasoning returns something sendable.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Say the run's budget in a unit a reader thinks in.
|
|
11
|
+
*
|
|
12
|
+
* Rounded on purpose — the agent is being told roughly how much room it has,
|
|
13
|
+
* and "about 120 minutes" is the honest shape of a ceiling it will never be
|
|
14
|
+
* told the exact remainder of.
|
|
15
|
+
*/
|
|
16
|
+
function renderBudget(timeoutMs) {
|
|
17
|
+
if (timeoutMs >= 90 * 60_000) {
|
|
18
|
+
const hours = timeoutMs / 3_600_000;
|
|
19
|
+
return `about ${Number.isInteger(hours) ? hours : hours.toFixed(1)} hours`;
|
|
20
|
+
}
|
|
21
|
+
if (timeoutMs >= 60_000)
|
|
22
|
+
return `about ${Math.round(timeoutMs / 60_000)} minutes`;
|
|
23
|
+
return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The bullet that tells the agent how much time it actually has.
|
|
27
|
+
*
|
|
28
|
+
* Both branches are load-bearing. A named ceiling stops an agent from treating
|
|
29
|
+
* a two-hour budget like a five-minute one (the failure this replaced: a
|
|
30
|
+
* multi-minute coding task killed at 300s having been told only that a limit
|
|
31
|
+
* existed). And an *uncapped* run still needs a word about pace, because
|
|
32
|
+
* nothing is posted until the turn ends — a run that explores forever reads,
|
|
33
|
+
* from the thread, exactly like one that crashed.
|
|
34
|
+
*/
|
|
35
|
+
function timeBudgetLines(timeoutMs) {
|
|
36
|
+
if (timeoutMs === undefined)
|
|
37
|
+
return [];
|
|
38
|
+
if (timeoutMs <= 0) {
|
|
39
|
+
return [
|
|
40
|
+
'- There is no fixed time limit on this turn, so work that genuinely takes',
|
|
41
|
+
' a while is fine — finish it rather than cutting it short. But nothing you',
|
|
42
|
+
' write is posted until the turn ends, and a long silence gets reported as a',
|
|
43
|
+
' stalled run, so keep moving toward an answer rather than exploring',
|
|
44
|
+
' indefinitely.',
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
`- You have ${renderBudget(timeoutMs)} for this whole turn. Past that you are killed`,
|
|
49
|
+
' mid-thought and the person waiting gets nothing, so spend the budget on the',
|
|
50
|
+
' work and leave yourself room to write the answer.',
|
|
51
|
+
];
|
|
52
|
+
}
|
|
9
53
|
/**
|
|
10
54
|
* Describe one attachment.
|
|
11
55
|
*
|
|
@@ -139,6 +183,11 @@ export function buildPrompt(run, context) {
|
|
|
139
183
|
' as you call them. Do the work rather than proposing it — but that\n' +
|
|
140
184
|
' also means a mistaken action happens for real, so check before you\n' +
|
|
141
185
|
' change or delete anything.', '- Nothing about your access changes after this turn. Do not say a tool is', ' "connecting", "loading", or about to become available, and do not', ' promise to follow up.',
|
|
186
|
+
// Stated up here with the other facts about the shape of the invocation,
|
|
187
|
+
// not buried under the tool advice: how long the agent has changes how it
|
|
188
|
+
// should approach the work, so it has to be read before any of the "go and
|
|
189
|
+
// look" bullets below.
|
|
190
|
+
...timeBudgetLines(context.timeoutMs),
|
|
142
191
|
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
143
192
|
// them — so a run that inspects its loaded tools concludes it cannot
|
|
144
193
|
// browse and says so to a customer, when one ToolSearch call away it
|
|
@@ -169,8 +218,7 @@ export function buildPrompt(run, context) {
|
|
|
169
218
|
' tell anyone you have no access to some repository, codebase, log, or',
|
|
170
219
|
' capability, take a QUICK look for it: the usual places a checkout',
|
|
171
220
|
' lives, `gh auth status`, whether the command is on PATH. A quick',
|
|
172
|
-
' look, not an audit —
|
|
173
|
-
' waiting at the end of it.',
|
|
221
|
+
' look, not an audit — there is somebody waiting at the end of this.',
|
|
174
222
|
'- "Looking" means running the command, not inspecting your own tool',
|
|
175
223
|
' list. Say you have no shell, no file access, or no checkout only',
|
|
176
224
|
' after an attempt failed and you can quote what it said. Introspection',
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -16,12 +16,28 @@
|
|
|
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 type { spawn } from 'node:child_process';
|
|
21
26
|
import WebSocket from 'ws';
|
|
22
27
|
import type { AgentCredentials } from './credentials.js';
|
|
23
|
-
/**
|
|
24
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Default ceiling on one agent invocation.
|
|
30
|
+
*
|
|
31
|
+
* Two hours, up from five minutes. Five was a guess made when a run meant
|
|
32
|
+
* "answer a mention", and it was wrong in the expensive direction: a paired
|
|
33
|
+
* brain given a real coding task in production was killed at 300s, mid-edit,
|
|
34
|
+
* and reported `timed out after 300000ms` to the person waiting. A coding
|
|
35
|
+
* agent's unit of work is not a chat reply, and no local clock can tell a
|
|
36
|
+
* wedged run from a productive one — the server's lease can, which is what
|
|
37
|
+
* heartbeats are for. This is now a backstop against a genuinely stuck process
|
|
38
|
+
* rather than a shape imposed on the work; `--timeout 0` removes it entirely.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DEFAULT_EXEC_TIMEOUT_MS = 7200000;
|
|
25
41
|
export interface AgentRunOptions {
|
|
26
42
|
/** Shell command that runs the agent, e.g. ``claude -p``. */
|
|
27
43
|
exec: string;
|
|
@@ -30,7 +46,7 @@ export interface AgentRunOptions {
|
|
|
30
46
|
url?: string;
|
|
31
47
|
/** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
|
|
32
48
|
machine?: string;
|
|
33
|
-
/** Per-run execution timeout. */
|
|
49
|
+
/** Per-run execution timeout. ``0`` disables it — see `--timeout 0`. */
|
|
34
50
|
timeoutMs?: number;
|
|
35
51
|
/** Print the reply that would be posted and make no writes. */
|
|
36
52
|
dryRun?: boolean;
|
|
@@ -73,6 +89,12 @@ export interface AgentRunOptions {
|
|
|
73
89
|
};
|
|
74
90
|
/** Environment used to locate the run ledger (tests point this at a temp dir). */
|
|
75
91
|
env?: NodeJS.ProcessEnv;
|
|
92
|
+
/**
|
|
93
|
+
* How often open runs are heartbeated. Defaults to
|
|
94
|
+
* {@link HEARTBEAT_INTERVAL_MS}; tests shorten it so a cancellation arrives
|
|
95
|
+
* inside a test's lifetime.
|
|
96
|
+
*/
|
|
97
|
+
heartbeatIntervalMs?: number;
|
|
76
98
|
}
|
|
77
99
|
export interface AgentRunMetrics {
|
|
78
100
|
connections: number;
|
package/dist/agent/run.js
CHANGED
|
@@ -16,26 +16,43 @@
|
|
|
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 { 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.
|
|
45
|
+
*
|
|
46
|
+
* Two hours, up from five minutes. Five was a guess made when a run meant
|
|
47
|
+
* "answer a mention", and it was wrong in the expensive direction: a paired
|
|
48
|
+
* brain given a real coding task in production was killed at 300s, mid-edit,
|
|
49
|
+
* and reported `timed out after 300000ms` to the person waiting. A coding
|
|
50
|
+
* agent's unit of work is not a chat reply, and no local clock can tell a
|
|
51
|
+
* wedged run from a productive one — the server's lease can, which is what
|
|
52
|
+
* heartbeats are for. This is now a backstop against a genuinely stuck process
|
|
53
|
+
* rather than a shape imposed on the work; `--timeout 0` removes it entirely.
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 7_200_000;
|
|
39
56
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
40
57
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
41
58
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
@@ -66,8 +83,18 @@ function backoffDelay(attempt) {
|
|
|
66
83
|
const cap = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
|
|
67
84
|
return Math.floor(Math.random() * cap);
|
|
68
85
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Backoff schedule for retrying a completion the server didn't accept.
|
|
88
|
+
*
|
|
89
|
+
* Roughly ten minutes in total, up from thirteen seconds. The old schedule was
|
|
90
|
+
* sized for a five-minute run: losing the completion meant losing five minutes
|
|
91
|
+
* of work, and a laptop that has been offline for thirteen seconds is usually
|
|
92
|
+
* offline for longer. Now a run can be four hours of an agent's work, which
|
|
93
|
+
* makes waiting out a wifi drop overwhelmingly the better bet — and the run
|
|
94
|
+
* keeps being heartbeated for the whole retry window, so the row it is trying
|
|
95
|
+
* to close stays alive rather than being reaped underneath the retries.
|
|
96
|
+
*/
|
|
97
|
+
const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000, 180_000, 240_000];
|
|
71
98
|
/**
|
|
72
99
|
* Complete a run, retrying transient failures.
|
|
73
100
|
*
|
|
@@ -347,35 +374,113 @@ function defaultProbe(command, cwd) {
|
|
|
347
374
|
* tell a failed turn from a successful one. A plain command's behaviour here is
|
|
348
375
|
* byte-for-byte what it was before this function existed.
|
|
349
376
|
*/
|
|
350
|
-
async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
377
|
+
async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
351
378
|
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
352
379
|
// must not let one run take twice what the operator allowed. Both attempts
|
|
353
380
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
354
381
|
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
355
382
|
const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
383
|
+
// `--timeout 0`: no local ceiling at all. The run then ends when the agent
|
|
384
|
+
// ends it, when somebody cancels it, or when the server stops renewing its
|
|
385
|
+
// lease — all of which are better judges of "stuck" than a fixed clock.
|
|
386
|
+
const capped = budgetMs > 0;
|
|
356
387
|
const startedAt = Date.now();
|
|
357
388
|
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
const timeLeft = () => !capped || remainingMs() > 0;
|
|
390
|
+
// Which surface's session this turn belongs to, and `undefined` on a dry run
|
|
391
|
+
// — which is what makes every `remember`/`forget` below a no-op there.
|
|
392
|
+
// `--dry-run` promises to make no writes, and a resumed turn is a write: both
|
|
393
|
+
// brains record it in their transcript whether or not we post the reply, so
|
|
394
|
+
// remembering the session would leave the next *real* run continuing from an
|
|
395
|
+
// answer nobody saw. Resolved once, up here, because the streaming reader
|
|
396
|
+
// records it mid-run rather than at the end of the turn.
|
|
397
|
+
const sessionKey = options.dryRun ? undefined : sessionKeyFor(run);
|
|
398
|
+
let recordedSession;
|
|
399
|
+
/** The reader for the attempt currently running, if this brain streams. */
|
|
400
|
+
let stream;
|
|
401
|
+
/**
|
|
402
|
+
* Act on what the stream has said so far.
|
|
403
|
+
*
|
|
404
|
+
* The session id is written to the ledger **as soon as it is announced**,
|
|
405
|
+
* which is a deliberate departure from "record it once the reply is durable".
|
|
406
|
+
* That rule protects against continuing a session whose answer nobody saw;
|
|
407
|
+
* it cannot protect a run that is killed at hour two, because that run never
|
|
408
|
+
* reaches the end where the recording used to happen — and losing the id
|
|
409
|
+
* loses the only handle on two hours of the agent's context. The rejected-
|
|
410
|
+
* reply case is handled where it happens instead, by forgetting again.
|
|
411
|
+
*/
|
|
412
|
+
const observe = (reader) => {
|
|
413
|
+
const id = reader.sessionId();
|
|
414
|
+
if (id && id !== recordedSession) {
|
|
415
|
+
recordedSession = id;
|
|
416
|
+
runtime.sessions.remember(sessionKey, id);
|
|
417
|
+
}
|
|
418
|
+
const activity = reader.activity();
|
|
419
|
+
if (activity)
|
|
420
|
+
control.report(activity);
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* Undo an eagerly recorded Claude session after an ordinary failed turn.
|
|
424
|
+
*
|
|
425
|
+
* Timeouts and cancellations deliberately keep their session so the next
|
|
426
|
+
* delivery can continue a long-running turn whose context would otherwise be
|
|
427
|
+
* lost. A command or parser failure has no such continuity guarantee: its
|
|
428
|
+
* answer was never made durable, so resuming it would skip an unseen turn.
|
|
429
|
+
*/
|
|
430
|
+
const forgetRecordedSession = () => {
|
|
431
|
+
if (!recordedSession)
|
|
432
|
+
return;
|
|
433
|
+
runtime.sessions.forget(sessionKey);
|
|
434
|
+
recordedSession = undefined;
|
|
435
|
+
};
|
|
436
|
+
const exec = (command) => {
|
|
437
|
+
// A fresh reader per attempt: a retry without `--resume` is a different
|
|
438
|
+
// session, and inheriting the failed attempt's id would resume nothing.
|
|
439
|
+
const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
|
|
440
|
+
stream = reader;
|
|
441
|
+
return execAgent({
|
|
442
|
+
command,
|
|
443
|
+
prompt,
|
|
444
|
+
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
445
|
+
cwd: runtime.workdir,
|
|
446
|
+
env: mcp.execEnv,
|
|
447
|
+
spawnImpl: options.spawnImpl,
|
|
448
|
+
cancelSignal: control.signal,
|
|
449
|
+
onStdout: reader
|
|
450
|
+
? (chunk) => {
|
|
451
|
+
reader.push(chunk);
|
|
452
|
+
observe(reader);
|
|
453
|
+
}
|
|
454
|
+
: undefined,
|
|
455
|
+
});
|
|
456
|
+
};
|
|
366
457
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
367
458
|
// rather than what the last attempt took.
|
|
368
459
|
const elapsed = () => Date.now() - startedAt;
|
|
369
460
|
if (mcp.error) {
|
|
370
461
|
return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
|
|
371
462
|
}
|
|
372
|
-
const failure = (result) =>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
463
|
+
const failure = (result) => {
|
|
464
|
+
// A killed run has no reply, but it usually has most of one. Reporting the
|
|
465
|
+
// tail of what the agent was writing turns "timed out after 7200000ms" into
|
|
466
|
+
// something the person waiting can act on — and it is the only trace left
|
|
467
|
+
// of however many hours of work.
|
|
468
|
+
const salvaged = result.timedOut || result.cancelled ? stream?.salvage() : undefined;
|
|
469
|
+
// The stderr tail yields room when there is a salvaged answer to carry: the
|
|
470
|
+
// completion `error` is capped server-side, and of the two, what the agent
|
|
471
|
+
// was saying beats the last 500 characters of its logging.
|
|
472
|
+
const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
|
|
473
|
+
const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
|
|
474
|
+
.filter((part) => part.length > 0)
|
|
475
|
+
.join('\n');
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: result.error ??
|
|
479
|
+
(result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
|
|
480
|
+
detail,
|
|
481
|
+
durationMs: elapsed(),
|
|
482
|
+
};
|
|
483
|
+
};
|
|
379
484
|
if (runtime.brain === 'plain') {
|
|
380
485
|
const result = await exec(options.exec);
|
|
381
486
|
if (!result.ok)
|
|
@@ -385,12 +490,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
385
490
|
? { ok: true, reply, durationMs: elapsed() }
|
|
386
491
|
: { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
|
|
387
492
|
}
|
|
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);
|
|
493
|
+
const sessionId = runtime.sessions.get(sessionKey);
|
|
394
494
|
const codex = runtime.brain === 'codex';
|
|
395
495
|
// Same three questions for both brains — resume this thread, wire these
|
|
396
496
|
// tools, gate or don't — asked with each one's own flags.
|
|
@@ -416,13 +516,19 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
416
516
|
// Skipped rather than squeezed into a token budget: a retry with no time
|
|
417
517
|
// left would be killed on the spot and reported as a timeout, which is a
|
|
418
518
|
// worse error message than the resume failure it replaced.
|
|
419
|
-
|
|
519
|
+
timeLeft() &&
|
|
520
|
+
// A run somebody just stopped does not get started again on its way out.
|
|
521
|
+
!control.signal.aborted) {
|
|
420
522
|
log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
|
|
421
|
-
runtime.sessions.forget(
|
|
523
|
+
runtime.sessions.forget(sessionKey);
|
|
524
|
+
recordedSession = undefined;
|
|
422
525
|
result = await exec(build());
|
|
423
526
|
}
|
|
424
|
-
if (!result.ok)
|
|
527
|
+
if (!result.ok) {
|
|
528
|
+
if (!result.timedOut && !result.cancelled)
|
|
529
|
+
forgetRecordedSession();
|
|
425
530
|
return failure(result);
|
|
531
|
+
}
|
|
426
532
|
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
427
533
|
// the reply just quietly lacks whatever the tool would have found. Printing
|
|
428
534
|
// it is the difference between "the agent seems dumb" and "the agent asked
|
|
@@ -445,9 +551,15 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
445
551
|
}
|
|
446
552
|
return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
|
|
447
553
|
}
|
|
448
|
-
|
|
554
|
+
// The reply comes out of the stream's own `result` event, so a run whose
|
|
555
|
+
// stdout we watched arrives already parsed. `finish` still gets the captured
|
|
556
|
+
// stdout: a command that emitted no result event at all — a login prompt, a
|
|
557
|
+
// crash banner, a `claude` that ignored `--output-format` — is a failure, and
|
|
558
|
+
// the whole-output parser is what says so in words.
|
|
559
|
+
const parsed = (stream ?? readClaudeStream()).finish(result.stdout);
|
|
449
560
|
reportDenials(parsed.denials);
|
|
450
561
|
if (!parsed.ok) {
|
|
562
|
+
forgetRecordedSession();
|
|
451
563
|
return {
|
|
452
564
|
ok: false,
|
|
453
565
|
reason: 'the agent did not produce a usable reply',
|
|
@@ -455,7 +567,13 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
455
567
|
durationMs: elapsed(),
|
|
456
568
|
};
|
|
457
569
|
}
|
|
458
|
-
return {
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
reply: parsed.text,
|
|
573
|
+
durationMs: elapsed(),
|
|
574
|
+
sessionId: parsed.sessionId,
|
|
575
|
+
sessionRecorded: recordedSession !== undefined,
|
|
576
|
+
};
|
|
459
577
|
}
|
|
460
578
|
/**
|
|
461
579
|
* Handle one verified run end-to-end.
|
|
@@ -463,7 +581,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
|
463
581
|
* Every path except a dry run — including every failure — calls the completion
|
|
464
582
|
* endpoint before returning.
|
|
465
583
|
*/
|
|
466
|
-
async function handleRun(run, options, runtime, api, log, sleep) {
|
|
584
|
+
async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
467
585
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
468
586
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
469
587
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
@@ -480,9 +598,37 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
480
598
|
// it is. See PromptContext.mcpToolPattern.
|
|
481
599
|
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
482
600
|
toolsGated: !runtime.skipPermissions,
|
|
601
|
+
// The real number, not a vague "there is a limit" — see PromptContext.
|
|
602
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
483
603
|
});
|
|
484
|
-
const result = await runTurn(run, prompt, options, runtime, mcp, log);
|
|
604
|
+
const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
|
|
485
605
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
606
|
+
// Stopped from outside. Checked before the failure branch below, because a
|
|
607
|
+
// stopped run *is* a failed command — reporting it as one would put "timed
|
|
608
|
+
// out"-shaped noise in a thread where somebody deliberately pressed stop.
|
|
609
|
+
const stopped = control.disposition();
|
|
610
|
+
if (stopped === 'resolved') {
|
|
611
|
+
// The server already closed this run: reaped after its lease lapsed, or
|
|
612
|
+
// resolved by something else. There is nothing left to post a reply to and
|
|
613
|
+
// nothing left to complete — a completion would just 409.
|
|
614
|
+
log(`${timestamp()} ⏹ ${label} was already closed server-side (${seconds}s) — nothing was posted`);
|
|
615
|
+
return 'resolved';
|
|
616
|
+
}
|
|
617
|
+
if (stopped === 'cancelled') {
|
|
618
|
+
log(`${timestamp()} ⏹ ${label} cancelled after ${seconds}s`);
|
|
619
|
+
if (options.dryRun) {
|
|
620
|
+
log(' [dry-run] would report the run as cancelled');
|
|
621
|
+
return 'skipped';
|
|
622
|
+
}
|
|
623
|
+
// The exact marker string, alone: the server words the thread notice from
|
|
624
|
+
// it, and anything wrapped around it reads as a crash instead of a stop.
|
|
625
|
+
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
626
|
+
if (!closed.ok) {
|
|
627
|
+
log(` ! could not report the cancellation (HTTP ${closed.status}) ${closed.error ?? ''}`);
|
|
628
|
+
return 'unreported';
|
|
629
|
+
}
|
|
630
|
+
return 'cancelled';
|
|
631
|
+
}
|
|
486
632
|
if (!result.ok) {
|
|
487
633
|
const { reason } = result;
|
|
488
634
|
const detail = result.detail ?? '';
|
|
@@ -533,6 +679,12 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
533
679
|
const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
|
|
534
680
|
if (!posted.ok) {
|
|
535
681
|
log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
|
|
682
|
+
// Undo the mid-run recording. The session exists and the agent's transcript
|
|
683
|
+
// holds this turn, but the conversation never received its answer, so
|
|
684
|
+
// continuing it would skip reasoning nobody can see — the same rule that
|
|
685
|
+
// used to be expressed by simply not recording until the reply landed.
|
|
686
|
+
if (result.sessionRecorded)
|
|
687
|
+
runtime.sessions.forget(sessionKeyFor(run));
|
|
536
688
|
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
|
|
537
689
|
return reported.ok ? 'failed' : 'unreported';
|
|
538
690
|
}
|
|
@@ -588,9 +740,79 @@ export async function runAgentHarness(options) {
|
|
|
588
740
|
// duplicate — the run is in flight right here. Only a run in the ledger that
|
|
589
741
|
// this process never handled is a candidate for restart recovery.
|
|
590
742
|
const handledThisSession = new Set();
|
|
743
|
+
/** Accepted, not yet reported — the executing run and everything queued. */
|
|
744
|
+
const accepted = new Map();
|
|
745
|
+
const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
|
|
591
746
|
let queue = Promise.resolve();
|
|
592
747
|
let fatal;
|
|
593
748
|
let attempt = 0;
|
|
749
|
+
/** Stop tracking a run — it has reached a terminal state, one way or another. */
|
|
750
|
+
const release = (runId) => {
|
|
751
|
+
accepted.delete(runId);
|
|
752
|
+
heartbeats.untrack(runId);
|
|
753
|
+
};
|
|
754
|
+
/**
|
|
755
|
+
* Work started from a heartbeat that the harness must still wait for on the
|
|
756
|
+
* way out, but that a heartbeat tick must not wait for.
|
|
757
|
+
*
|
|
758
|
+
* Closing out a cancelled queued run can take the whole completion-retry
|
|
759
|
+
* schedule — around ten minutes against an unreachable server — and beats do
|
|
760
|
+
* not overlap. Awaited inside the tick, one unreachable completion would stop
|
|
761
|
+
* every other run being heartbeated, and the four-hour job it was queued
|
|
762
|
+
* behind would be reaped for going quiet.
|
|
763
|
+
*/
|
|
764
|
+
const background = [];
|
|
765
|
+
/**
|
|
766
|
+
* Somebody pressed stop in ProhostAI.
|
|
767
|
+
*
|
|
768
|
+
* The two cases are genuinely different work. An executing run reports
|
|
769
|
+
* itself: the abort reaches `execAgent`, which signals the process group the
|
|
770
|
+
* same way a timeout does, and `handleRun` completes it with the marker
|
|
771
|
+
* error. A run still in the queue has nobody to report it — it will never
|
|
772
|
+
* execute, and the queue is about to skip it — so it is closed out here.
|
|
773
|
+
*/
|
|
774
|
+
const cancelAccepted = async (entry) => {
|
|
775
|
+
if (entry.disposition)
|
|
776
|
+
return;
|
|
777
|
+
entry.disposition = 'cancelled';
|
|
778
|
+
if (entry.executing) {
|
|
779
|
+
entry.controller.abort();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
release(entry.run.run_id);
|
|
783
|
+
log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was cancelled before it started`);
|
|
784
|
+
// `--dry-run` promises to make no writes, and a completion is the most
|
|
785
|
+
// terminal write there is. The executing-run path defers to the same check
|
|
786
|
+
// inside `handleRun`; this one has to make it itself, because a queued run
|
|
787
|
+
// never reaches `handleRun` at all.
|
|
788
|
+
if (options.dryRun) {
|
|
789
|
+
log(' [dry-run] would report the run as cancelled');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const closed = await completeWithRetry(api, entry.run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
793
|
+
if (closed.ok)
|
|
794
|
+
runs.markCompleted(entry.run.run_id);
|
|
795
|
+
metrics.runsFailed += 1;
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* The server has already closed this run out — it was reaped, or resolved by
|
|
799
|
+
* something else. Either way nothing this machine does with it can land, so
|
|
800
|
+
* the only correct move is to stop: kill the command if one is running, and
|
|
801
|
+
* post nothing at all. A completion here would be a 409 by definition.
|
|
802
|
+
*/
|
|
803
|
+
const resolveAccepted = (entry) => {
|
|
804
|
+
if (entry.disposition)
|
|
805
|
+
return;
|
|
806
|
+
entry.disposition = 'resolved';
|
|
807
|
+
if (entry.executing) {
|
|
808
|
+
entry.controller.abort();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
release(entry.run.run_id);
|
|
812
|
+
// Terminal server-side, so a redelivery must not start it either.
|
|
813
|
+
runs.markCompleted(entry.run.run_id);
|
|
814
|
+
log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was closed server-side before it started`);
|
|
815
|
+
};
|
|
594
816
|
const exhausted = () => options.maxConnections !== undefined && metrics.connections >= options.maxConnections;
|
|
595
817
|
while (!options.signal?.aborted && fatal === undefined && !exhausted()) {
|
|
596
818
|
metrics.connections += 1;
|
|
@@ -604,8 +826,14 @@ export async function runAgentHarness(options) {
|
|
|
604
826
|
log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
|
|
605
827
|
await sleep(delay);
|
|
606
828
|
}
|
|
607
|
-
// Let an in-flight run finish reporting before we hand back metrics
|
|
829
|
+
// Let an in-flight run finish reporting before we hand back metrics — and
|
|
830
|
+
// any cancellation the heartbeat loop started and deliberately did not wait
|
|
831
|
+
// for, so the harness never exits with a run half-closed.
|
|
608
832
|
await queue;
|
|
833
|
+
// Stopped first, so no further tick can start work after the wait below has
|
|
834
|
+
// taken its snapshot of `background`.
|
|
835
|
+
heartbeats.stop();
|
|
836
|
+
await Promise.all(background);
|
|
609
837
|
if (fatal !== undefined) {
|
|
610
838
|
log(`${timestamp()} ✗ ${fatal}`);
|
|
611
839
|
metrics.fatal = fatal;
|
|
@@ -753,13 +981,36 @@ export async function runAgentHarness(options) {
|
|
|
753
981
|
// redelivery execute the agent a second time.
|
|
754
982
|
runs.markStarted(run.run_id, run.completion_path);
|
|
755
983
|
metrics.runsStarted += 1;
|
|
984
|
+
// Heartbeat from the moment it is accepted, not from the moment it
|
|
985
|
+
// starts. The row is `running` server-side either way, and a run that
|
|
986
|
+
// waits an hour behind a long job is reaped exactly like one that has
|
|
987
|
+
// been working for an hour.
|
|
988
|
+
const entry = { run, controller: new AbortController(), executing: false };
|
|
989
|
+
accepted.set(run.run_id, entry);
|
|
990
|
+
heartbeats.track(run.run_id, {
|
|
991
|
+
path: heartbeatPathFor(run),
|
|
992
|
+
progress: () => entry.progress,
|
|
993
|
+
// Deliberately not awaited here — see `background`.
|
|
994
|
+
onCancelRequested: () => {
|
|
995
|
+
background.push(cancelAccepted(entry).catch((err) => {
|
|
996
|
+
log(`${timestamp()} ✗ closing cancelled run ${run.run_id} failed: ${String(err)}`);
|
|
997
|
+
}));
|
|
998
|
+
},
|
|
999
|
+
onResolved: () => resolveAccepted(entry),
|
|
1000
|
+
});
|
|
756
1001
|
// Serialize execution but never block the socket reader: the ack and
|
|
757
1002
|
// the heartbeat have to keep flowing while the agent thinks.
|
|
758
1003
|
queue = queue.then(async () => {
|
|
1004
|
+
// Settled while it waited — cancelled, or closed server-side. Both
|
|
1005
|
+
// were dealt with where they were decided; there is nothing to run
|
|
1006
|
+
// and nothing left to report.
|
|
1007
|
+
if (entry.disposition)
|
|
1008
|
+
return;
|
|
759
1009
|
// Shutdown was requested while this sat in the queue. Don't start a
|
|
760
1010
|
// fresh command the operator is waiting to be rid of — but do close
|
|
761
1011
|
// the run out, so the server isn't left waiting on it either.
|
|
762
1012
|
if (options.signal?.aborted) {
|
|
1013
|
+
release(run.run_id);
|
|
763
1014
|
log(`${timestamp()} ⏹ run ${run.run_id.slice(0, 12)} dropped — harness is shutting down`);
|
|
764
1015
|
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: 'the harness shut down before this run started' }, log, sleep);
|
|
765
1016
|
if (closed.ok)
|
|
@@ -767,20 +1018,42 @@ export async function runAgentHarness(options) {
|
|
|
767
1018
|
metrics.runsFailed += 1;
|
|
768
1019
|
return;
|
|
769
1020
|
}
|
|
770
|
-
|
|
1021
|
+
// Set before the first `await`, so a cancellation arriving from the
|
|
1022
|
+
// heartbeat either sees a queued run (and closes it out itself) or an
|
|
1023
|
+
// executing one (and aborts it) — never both, and never neither.
|
|
1024
|
+
entry.executing = true;
|
|
1025
|
+
const control = {
|
|
1026
|
+
signal: entry.controller.signal,
|
|
1027
|
+
disposition: () => entry.disposition,
|
|
1028
|
+
report: (activity) => {
|
|
1029
|
+
entry.progress = activity;
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
const outcome = await handleRun(run, options, runtime, api, log, sleep, control).catch((err) => {
|
|
771
1033
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
772
1034
|
return 'failed';
|
|
773
1035
|
});
|
|
1036
|
+
// Held until here on purpose: heartbeats keep the row alive for the
|
|
1037
|
+
// whole of `handleRun`, including the ten minutes of completion
|
|
1038
|
+
// retries at the end of it.
|
|
1039
|
+
release(run.run_id);
|
|
774
1040
|
// Both terminal outcomes reached the completion endpoint; only then
|
|
775
1041
|
// is the run settled and safe to skip on redelivery.
|
|
776
1042
|
if (outcome === 'succeeded')
|
|
777
1043
|
metrics.runsSucceeded += 1;
|
|
778
|
-
|
|
1044
|
+
// A cancellation is reported to the server as `failed`, and counted
|
|
1045
|
+
// the same way here — the run did not produce the answer it was for.
|
|
1046
|
+
if (outcome === 'failed' || outcome === 'unreported' || outcome === 'cancelled') {
|
|
779
1047
|
metrics.runsFailed += 1;
|
|
1048
|
+
}
|
|
780
1049
|
// Only a run the server acknowledged as terminal is settled; an
|
|
781
1050
|
// unreported one stays open so a redelivery can report it.
|
|
782
|
-
if (outcome === 'succeeded' ||
|
|
1051
|
+
if (outcome === 'succeeded' ||
|
|
1052
|
+
outcome === 'failed' ||
|
|
1053
|
+
outcome === 'cancelled' ||
|
|
1054
|
+
outcome === 'resolved') {
|
|
783
1055
|
runs.markCompleted(run.run_id);
|
|
1056
|
+
}
|
|
784
1057
|
});
|
|
785
1058
|
});
|
|
786
1059
|
ws.on('close', () => {
|
package/dist/index.js
CHANGED
|
@@ -43,7 +43,9 @@ 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 Seconds one run may take before it is killed. Defaults to
|
|
46
|
+
' --timeout Seconds one run may take before it is killed. Defaults to 7200 (2 hours).',
|
|
47
|
+
' Pass 0 for no local limit — the run then ends when the agent finishes,',
|
|
48
|
+
' when someone cancels it in ProhostAI, or when the server reaps it.',
|
|
47
49
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
48
50
|
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
49
51
|
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
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.3.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.3.0";
|