@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/contract.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Server-side sources of truth:
|
|
14
14
|
* - payload shape — `shared/events/payloads/agent_runs.py`
|
|
15
15
|
* - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
|
|
16
|
+
* - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
|
|
16
17
|
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
17
18
|
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
18
19
|
*/
|
|
@@ -28,6 +29,23 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
|
|
|
28
29
|
export const RUN_STATUS_FAILED = 'failed';
|
|
29
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
30
31
|
export const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/**
|
|
33
|
+
* The completion `error` that means "a person asked for this run to stop".
|
|
34
|
+
*
|
|
35
|
+
* A magic string on purpose, and it must be sent **exactly**: the server
|
|
36
|
+
* matches on it to word the notice it drops in the thread ("cancelled" rather
|
|
37
|
+
* than "failed"). Prefixing it with a reason, or sending
|
|
38
|
+
* `cancelled_by_user: …`, makes a deliberate stop read as a crash.
|
|
39
|
+
*/
|
|
40
|
+
export const RUN_ERROR_CANCELLED_BY_USER = 'cancelled_by_user';
|
|
41
|
+
/**
|
|
42
|
+
* Cap on the `progress` string sent with a heartbeat.
|
|
43
|
+
*
|
|
44
|
+
* Progress is a hint for the UI — "using Bash", the first line of what the
|
|
45
|
+
* agent is drafting — not a transcript. Bounded here so a brain that emits one
|
|
46
|
+
* enormous block of text can't turn a keep-alive into a payload.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_PROGRESS_CHARS = 200;
|
|
31
49
|
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
32
50
|
export const REDEEM_PATH = '/v1/agent-pairing/redeem';
|
|
33
51
|
/** `POST` — send a message into a conversation as the paired agent. */
|
|
@@ -38,6 +56,35 @@ export function conversationMessagesPath(conversationId) {
|
|
|
38
56
|
export function agentRunCompletePath(runId) {
|
|
39
57
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
|
|
40
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* `POST` — say a run is still alive, and hear back whether it was cancelled.
|
|
61
|
+
*
|
|
62
|
+
* The server holds a lease on every running row and reaps the ones that stop
|
|
63
|
+
* being heard from; a run that legitimately takes four hours has to say so on a
|
|
64
|
+
* cadence or it is reaped mid-work. The response also carries
|
|
65
|
+
* `cancel_requested`, which is the only way a laptop behind NAT learns that
|
|
66
|
+
* somebody pressed stop.
|
|
67
|
+
*/
|
|
68
|
+
export function agentRunHeartbeatPath(runId) {
|
|
69
|
+
return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Where this run's heartbeats go.
|
|
73
|
+
*
|
|
74
|
+
* Derived from the server-supplied completion path rather than rebuilt from the
|
|
75
|
+
* run id, on the same reasoning the rest of this module prefers server-supplied
|
|
76
|
+
* paths: the two calls are the same resource, so a server that moves its
|
|
77
|
+
* agent-run routes moves both together and this CLI follows without a release.
|
|
78
|
+
* The documented path is the fallback for a completion path shaped in some way
|
|
79
|
+
* we don't recognise.
|
|
80
|
+
*/
|
|
81
|
+
export function heartbeatPathFor(run) {
|
|
82
|
+
const completion = run.completion_path;
|
|
83
|
+
const suffix = '/complete';
|
|
84
|
+
return completion.endsWith(suffix)
|
|
85
|
+
? `${completion.slice(0, -suffix.length)}/heartbeat`
|
|
86
|
+
: agentRunHeartbeatPath(run.run_id);
|
|
87
|
+
}
|
|
41
88
|
/**
|
|
42
89
|
* Ceiling on how many context entries are kept from one payload.
|
|
43
90
|
*
|
package/dist/agent/exec.d.ts
CHANGED
|
@@ -17,12 +17,36 @@ export interface ExecOptions {
|
|
|
17
17
|
command: string;
|
|
18
18
|
/** Prompt written to the child's stdin. */
|
|
19
19
|
prompt: string;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Hard cap on wall-clock runtime.
|
|
22
|
+
*
|
|
23
|
+
* ``0`` disables it: the child then runs until it exits on its own, until
|
|
24
|
+
* {@link cancelSignal} fires, or until the server gives up on the run. That
|
|
25
|
+
* is a real configuration (`--timeout 0`), not a degenerate one — some work
|
|
26
|
+
* genuinely takes hours and a local clock is the wrong thing to bound it by.
|
|
27
|
+
*/
|
|
21
28
|
timeoutMs: number;
|
|
22
29
|
/** Working directory for the child. Defaults to the CLI's cwd. */
|
|
23
30
|
cwd?: string;
|
|
24
31
|
/** Extra environment for the child, merged over ``process.env``. */
|
|
25
32
|
env?: NodeJS.ProcessEnv;
|
|
33
|
+
/**
|
|
34
|
+
* Ends the run early — a person pressed stop, or the server closed the run.
|
|
35
|
+
*
|
|
36
|
+
* Terminated through exactly the same SIGTERM → grace → SIGKILL path a
|
|
37
|
+
* timeout uses, because the problem is identical: the thing being stopped is
|
|
38
|
+
* a process *group* that may be ignoring polite signals.
|
|
39
|
+
*/
|
|
40
|
+
cancelSignal?: AbortSignal;
|
|
41
|
+
/**
|
|
42
|
+
* Each stdout chunk, as it arrives.
|
|
43
|
+
*
|
|
44
|
+
* The captured ``stdout`` is still returned in full; this exists for the
|
|
45
|
+
* things that cannot wait for the process to exit — reading a session id out
|
|
46
|
+
* of the first event, tracking what the agent is doing for the heartbeat, and
|
|
47
|
+
* having *something* to salvage from a run that gets killed.
|
|
48
|
+
*/
|
|
49
|
+
onStdout?: (chunk: string) => void;
|
|
26
50
|
/** Injectable spawn — tests substitute a fake process. */
|
|
27
51
|
spawnImpl?: typeof spawn;
|
|
28
52
|
}
|
|
@@ -32,17 +56,31 @@ export interface ExecResult {
|
|
|
32
56
|
stderr: string;
|
|
33
57
|
exitCode: number | null;
|
|
34
58
|
timedOut: boolean;
|
|
59
|
+
/** The run was stopped through {@link ExecOptions.cancelSignal}. */
|
|
60
|
+
cancelled: boolean;
|
|
35
61
|
/** Populated when the process could not be started at all. */
|
|
36
62
|
error?: string;
|
|
37
63
|
durationMs: number;
|
|
38
64
|
}
|
|
39
65
|
/** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
|
|
40
66
|
export declare const MAX_OUTPUT_CHARS: number;
|
|
67
|
+
/** ``error`` on a result the caller stopped deliberately. */
|
|
68
|
+
export declare const CANCELLED_ERROR = "cancelled";
|
|
69
|
+
/**
|
|
70
|
+
* What a timed-out run reports.
|
|
71
|
+
*
|
|
72
|
+
* Ends with the way out, because this string does not stay in `agent.log`: the
|
|
73
|
+
* server echoes a failed run's error into the thread, so the person who was
|
|
74
|
+
* waiting reads it. Told "timed out after 7200000ms" they learn only that
|
|
75
|
+
* something broke; told how to raise the ceiling they can fix it themselves.
|
|
76
|
+
* Kept to one line for the same reason.
|
|
77
|
+
*/
|
|
78
|
+
export declare function timeoutMessage(timeoutMs: number): string;
|
|
41
79
|
/**
|
|
42
80
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
43
81
|
*
|
|
44
|
-
* Never throws: a spawn failure, a non-zero exit,
|
|
45
|
-
* as ``ok: false`` with the detail filled in, because the caller
|
|
46
|
-
* *some* outcome to the server for every run.
|
|
82
|
+
* Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
|
|
83
|
+
* all come back as ``ok: false`` with the detail filled in, because the caller
|
|
84
|
+
* must report *some* outcome to the server for every run.
|
|
47
85
|
*/
|
|
48
86
|
export declare function execAgent(options: ExecOptions): Promise<ExecResult>;
|
package/dist/agent/exec.js
CHANGED
|
@@ -11,11 +11,25 @@
|
|
|
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
|
|
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
|
+
}
|
|
19
33
|
function clamp(text) {
|
|
20
34
|
if (text.length <= MAX_OUTPUT_CHARS)
|
|
21
35
|
return text;
|
|
@@ -24,9 +38,9 @@ function clamp(text) {
|
|
|
24
38
|
/**
|
|
25
39
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
26
40
|
*
|
|
27
|
-
* Never throws: a spawn failure, a non-zero exit,
|
|
28
|
-
* as ``ok: false`` with the detail filled in, because the caller
|
|
29
|
-
* *some* outcome to the server for every run.
|
|
41
|
+
* Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
|
|
42
|
+
* all come back as ``ok: false`` with the detail filled in, because the caller
|
|
43
|
+
* must report *some* outcome to the server for every run.
|
|
30
44
|
*/
|
|
31
45
|
export async function execAgent(options) {
|
|
32
46
|
const spawnImpl = options.spawnImpl ?? spawn;
|
|
@@ -35,7 +49,23 @@ export async function execAgent(options) {
|
|
|
35
49
|
let stdout = '';
|
|
36
50
|
let stderr = '';
|
|
37
51
|
let timedOut = false;
|
|
52
|
+
let cancelled = false;
|
|
38
53
|
let settled = false;
|
|
54
|
+
// Nothing to stop if the stop already happened: a run cancelled while it
|
|
55
|
+
// sat in the queue must not spawn the agent at all.
|
|
56
|
+
if (options.cancelSignal?.aborted) {
|
|
57
|
+
resolve({
|
|
58
|
+
ok: false,
|
|
59
|
+
stdout: '',
|
|
60
|
+
stderr: '',
|
|
61
|
+
exitCode: null,
|
|
62
|
+
timedOut: false,
|
|
63
|
+
cancelled: true,
|
|
64
|
+
error: CANCELLED_ERROR,
|
|
65
|
+
durationMs: Date.now() - started,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
39
69
|
const spawnOptions = {
|
|
40
70
|
shell: true,
|
|
41
71
|
cwd: options.cwd,
|
|
@@ -58,27 +88,52 @@ export async function execAgent(options) {
|
|
|
58
88
|
stderr: '',
|
|
59
89
|
exitCode: null,
|
|
60
90
|
timedOut: false,
|
|
91
|
+
cancelled: false,
|
|
61
92
|
error: err instanceof Error ? err.message : String(err),
|
|
62
93
|
durationMs: Date.now() - started,
|
|
63
94
|
});
|
|
64
95
|
return;
|
|
65
96
|
}
|
|
97
|
+
let timer;
|
|
66
98
|
let killTimer;
|
|
99
|
+
/** Why the run ended, when it ended because we ended it. */
|
|
100
|
+
const stopReason = () => cancelled ? CANCELLED_ERROR : timedOut ? timeoutMessage(options.timeoutMs) : undefined;
|
|
67
101
|
const finish = (result) => {
|
|
68
102
|
if (settled)
|
|
69
103
|
return;
|
|
70
104
|
settled = true;
|
|
71
|
-
|
|
105
|
+
if (timer !== undefined)
|
|
106
|
+
clearTimeout(timer);
|
|
72
107
|
if (killTimer !== undefined)
|
|
73
108
|
clearTimeout(killTimer);
|
|
109
|
+
options.cancelSignal?.removeEventListener('abort', onAbort);
|
|
74
110
|
resolve({ ...result, durationMs: Date.now() - started });
|
|
75
111
|
};
|
|
76
112
|
/**
|
|
77
|
-
* Signal the child's whole process
|
|
78
|
-
*
|
|
113
|
+
* Signal the child's whole process tree.
|
|
114
|
+
*
|
|
115
|
+
* POSIX: the child has its own process group (see `detached` above), so one
|
|
116
|
+
* signal reaches the shell and everything it started.
|
|
117
|
+
*
|
|
118
|
+
* Windows has no process groups to signal, and `shell: true` means the
|
|
119
|
+
* child is `cmd.exe` — killing it alone leaves the agent it launched
|
|
120
|
+
* running, which is a run reported as stopped that carries on acting with
|
|
121
|
+
* the operator's credentials. `taskkill /T` walks the tree instead, with
|
|
122
|
+
* `/F` as the SIGKILL half of the escalation. It is best-effort: if
|
|
123
|
+
* `taskkill` is missing or refuses, the fallback below still kills the
|
|
124
|
+
* shell, which is exactly what this did before.
|
|
79
125
|
*/
|
|
80
126
|
const signalTree = (signal) => {
|
|
81
127
|
const pid = child.pid;
|
|
128
|
+
if (pid !== undefined && process.platform === 'win32') {
|
|
129
|
+
try {
|
|
130
|
+
const args = ['/pid', String(pid), '/T', ...(signal === 'SIGKILL' ? ['/F'] : [])];
|
|
131
|
+
spawnSync('taskkill', args, { windowsHide: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Fall through — the child itself is still signalled below.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
82
137
|
try {
|
|
83
138
|
if (pid !== undefined && spawnOptions.detached)
|
|
84
139
|
process.kill(-pid, signal);
|
|
@@ -89,11 +144,18 @@ export async function execAgent(options) {
|
|
|
89
144
|
// ESRCH — the group is already gone, which is the outcome we wanted.
|
|
90
145
|
}
|
|
91
146
|
};
|
|
92
|
-
|
|
93
|
-
|
|
147
|
+
/**
|
|
148
|
+
* End the run: ask politely, then insist.
|
|
149
|
+
*
|
|
150
|
+
* The single path for both reasons a run is stopped from outside. They need
|
|
151
|
+
* identical handling — an agent that ignores SIGTERM ignores it whoever
|
|
152
|
+
* sent it — and having one caller escalate and the other not is how a
|
|
153
|
+
* cancelled run wedges the queue behind a child that never died.
|
|
154
|
+
*/
|
|
155
|
+
const terminate = () => {
|
|
156
|
+
if (settled || killTimer !== undefined)
|
|
157
|
+
return;
|
|
94
158
|
signalTree('SIGTERM');
|
|
95
|
-
// Escalate if the agent ignores SIGTERM, so a wedged child can't hold
|
|
96
|
-
// the harness open past its timeout.
|
|
97
159
|
killTimer = setTimeout(() => {
|
|
98
160
|
signalTree('SIGKILL');
|
|
99
161
|
// A descendant can still hold the pipes open after the group is
|
|
@@ -104,14 +166,36 @@ export async function execAgent(options) {
|
|
|
104
166
|
stdout: clamp(stdout),
|
|
105
167
|
stderr: clamp(stderr),
|
|
106
168
|
exitCode: null,
|
|
107
|
-
timedOut
|
|
108
|
-
|
|
169
|
+
timedOut,
|
|
170
|
+
cancelled,
|
|
171
|
+
error: stopReason(),
|
|
109
172
|
});
|
|
110
173
|
}, KILL_GRACE_MS);
|
|
111
|
-
}
|
|
174
|
+
};
|
|
175
|
+
function onAbort() {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
terminate();
|
|
178
|
+
}
|
|
179
|
+
if (options.timeoutMs > 0) {
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
timedOut = true;
|
|
182
|
+
terminate();
|
|
183
|
+
}, options.timeoutMs);
|
|
184
|
+
}
|
|
185
|
+
options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
|
|
112
186
|
child.stdout?.on('data', (chunk) => {
|
|
187
|
+
const text = chunk.toString();
|
|
188
|
+
// Capture is bounded; the callback is not. A parser that stops being fed
|
|
189
|
+
// at 512 KB would report stale progress and salvage the wrong text for
|
|
190
|
+
// the rest of a long run — and the callback keeps nothing per chunk.
|
|
113
191
|
if (stdout.length < MAX_OUTPUT_CHARS)
|
|
114
|
-
stdout +=
|
|
192
|
+
stdout += text;
|
|
193
|
+
try {
|
|
194
|
+
options.onStdout?.(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// A broken observer must not cost the run its output.
|
|
198
|
+
}
|
|
115
199
|
});
|
|
116
200
|
child.stderr?.on('data', (chunk) => {
|
|
117
201
|
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
@@ -124,17 +208,19 @@ export async function execAgent(options) {
|
|
|
124
208
|
stderr: clamp(stderr),
|
|
125
209
|
exitCode: null,
|
|
126
210
|
timedOut,
|
|
211
|
+
cancelled,
|
|
127
212
|
error: err.message,
|
|
128
213
|
});
|
|
129
214
|
});
|
|
130
215
|
child.on('close', (code) => {
|
|
131
216
|
finish({
|
|
132
|
-
ok: !timedOut && code === 0,
|
|
217
|
+
ok: !timedOut && !cancelled && code === 0,
|
|
133
218
|
stdout: clamp(stdout),
|
|
134
219
|
stderr: clamp(stderr),
|
|
135
220
|
exitCode: code,
|
|
136
221
|
timedOut,
|
|
137
|
-
|
|
222
|
+
cancelled,
|
|
223
|
+
error: stopReason(),
|
|
138
224
|
});
|
|
139
225
|
});
|
|
140
226
|
// stdin may already be closed if the command failed to start.
|
|
@@ -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
|
+
}
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -39,6 +39,17 @@ 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;
|
|
42
53
|
}
|
|
43
54
|
/** Build the stdin prompt for one run. */
|
|
44
55
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|