@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.
@@ -11,11 +11,57 @@
11
11
  * comes from the host's own flag — but event-derived text never reaches the
12
12
  * shell, so a crafted mention cannot inject a command.
13
13
  */
14
- import { spawn } from 'node:child_process';
14
+ import { spawn, spawnSync } from 'node:child_process';
15
15
  /** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
16
16
  export const MAX_OUTPUT_CHARS = 512 * 1024;
17
- /** Grace period between SIGTERM and SIGKILL when a run overruns its timeout. */
17
+ /** Grace period between SIGTERM and SIGKILL when a run is being stopped. */
18
18
  const KILL_GRACE_MS = 5_000;
19
+ /** ``error`` on a result the caller stopped deliberately. */
20
+ export const CANCELLED_ERROR = 'cancelled';
21
+ /**
22
+ * What a timed-out run reports.
23
+ *
24
+ * Ends with the way out, because this string does not stay in `agent.log`: the
25
+ * server echoes a failed run's error into the thread, so the person who was
26
+ * waiting reads it. Told "timed out after 7200000ms" they learn only that
27
+ * something broke; told how to raise the ceiling they can fix it themselves.
28
+ * Kept to one line for the same reason.
29
+ */
30
+ export function timeoutMessage(timeoutMs) {
31
+ return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
32
+ }
33
+ /**
34
+ * Say a silence window the way a person would say it.
35
+ *
36
+ * The default is fifteen minutes and it wants to read as "15 minutes", not
37
+ * "900000ms": this string is shown to whoever was waiting on the agent, and a
38
+ * number they have to convert is a number they will misread.
39
+ */
40
+ function renderWindow(ms) {
41
+ if (ms >= 60_000 && ms % 60_000 === 0) {
42
+ const minutes = ms / 60_000;
43
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
44
+ }
45
+ const seconds = ms / 1_000;
46
+ const rendered = Number.isInteger(seconds)
47
+ ? String(seconds)
48
+ : seconds.toFixed(seconds < 1 ? 2 : 1);
49
+ return `${rendered} second${rendered === '1' ? '' : 's'}`;
50
+ }
51
+ /**
52
+ * What a run killed by the inactivity watchdog reports.
53
+ *
54
+ * Same job as {@link timeoutMessage}, and the same constraint: the server
55
+ * echoes a failed run's error into the thread, so this is read by the person
56
+ * who was waiting rather than by whoever tails `agent.log`. It has to say what
57
+ * happened ("nothing came out of it"), that this is an inference rather than a
58
+ * crash the agent reported, and how to change the judgement — in one line,
59
+ * because the notice it lands in has room for one.
60
+ */
61
+ export function idleTimeoutMessage(idleTimeoutMs) {
62
+ return (`no output for ${renderWindow(idleTimeoutMs)} — treated as stalled; ` +
63
+ 'tune with --idle-timeout <seconds> (0 = disabled)');
64
+ }
19
65
  function clamp(text) {
20
66
  if (text.length <= MAX_OUTPUT_CHARS)
21
67
  return text;
@@ -24,9 +70,9 @@ function clamp(text) {
24
70
  /**
25
71
  * Execute the agent command with ``prompt`` on stdin and return its stdout.
26
72
  *
27
- * Never throws: a spawn failure, a non-zero exit, and a timeout all come back
28
- * as ``ok: false`` with the detail filled in, because the caller must report
29
- * *some* outcome to the server for every run.
73
+ * Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
74
+ * all come back as ``ok: false`` with the detail filled in, because the caller
75
+ * must report *some* outcome to the server for every run.
30
76
  */
31
77
  export async function execAgent(options) {
32
78
  const spawnImpl = options.spawnImpl ?? spawn;
@@ -35,7 +81,25 @@ export async function execAgent(options) {
35
81
  let stdout = '';
36
82
  let stderr = '';
37
83
  let timedOut = false;
84
+ let idleTimedOut = false;
85
+ let cancelled = false;
38
86
  let settled = false;
87
+ // Nothing to stop if the stop already happened: a run cancelled while it
88
+ // sat in the queue must not spawn the agent at all.
89
+ if (options.cancelSignal?.aborted) {
90
+ resolve({
91
+ ok: false,
92
+ stdout: '',
93
+ stderr: '',
94
+ exitCode: null,
95
+ timedOut: false,
96
+ idleTimedOut: false,
97
+ cancelled: true,
98
+ error: CANCELLED_ERROR,
99
+ durationMs: Date.now() - started,
100
+ });
101
+ return;
102
+ }
39
103
  const spawnOptions = {
40
104
  shell: true,
41
105
  cwd: options.cwd,
@@ -58,27 +122,70 @@ export async function execAgent(options) {
58
122
  stderr: '',
59
123
  exitCode: null,
60
124
  timedOut: false,
125
+ idleTimedOut: false,
126
+ cancelled: false,
61
127
  error: err instanceof Error ? err.message : String(err),
62
128
  durationMs: Date.now() - started,
63
129
  });
64
130
  return;
65
131
  }
132
+ let timer;
133
+ let idleTimer;
66
134
  let killTimer;
135
+ const idleMs = options.idleTimeoutMs ?? 0;
136
+ /**
137
+ * Why the run ended, when it ended because we ended it.
138
+ *
139
+ * Ordered by which decision was made first in practice: a deliberate stop
140
+ * outranks either clock, and the wall clock outranks the watchdog because
141
+ * `terminate` disarms the watchdog on its way out — so if both flags are
142
+ * somehow set, the ceiling is what actually fired.
143
+ */
144
+ const stopReason = () => cancelled
145
+ ? CANCELLED_ERROR
146
+ : timedOut
147
+ ? timeoutMessage(options.timeoutMs)
148
+ : idleTimedOut
149
+ ? idleTimeoutMessage(idleMs)
150
+ : undefined;
67
151
  const finish = (result) => {
68
152
  if (settled)
69
153
  return;
70
154
  settled = true;
71
- clearTimeout(timer);
155
+ if (timer !== undefined)
156
+ clearTimeout(timer);
157
+ if (idleTimer !== undefined)
158
+ clearTimeout(idleTimer);
72
159
  if (killTimer !== undefined)
73
160
  clearTimeout(killTimer);
161
+ options.cancelSignal?.removeEventListener('abort', onAbort);
74
162
  resolve({ ...result, durationMs: Date.now() - started });
75
163
  };
76
164
  /**
77
- * Signal the child's whole process group, falling back to the child alone
78
- * when there is no group (Windows, or a spawn that never got a pid).
165
+ * Signal the child's whole process tree.
166
+ *
167
+ * POSIX: the child has its own process group (see `detached` above), so one
168
+ * signal reaches the shell and everything it started.
169
+ *
170
+ * Windows has no process groups to signal, and `shell: true` means the
171
+ * child is `cmd.exe` — killing it alone leaves the agent it launched
172
+ * running, which is a run reported as stopped that carries on acting with
173
+ * the operator's credentials. `taskkill /T` walks the tree instead, with
174
+ * `/F` as the SIGKILL half of the escalation. It is best-effort: if
175
+ * `taskkill` is missing or refuses, the fallback below still kills the
176
+ * shell, which is exactly what this did before.
79
177
  */
80
178
  const signalTree = (signal) => {
81
179
  const pid = child.pid;
180
+ if (pid !== undefined && process.platform === 'win32') {
181
+ try {
182
+ const args = ['/pid', String(pid), '/T', ...(signal === 'SIGKILL' ? ['/F'] : [])];
183
+ spawnSync('taskkill', args, { windowsHide: true });
184
+ }
185
+ catch {
186
+ // Fall through — the child itself is still signalled below.
187
+ }
188
+ }
82
189
  try {
83
190
  if (pid !== undefined && spawnOptions.detached)
84
191
  process.kill(-pid, signal);
@@ -89,11 +196,33 @@ export async function execAgent(options) {
89
196
  // ESRCH — the group is already gone, which is the outcome we wanted.
90
197
  }
91
198
  };
92
- const timer = setTimeout(() => {
93
- timedOut = true;
199
+ /**
200
+ * End the run: ask politely, then insist.
201
+ *
202
+ * The single path for both reasons a run is stopped from outside. They need
203
+ * identical handling — an agent that ignores SIGTERM ignores it whoever
204
+ * sent it — and having one caller escalate and the other not is how a
205
+ * cancelled run wedges the queue behind a child that never died.
206
+ */
207
+ const terminate = () => {
208
+ if (settled || killTimer !== undefined)
209
+ return;
210
+ // Both clocks are disarmed here rather than in `finish`, because a
211
+ // termination takes up to KILL_GRACE_MS and neither clock may re-decide
212
+ // the reason during it. A ceiling that lapses while a stalled run is
213
+ // being killed would otherwise overwrite "no output for 15 minutes" with
214
+ // "timed out", sending whoever reads it to the wrong flag — and the
215
+ // child's dying output would likewise re-arm a watchdog for a run that
216
+ // is already on its way out.
217
+ if (timer !== undefined) {
218
+ clearTimeout(timer);
219
+ timer = undefined;
220
+ }
221
+ if (idleTimer !== undefined) {
222
+ clearTimeout(idleTimer);
223
+ idleTimer = undefined;
224
+ }
94
225
  signalTree('SIGTERM');
95
- // Escalate if the agent ignores SIGTERM, so a wedged child can't hold
96
- // the harness open past its timeout.
97
226
  killTimer = setTimeout(() => {
98
227
  signalTree('SIGKILL');
99
228
  // A descendant can still hold the pipes open after the group is
@@ -104,16 +233,62 @@ export async function execAgent(options) {
104
233
  stdout: clamp(stdout),
105
234
  stderr: clamp(stderr),
106
235
  exitCode: null,
107
- timedOut: true,
108
- error: `timed out after ${options.timeoutMs}ms`,
236
+ timedOut,
237
+ idleTimedOut,
238
+ cancelled,
239
+ error: stopReason(),
109
240
  });
110
241
  }, KILL_GRACE_MS);
111
- }, options.timeoutMs);
242
+ };
243
+ /**
244
+ * (Re)start the silence window.
245
+ *
246
+ * Armed once the child exists and again on every chunk it writes, so the
247
+ * window measures time since the last sign of life rather than time since
248
+ * the run began. Deliberately a no-op once a kill is under way: the run's
249
+ * fate is already decided, and the flag that explains it is already set.
250
+ */
251
+ const armIdleWatchdog = () => {
252
+ if (idleMs <= 0 || settled || killTimer !== undefined)
253
+ return;
254
+ if (idleTimer !== undefined)
255
+ clearTimeout(idleTimer);
256
+ idleTimer = setTimeout(() => {
257
+ idleTimedOut = true;
258
+ terminate();
259
+ }, idleMs);
260
+ };
261
+ function onAbort() {
262
+ cancelled = true;
263
+ terminate();
264
+ }
265
+ if (options.timeoutMs > 0) {
266
+ timer = setTimeout(() => {
267
+ timedOut = true;
268
+ terminate();
269
+ }, options.timeoutMs);
270
+ }
271
+ options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
112
272
  child.stdout?.on('data', (chunk) => {
273
+ const text = chunk.toString();
274
+ armIdleWatchdog();
275
+ // Capture is bounded; the callback is not. A parser that stops being fed
276
+ // at 512 KB would report stale progress and salvage the wrong text for
277
+ // the rest of a long run — and the callback keeps nothing per chunk.
113
278
  if (stdout.length < MAX_OUTPUT_CHARS)
114
- stdout += chunk.toString();
279
+ stdout += text;
280
+ try {
281
+ options.onStdout?.(text);
282
+ }
283
+ catch {
284
+ // A broken observer must not cost the run its output.
285
+ }
115
286
  });
116
287
  child.stderr?.on('data', (chunk) => {
288
+ // stderr counts as life exactly as much as stdout does. An agent that
289
+ // logs its progress to stderr and answers on stdout — several do — is
290
+ // plainly working, and a watchdog watching one stream would kill it.
291
+ armIdleWatchdog();
117
292
  if (stderr.length < MAX_OUTPUT_CHARS)
118
293
  stderr += chunk.toString();
119
294
  });
@@ -124,19 +299,27 @@ export async function execAgent(options) {
124
299
  stderr: clamp(stderr),
125
300
  exitCode: null,
126
301
  timedOut,
302
+ idleTimedOut,
303
+ cancelled,
127
304
  error: err.message,
128
305
  });
129
306
  });
