@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.
@@ -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 — this run has a hard time limit and somebody',
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',
@@ -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
- /** Default ceiling on one agent invocation. */
24
- export declare const DEFAULT_EXEC_TIMEOUT_MS = 300000;
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, parseClaudeOutput, writeMcpConfig, } from './claude.js';
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
- /** Default ceiling on one agent invocation. */
38
- export const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
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
- /** Backoff schedule for retrying a completion the server didn't accept. */
70
- const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000];
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 exec = (command) => execAgent({
359
- command,
360
- prompt,
361
- timeoutMs: remainingMs(),
362
- cwd: runtime.workdir,
363
- env: mcp.execEnv,
364
- spawnImpl: options.spawnImpl,
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
- ok: false,
374
- reason: result.error ??
375
- (result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
376
- detail: result.stderr.trim().slice(-500),
377
- durationMs: elapsed(),
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
- // No session bookkeeping on a dry run. `--dry-run` promises to make no
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
- remainingMs() > 0) {
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(key);
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
- const parsed = parseClaudeOutput(result.stdout);
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 { ok: true, reply: parsed.text, durationMs: elapsed(), sessionId: parsed.sessionId };
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
- const outcome = await handleRun(run, options, runtime, api, log, sleep).catch((err) => {
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
- if (outcome === 'failed' || outcome === 'unreported')
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' || outcome === 'failed')
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 300.',
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.2.3";
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.2.3";
12
+ export declare const USER_AGENT = "prohost-cli/0.3.0";