130
307
  child.on('close', (code) => {
131
308
  finish({
132
- ok: !timedOut && code === 0,
309
+ ok: !timedOut && !idleTimedOut && !cancelled && code === 0,
133
310
  stdout: clamp(stdout),
134
311
  stderr: clamp(stderr),
135
312
  exitCode: code,
136
313
  timedOut,
137
- error: timedOut ? `timed out after ${options.timeoutMs}ms` : undefined,
314
+ idleTimedOut,
315
+ cancelled,
316
+ error: stopReason(),
138
317
  });
139
318
  });
319
+ // Started here, not at the top of the function: the window measures the
320
+ // child's silence, and a child that has not been spawned yet — or that
321
+ // failed to spawn at all — has nothing to be silent about.
322
+ armIdleWatchdog();
140
323
  // stdin may already be closed if the command failed to start.
141
324
  try {
142
325
  child.stdin?.on('error', () => {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Keeps long runs alive, and is how a laptop hears "stop".
3
+ *
4
+ * The server holds a lease on every `running` agent-run row and reaps the ones
5
+ * that go quiet, because the alternative — trusting a laptop to always report —
6
+ * leaves rows spinning forever whenever someone closes a lid. A run that
7
+ * legitimately takes four hours therefore has to say so on a cadence.
8
+ *
9
+ * The response is the other half: this CLI dials *out* over a websocket and
10
+ * accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
11
+ * the only channel by which "somebody pressed stop in the UI" can reach a
12
+ * machine behind NAT.
13
+ *
14
+ * Two properties this module is written around:
15
+ *
16
+ * * **Every accepted run beats, not just the executing one.** Runs are
17
+ * serialized, so a run queued behind a four-hour job sits `running`
18
+ * server-side for four hours without a single line of work being done on it.
19
+ * Beating only for the executing run would have the server reap the queue.
20
+ * * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
21
+ * failure mode — an old server that 404s, a dropped network, a handler that
22
+ * throws — costs at most the beat it happened on.
23
+ */
24
+ import type { ApiOptions } from './api.js';
25
+ /**
26
+ * How often each open run is reported.
27
+ *
28
+ * Well inside any sane server-side lease so a run survives losing a beat or
29
+ * two to a flaky link, and long enough that a queue of runs is a trickle of
30
+ * requests rather than a load source.
31
+ */
32
+ export declare const HEARTBEAT_INTERVAL_MS = 60000;
33
+ /** A cancelled timer. Returned by {@link Scheduler} implementations. */
34
+ export interface ScheduledTick {
35
+ cancel(): void;
36
+ }
37
+ /**
38
+ * Deferred execution, injectable so tests drive the loop rather than wait it
39
+ * out. The default unrefs its timer: a heartbeat loop must never be the reason
40
+ * a process refuses to exit.
41
+ */
42
+ export type Scheduler = (run: () => void, ms: number) => ScheduledTick;
43
+ /** One run the loop is beating for. */
44
+ export interface HeartbeatTarget {
45
+ /** Where the heartbeat POSTs — see `heartbeatPathFor`. */
46
+ path: string;
47
+ /**
48
+ * Latest one-line description of what the agent is doing, read fresh at each
49
+ * beat. Absent for a queued run, and for brains whose output we don't stream.
50
+ */
51
+ progress?: () => string | undefined;
52
+ /**
53
+ * The server wants this run stopped. Called at most once, and never again
54
+ * beaten for.
55
+ */
56
+ onCancelRequested: () => void | Promise<void>;
57
+ /**
58
+ * The server has already closed this run out (409) — reaped, or resolved by
59
+ * someone else. Called at most once. The work in flight is now pointless:
60
+ * whatever it produces has nowhere to go.
61
+ */
62
+ onResolved: () => void | Promise<void>;
63
+ }
64
+ export interface HeartbeatLoopOptions {
65
+ api: ApiOptions;
66
+ log: (line: string) => void;
67
+ /** Defaults to {@link HEARTBEAT_INTERVAL_MS}. */
68
+ intervalMs?: number;
69
+ /** Defaults to an unref'd `setTimeout`. */
70
+ scheduler?: Scheduler;
71
+ }
72
+ /**
73
+ * Beats for every open run on a fixed cadence.
74
+ *
75
+ * Idle when nothing is tracked — no timer is armed at all — so a harness that
76
+ * never receives a run makes no requests, and the first {@link track} starts
77
+ * the cadence.
78
+ */
79
+ export declare class HeartbeatLoop {
80
+ private readonly options;
81
+ private readonly targets;
82
+ private readonly intervalMs;
83
+ private readonly scheduler;
84
+ private pending;
85
+ private stopped;
86
+ /** A tick in flight. Ticks never overlap; a slow one delays the next. */
87
+ private ticking;
88
+ constructor(options: HeartbeatLoopOptions);
89
+ /** Begin beating for a run. Idempotent on ``runId``. */
90
+ track(runId: string, target: HeartbeatTarget): void;
91
+ /** Stop beating for a run — it reached a terminal state, one way or another. */
92
+ untrack(runId: string): void;
93
+ /** Runs currently being beaten for. Exposed for assertions and logging. */
94
+ tracked(): string[];
95
+ /** Stop the loop for good. Called once, when the harness is shutting down. */
96
+ stop(): void;
97
+ /**
98
+ * Beat once for every tracked run.
99
+ *
100
+ * Public so tests can drive the cadence directly instead of waiting on a
101
+ * timer. Beats are issued concurrently: they are independent requests, and
102
+ * doing them in series would make one slow response delay every run behind it.
103
+ */
104
+ tick(): Promise<void>;
105
+ private arm;
106
+ private runTick;
107
+ /**
108
+ * One run's heartbeat, and what its answer means.
109
+ *
110
+ * Wrapped so that a throwing handler — or a fetch implementation that rejects
111
+ * rather than resolving — costs this run's beat and nothing else.
112
+ */
113
+ private beat;
114
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Keeps long runs alive, and is how a laptop hears "stop".
3
+ *
4
+ * The server holds a lease on every `running` agent-run row and reaps the ones
5
+ * that go quiet, because the alternative — trusting a laptop to always report —
6
+ * leaves rows spinning forever whenever someone closes a lid. A run that
7
+ * legitimately takes four hours therefore has to say so on a cadence.
8
+ *
9
+ * The response is the other half: this CLI dials *out* over a websocket and
10
+ * accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
11
+ * the only channel by which "somebody pressed stop in the UI" can reach a
12
+ * machine behind NAT.
13
+ *
14
+ * Two properties this module is written around:
15
+ *
16
+ * * **Every accepted run beats, not just the executing one.** Runs are
17
+ * serialized, so a run queued behind a four-hour job sits `running`
18
+ * server-side for four hours without a single line of work being done on it.
19
+ * Beating only for the executing run would have the server reap the queue.
20
+ * * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
21
+ * failure mode — an old server that 404s, a dropped network, a handler that
22
+ * throws — costs at most the beat it happened on.
23
+ */
24
+ import { heartbeatRun } from './api.js';
25
+ /**
26
+ * How often each open run is reported.
27
+ *
28
+ * Well inside any sane server-side lease so a run survives losing a beat or
29
+ * two to a flaky link, and long enough that a queue of runs is a trickle of
30
+ * requests rather than a load source.
31
+ */
32
+ export const HEARTBEAT_INTERVAL_MS = 60_000;
33
+ const defaultScheduler = (run, ms) => {
34
+ const timer = setTimeout(run, ms);
35
+ // `unref` exists on Node/Bun timers and not in the DOM lib's typing.
36
+ timer.unref?.();
37
+ return { cancel: () => clearTimeout(timer) };
38
+ };
39
+ /**
40
+ * Beats for every open run on a fixed cadence.
41
+ *
42
+ * Idle when nothing is tracked — no timer is armed at all — so a harness that
43
+ * never receives a run makes no requests, and the first {@link track} starts
44
+ * the cadence.
45
+ */
46
+ export class HeartbeatLoop {
47
+ options;
48
+ targets = new Map();
49
+ intervalMs;
50
+ scheduler;
51
+ pending;
52
+ stopped = false;
53
+ /** A tick in flight. Ticks never overlap; a slow one delays the next. */
54
+ ticking = false;
55
+ constructor(options) {
56
+ this.options = options;
57
+ this.intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
58
+ this.scheduler = options.scheduler ?? defaultScheduler;
59
+ }
60
+ /** Begin beating for a run. Idempotent on ``runId``. */
61
+ track(runId, target) {
62
+ if (this.stopped)
63
+ return;
64
+ this.targets.set(runId, { ...target, runId });
65
+ this.arm();
66
+ }
67
+ /** Stop beating for a run — it reached a terminal state, one way or another. */
68
+ untrack(runId) {
69
+ this.targets.delete(runId);
70
+ }
71
+ /** Runs currently being beaten for. Exposed for assertions and logging. */
72
+ tracked() {
73
+ return [...this.targets.keys()];
74
+ }
75
+ /** Stop the loop for good. Called once, when the harness is shutting down. */
76
+ stop() {
77
+ this.stopped = true;
78
+ this.targets.clear();
79
+ this.pending?.cancel();
80
+ this.pending = undefined;
81
+ }
82
+ /**
83
+ * Beat once for every tracked run.
84
+ *
85
+ * Public so tests can drive the cadence directly instead of waiting on a
86
+ * timer. Beats are issued concurrently: they are independent requests, and
87
+ * doing them in series would make one slow response delay every run behind it.
88
+ */
89
+ async tick() {
90
+ const targets = [...this.targets.values()];
91
+ await Promise.all(targets.map((target) => this.beat(target)));
92
+ }
93
+ arm() {
94
+ if (this.stopped || this.pending || this.ticking || this.targets.size === 0)
95
+ return;
96
+ this.pending = this.scheduler(() => {
97
+ this.pending = undefined;
98
+ void this.runTick();
99
+ }, this.intervalMs);
100
+ }
101
+ async runTick() {
102
+ this.ticking = true;
103
+ try {
104
+ await this.tick();
105
+ }
106
+ catch (err) {
107
+ // `tick` already contains every beat's failure; reaching here means the
108
+ // bookkeeping around them broke. Say so and keep the cadence — a harness
109
+ // that stops beating because of a bug here silently loses every long run.
110
+ this.options.log(` ! heartbeat loop error: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ finally {
113
+ this.ticking = false;
114
+ this.arm();
115
+ }
116
+ }
117
+ /**
118
+ * One run's heartbeat, and what its answer means.
119
+ *
120
+ * Wrapped so that a throwing handler — or a fetch implementation that rejects
121
+ * rather than resolving — costs this run's beat and nothing else.
122
+ */
123
+ async beat(target) {
124
+ try {
125
+ const outcome = await heartbeatRun(this.options.api, target.path, target.progress?.());
126
+ // The run may have finished while its own heartbeat was in flight; acting
127
+ // on a stale answer would cancel a run that is already reporting itself.
128
+ if (!this.targets.has(target.runId))
129
+ return;
130
+ if (outcome.kind === 'alive') {
131
+ if (!outcome.cancelRequested)
132
+ return;
133
+ this.untrack(target.runId);
134
+ this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was cancelled from ProhostAI — stopping it`);
135
+ await target.onCancelRequested();
136
+ return;
137
+ }
138
+ if (outcome.kind === 'unknown') {
139
+ // Said once per run, then silence: on a server that predates
140
+ // heartbeats this is the answer for every run forever, and a line a
141
+ // minute per run would bury the log that operators actually read.
142
+ this.untrack(target.runId);
143
+ this.options.log(` ◦ run ${target.runId.slice(0, 12)} cannot be kept alive (HTTP 404) — this ProhostAI ` +
144
+ 'server has no heartbeat endpoint, or no longer knows this run. Long runs may be ' +
145
+ 'reaped while they are still working.');
146
+ return;
147
+ }
148
+ if (outcome.kind === 'resolved') {
149
+ this.untrack(target.runId);
150
+ this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was already closed server-side — abandoning it`);
151
+ await target.onResolved();
152
+ return;
153
+ }
154
+ // Transient by assumption: keep the cadence rather than give up on the
155
+ // run. A run whose beats all fail is reaped server-side eventually, which
156
+ // is the correct end state for a machine that cannot reach the API.
157
+ this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed (HTTP ${outcome.status})` +
158
+ `${outcome.detail ? ` ${outcome.detail}` : ''}`);
159
+ }
160
+ catch (err) {
161
+ this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} threw: ` +
162
+ `${err instanceof Error ? err.message : String(err)}`);
163
+ }
164
+ }
165
+ }
@@ -39,6 +39,28 @@ export interface PromptContext {
39
39
  * decline work it is perfectly able to do.
40
40
  */
41
41
  toolsGated?: boolean;
42
+ /**
43
+ * How long this run may take before it is killed, in milliseconds. ``0`` is
44
+ * `--timeout 0`: no local cap at all.
45
+ *
46
+ * Threaded in from the harness rather than assumed, because the prompt used
47
+ * to say "this run has a hard time limit" without ever naming it — which an
48
+ * agent reads as "hurry", whatever the number happens to be. An agent given
49
+ * five minutes should triage; an agent given two hours should do the work.
50
+ * They cannot tell those apart from a sentence that says neither.
51
+ */
52
+ timeoutMs?: number;
53
+ /**
54
+ * How long the run may produce nothing at all before it is killed, in
55
+ * milliseconds. ``0`` is `--idle-timeout 0`: no watchdog.
56
+ *
57
+ * Threaded in for the same reason as {@link timeoutMs}, and it now carries
58
+ * most of the weight: with no wall-clock ceiling by default, this is usually
59
+ * the only local limit there is. It is also a limit an agent can act on,
60
+ * which a duration is not — "do not go completely silent for a quarter of an
61
+ * hour" is advice about how to work, not a request to hurry.
62
+ */
63
+ idleTimeoutMs?: number;
42
64
  }
43
65
  /** Build the stdin prompt for one run. */
44
66
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;