@prohost/cli 0.2.2 → 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 +169 -0
- package/README.md +170 -21
- package/dist/agent/api.d.ts +53 -5
- package/dist/agent/api.js +51 -11
- package/dist/agent/claude.d.ts +74 -4
- package/dist/agent/claude.js +172 -21
- package/dist/agent/codex.d.ts +215 -11
- package/dist/agent/codex.js +555 -23
- 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/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/mcp.d.ts +7 -6
- package/dist/agent/mcp.js +7 -6
- package/dist/agent/prompt.d.ts +17 -5
- package/dist/agent/prompt.js +66 -2
- package/dist/agent/run.d.ts +58 -3
- package/dist/agent/run.js +525 -97
- package/dist/index.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/run.js
CHANGED
|
@@ -16,24 +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';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
21
27
|
import WebSocket from 'ws';
|
|
22
28
|
import { completeRun, postReply } from './api.js';
|
|
23
|
-
import { claudeCommand, isClaudeExec, looksLikeResumeFailure,
|
|
24
|
-
import { MCP_API_KEY_ENV_VAR, codexCommand, isCodexExec, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
29
|
+
import { claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
30
|
+
import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
31
|
+
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
25
32
|
import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
|
|
26
|
-
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';
|
|
27
34
|
import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
|
|
28
35
|
import { execAgent } from './exec.js';
|
|
36
|
+
import { HeartbeatLoop } from './heartbeat.js';
|
|
29
37
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
30
38
|
import { RunStore } from './runstore.js';
|
|
31
39
|
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
32
40
|
import { verifySignature } from './signature.js';
|
|
33
41
|
import { isWebhookEvent } from '../forward.js';
|
|
34
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
35
|
-
/**
|
|
36
|
-
|
|
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;
|
|
37
56
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
38
57
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
39
58
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
@@ -64,8 +83,18 @@ function backoffDelay(attempt) {
|
|
|
64
83
|
const cap = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
|
|
65
84
|
return Math.floor(Math.random() * cap);
|
|
66
85
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
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];
|
|
69
98
|
/**
|
|
70
99
|
* Complete a run, retrying transient failures.
|
|
71
100
|
*
|
|
@@ -116,13 +145,12 @@ function resolveWorkdir(options, log) {
|
|
|
116
145
|
}
|
|
117
146
|
}
|
|
118
147
|
/**
|
|
119
|
-
* Assemble the per-process runtime: workspace, session ledger, and
|
|
120
|
-
*
|
|
148
|
+
* Assemble the per-process runtime: workspace, session ledger, and the
|
|
149
|
+
* brain-specific MCP preparation used immediately before each run.
|
|
121
150
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* dressed up as a safety check.
|
|
151
|
+
* Brain-specific MCP preparation happens per run. A daemon is long-lived, so
|
|
152
|
+
* an extras-file deletion or newly installed Codex server must take effect on
|
|
153
|
+
* the next invocation rather than after a restart or cache timeout.
|
|
126
154
|
*/
|
|
127
155
|
function buildRuntime(options, log) {
|
|
128
156
|
const workdir = resolveWorkdir(options, log);
|
|
@@ -134,53 +162,51 @@ function buildRuntime(options, log) {
|
|
|
134
162
|
: 'plain';
|
|
135
163
|
const skipPermissions = options.safeTools !== true;
|
|
136
164
|
if (brain === 'plain') {
|
|
137
|
-
return { workdir, sessions, brain, skipPermissions, mcpWired: false };
|
|
165
|
+
return { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
|
|
138
166
|
}
|
|
139
167
|
const mcpUrl = options.credentials.mcp_url;
|
|
140
|
-
let
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
let announced = '';
|
|
169
|
+
const announce = (lines) => {
|
|
170
|
+
const message = lines.join('\n');
|
|
171
|
+
if (message === announced)
|
|
172
|
+
return;
|
|
173
|
+
for (const line of lines)
|
|
174
|
+
log(line);
|
|
175
|
+
announced = message;
|
|
176
|
+
};
|
|
143
177
|
if (!mcpUrl) {
|
|
144
178
|
log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
|
|
145
179
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
const prepareMcp = brain === 'claude'
|
|
181
|
+
? () => {
|
|
182
|
+
const extras = loadExtraMcpServers({ env: options.env });
|
|
183
|
+
announce(extras.warnings.map((warning) => `${timestamp()} ! ${warning}`));
|
|
184
|
+
// Always pass an explicit strict config, including when it is empty.
|
|
185
|
+
// Omitting the path would make `--strict-mcp-config` ineffective and
|
|
186
|
+
// expose Claude's user/project MCP servers under skipped permissions.
|
|
187
|
+
try {
|
|
188
|
+
const mcpConfigPath = writeMcpConfig({
|
|
189
|
+
mcpUrl,
|
|
190
|
+
apiKey: options.credentials.api_key,
|
|
191
|
+
extraServers: extras.servers,
|
|
192
|
+
env: options.env,
|
|
193
|
+
});
|
|
194
|
+
return { mcpConfigPath, mcpWired: Boolean(mcpUrl) };
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
198
|
+
return {
|
|
199
|
+
mcpWired: false,
|
|
200
|
+
error: `could not write the strict Claude MCP config (${detail})`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
161
203
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Codex needs no file: the server is a `-c` override on each invocation and
|
|
165
|
-
// the key is read from the child's environment. Nothing to fail, so nothing
|
|
166
|
-
// to fall back from.
|
|
167
|
-
codexMcpUrl = mcpUrl;
|
|
168
|
-
execEnv = { [MCP_API_KEY_ENV_VAR]: options.credentials.api_key };
|
|
169
|
-
}
|
|
170
|
-
const mcpWired = Boolean(mcpConfigPath ?? codexMcpUrl);
|
|
171
|
-
const tools = mcpWired ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '';
|
|
204
|
+
: codexMcpEntryProvider(options, log, workdir);
|
|
205
|
+
const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
|
|
172
206
|
const where = workdir ? ` (workspace: ${workdir})` : '';
|
|
173
207
|
log(brain === 'claude'
|
|
174
208
|
? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
|
|
175
209
|
: `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
|
|
176
|
-
if (brain === 'codex' && mcpWired) {
|
|
177
|
-
// The one asymmetry worth an operator knowing about, because it is a
|
|
178
|
-
// security property they may be assuming they have. Claude Code is told to
|
|
179
|
-
// load our config and nothing else; Codex has no equivalent switch, so its
|
|
180
|
-
// own `~/.codex/config.toml` servers come along.
|
|
181
|
-
log(`${timestamp()} ! Codex has no --strict-mcp-config equivalent: this run also holds ` +
|
|
182
|
-
'whatever MCP servers your ~/.codex/config.toml defines.');
|
|
183
|
-
}
|
|
184
210
|
// Said out loud every start-up, not buried in --help. This is the line that
|
|
185
211
|
// tells an operator their machine will run tool calls, unprompted, on text
|
|
186
212
|
// written by someone else in their workspace.
|
|
@@ -191,7 +217,153 @@ function buildRuntime(options, log) {
|
|
|
191
217
|
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
192
218
|
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
193
219
|
'approval will not run (nobody is at a terminal to approve it).');
|
|
194
|
-
return { workdir, sessions, brain,
|
|
220
|
+
return { workdir, sessions, brain, prepareMcp, skipPermissions };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Prepare a fresh Codex MCP override immediately before each invocation.
|
|
224
|
+
*
|
|
225
|
+
* No cache is safe here: Codex rereads its config per invocation and the
|
|
226
|
+
* extras file is the operator's live grant list. Fresh reads make both a new
|
|
227
|
+
* configured server and a revoked extra visible to the very next run.
|
|
228
|
+
*/
|
|
229
|
+
export function codexMcpEntryProvider(options, log, workdir) {
|
|
230
|
+
let announced = '';
|
|
231
|
+
return () => {
|
|
232
|
+
const extras = loadExtraMcpServers({ env: options.env });
|
|
233
|
+
const lines = extras.warnings.map((warning) => `${timestamp()} ! ${warning}`);
|
|
234
|
+
const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir);
|
|
235
|
+
const said = lines.join('\n');
|
|
236
|
+
if (said !== announced) {
|
|
237
|
+
for (const line of lines)
|
|
238
|
+
log(line);
|
|
239
|
+
announced = said;
|
|
240
|
+
}
|
|
241
|
+
return prepared;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/*
|
|
245
|
+
* Every `mcp_servers` entry a Codex run gets: ours, the operator's extras, and
|
|
246
|
+
* verified switches that keep the operator's own servers out. Preparation is
|
|
247
|
+
* fail-closed: an unprovable boundary never reaches an unsandboxed agent.
|
|
248
|
+
*/
|
|
249
|
+
function codexMcpEntries(options, extraServers, log, workdir) {
|
|
250
|
+
const fail = (detail) => {
|
|
251
|
+
log(`${timestamp()} ! ${detail}`);
|
|
252
|
+
return { mcpWired: false, error: detail };
|
|
253
|
+
};
|
|
254
|
+
const probe = options.probeImpl ?? defaultProbe;
|
|
255
|
+
if (codexShellSyntax(options.exec)) {
|
|
256
|
+
return fail('--exec uses shell quoting, expansion, glob, comment, escape, or control syntax that the Codex ' +
|
|
257
|
+
'isolation verifier cannot tokenize safely; use a plain argv-shaped Codex command');
|
|
258
|
+
}
|
|
259
|
+
const binary = codexBinary(options.exec);
|
|
260
|
+
const entries = [];
|
|
261
|
+
const execEnv = {};
|
|
262
|
+
if (options.credentials.mcp_url) {
|
|
263
|
+
entries.push(prohostMcpEntry(options.credentials.mcp_url));
|
|
264
|
+
execEnv[MCP_API_KEY_ENV_VAR] = options.credentials.api_key;
|
|
265
|
+
}
|
|
266
|
+
const profile = codexProfileName(options.exec);
|
|
267
|
+
if (profile) {
|
|
268
|
+
return fail(`--exec runs Codex under profile "${profile}", whose MCP servers cannot be enumerated; ` +
|
|
269
|
+
'remove the profile flag before running this harness');
|
|
270
|
+
}
|
|
271
|
+
const inventoryFlag = codexMcpInventoryFlag(options.exec);
|
|
272
|
+
if (inventoryFlag) {
|
|
273
|
+
return fail(`--exec includes ${inventoryFlag}, which can change Codex's MCP or plugin inventory, or its ` +
|
|
274
|
+
`working directory, outside the configuration the harness can verify; remove cwd, config, feature, ` +
|
|
275
|
+
'and --ignore-user-config flags');
|
|
276
|
+
}
|
|
277
|
+
const configured = listMcpServers(binary, probe, undefined, workdir);
|
|
278
|
+
if (configured === null) {
|
|
279
|
+
return fail('could not ask Codex which MCP servers it has configured');
|
|
280
|
+
}
|
|
281
|
+
const configuredNames = new Set(configured.map((server) => server.name));
|
|
282
|
+
if (configuredNames.has(MCP_SERVER_NAME)) {
|
|
283
|
+
return fail(`your Codex config already defines the reserved MCP server name "${MCP_SERVER_NAME}"; ` +
|
|
284
|
+
'rename or remove it so credentials cannot be merged into the paired server');
|
|
285
|
+
}
|
|
286
|
+
const collisions = Object.keys(extraServers).filter((name) => configuredNames.has(name));
|
|
287
|
+
if (collisions.length > 0) {
|
|
288
|
+
log(`${timestamp()} ! ${EXTRA_MCP_FILENAME} reuses names your Codex config already defines, ` +
|
|
289
|
+
`so those entries were ignored (Codex would merge their credentials): ${collisions.join(', ')}. Rename them to use them.`);
|
|
290
|
+
}
|
|
291
|
+
const usableExtras = Object.fromEntries(Object.entries(extraServers).filter(([name]) => !collisions.includes(name)));
|
|
292
|
+
const extra = extraMcpEntries(usableExtras);
|
|
293
|
+
if (extra.skipped.length > 0) {
|
|
294
|
+
log(`${timestamp()} ! could not pass these extra MCP servers to Codex ` +
|
|
295
|
+
`(value not expressible as a safe config override): ${extra.skipped.join(', ')}`);
|
|
296
|
+
}
|
|
297
|
+
let acceptedExtraEntries = [];
|
|
298
|
+
if (extra.entries.length > 0) {
|
|
299
|
+
if (listMcpServers(binary, probe, mcpServersOverride(extra.entries), workdir) === null) {
|
|
300
|
+
log(`${timestamp()} ! Codex rejected the server definitions in ${EXTRA_MCP_FILENAME}; ` +
|
|
301
|
+
'the run will continue without those optional extras');
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
acceptedExtraEntries = extra.entries;
|
|
305
|
+
entries.push(...acceptedExtraEntries);
|
|
306
|
+
Object.assign(execEnv, extra.env);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// A configured collision was dropped above, so it is deliberately disabled.
|
|
310
|
+
const acceptedExtraNames = new Set(Object.keys(usableExtras).filter((name) => !extra.skipped.includes(name)));
|
|
311
|
+
if (acceptedExtraEntries.length === 0)
|
|
312
|
+
acceptedExtraNames.clear();
|
|
313
|
+
const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
|
|
314
|
+
const { entries: disabled, failed } = disableEntriesThatLoad(binary, probe, targets, workdir);
|
|
315
|
+
if (failed.length > 0) {
|
|
316
|
+
return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')}`);
|
|
317
|
+
}
|
|
318
|
+
entries.push(...disabled);
|
|
319
|
+
if (disabled.length > 0) {
|
|
320
|
+
log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
|
|
321
|
+
}
|
|
322
|
+
// Verify the exact combined override, not its pieces. This catches a config
|
|
323
|
+
// merge that re-enables a server and a server installed between the initial
|
|
324
|
+
// survey and this final probe.
|
|
325
|
+
const override = mcpServersOverride(entries);
|
|
326
|
+
const finalListing = listMcpServers(binary, probe, override, workdir);
|
|
327
|
+
if (finalListing === null)
|
|
328
|
+
return fail('Codex rejected the final isolated MCP configuration');
|
|
329
|
+
const allowed = new Set([
|
|
330
|
+
...(options.credentials.mcp_url ? [MCP_SERVER_NAME] : []),
|
|
331
|
+
...acceptedExtraNames,
|
|
332
|
+
]);
|
|
333
|
+
const enabled = new Set(finalListing.filter((server) => server.enabled).map((server) => server.name));
|
|
334
|
+
const missing = [...allowed].filter((name) => !enabled.has(name));
|
|
335
|
+
if (missing.length > 0) {
|
|
336
|
+
return fail(`expected MCP servers are missing or disabled after isolation: ${missing.join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
const unexpected = finalListing.filter((server) => server.enabled && !allowed.has(server.name));
|
|
339
|
+
if (unexpected.length > 0) {
|
|
340
|
+
return fail(`unexpected MCP servers remain enabled after isolation: ${unexpected.map((server) => server.name).join(', ')}`);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
codexMcpEntries: entries,
|
|
344
|
+
execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
|
|
345
|
+
mcpWired: Boolean(options.credentials.mcp_url),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Run one short command and capture stdout.
|
|
350
|
+
*
|
|
351
|
+
* `shell: true` for the same reason `exec.ts` uses it — the binary name comes
|
|
352
|
+
* out of the operator's own `--exec` string and may be a quoted path — and for
|
|
353
|
+
* the same reason it is safe: nothing event-derived reaches this command line.
|
|
354
|
+
* `cwd` must match the eventual run exactly, because Codex loads trusted
|
|
355
|
+
* project `.codex/config.toml` layers from there.
|
|
356
|
+
*/
|
|
357
|
+
function defaultProbe(command, cwd) {
|
|
358
|
+
const result = spawnSync(command, {
|
|
359
|
+
shell: true,
|
|
360
|
+
cwd,
|
|
361
|
+
encoding: 'utf8',
|
|
362
|
+
// Generous for a config read, short enough that a wedged binary delays
|
|
363
|
+
// a run rather than wedging the daemon.
|
|
364
|
+
timeout: 15_000,
|
|
365
|
+
});
|
|
366
|
+
return { status: result.status, stdout: result.stdout ?? '' };
|
|
195
367
|
}
|
|
196
368
|
/**
|
|
197
369
|
* Run the agent command once and turn its output into a reply.
|
|
@@ -202,32 +374,113 @@ function buildRuntime(options, log) {
|
|
|
202
374
|
* tell a failed turn from a successful one. A plain command's behaviour here is
|
|
203
375
|
* byte-for-byte what it was before this function existed.
|
|
204
376
|
*/
|
|
205
|
-
async function runTurn(run, prompt, options, runtime, log) {
|
|
377
|
+
async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
206
378
|
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
207
379
|
// must not let one run take twice what the operator allowed. Both attempts
|
|
208
380
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
209
381
|
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
210
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;
|
|
211
387
|
const startedAt = Date.now();
|
|
212
388
|
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
};
|
|
221
457
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
222
458
|
// rather than what the last attempt took.
|
|
223
459
|
const elapsed = () => Date.now() - startedAt;
|
|
224
|
-
|
|
225
|
-
ok: false,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
460
|
+
if (mcp.error) {
|
|
461
|
+
return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
|
|
462
|
+
}
|
|
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
|
+
};
|
|
231
484
|
if (runtime.brain === 'plain') {
|
|
232
485
|
const result = await exec(options.exec);
|
|
233
486
|
if (!result.ok)
|
|
@@ -237,24 +490,19 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
237
490
|
? { ok: true, reply, durationMs: elapsed() }
|
|
238
491
|
: { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
|
|
239
492
|
}
|
|
240
|
-
|
|
241
|
-
// writes, and a resumed turn is a write: both brains record it in their
|
|
242
|
-
// transcript whether or not we post the reply, so remembering the session
|
|
243
|
-
// would leave the next *real* run continuing from an answer nobody saw.
|
|
244
|
-
const key = options.dryRun ? undefined : sessionKeyFor(run);
|
|
245
|
-
const sessionId = runtime.sessions.get(key);
|
|
493
|
+
const sessionId = runtime.sessions.get(sessionKey);
|
|
246
494
|
const codex = runtime.brain === 'codex';
|
|
247
495
|
// Same three questions for both brains — resume this thread, wire these
|
|
248
496
|
// tools, gate or don't — asked with each one's own flags.
|
|
249
497
|
const build = (resume) => codex
|
|
250
498
|
? codexCommand(options.exec, {
|
|
251
499
|
sessionId: resume,
|
|
252
|
-
|
|
500
|
+
mcpEntries: mcp.codexMcpEntries,
|
|
253
501
|
skipPermissions: runtime.skipPermissions,
|
|
254
502
|
})
|
|
255
503
|
: claudeCommand(options.exec, {
|
|
256
504
|
sessionId: resume,
|
|
257
|
-
mcpConfigPath:
|
|
505
|
+
mcpConfigPath: mcp.mcpConfigPath,
|
|
258
506
|
skipPermissions: runtime.skipPermissions,
|
|
259
507
|
});
|
|
260
508
|
let result = await exec(build(sessionId));
|
|
@@ -268,15 +516,31 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
268
516
|
// Skipped rather than squeezed into a token budget: a retry with no time
|
|
269
517
|
// left would be killed on the spot and reported as a timeout, which is a
|
|
270
518
|
// worse error message than the resume failure it replaced.
|
|
271
|
-
|
|
519
|
+
timeLeft() &&
|
|
520
|
+
// A run somebody just stopped does not get started again on its way out.
|
|
521
|
+
!control.signal.aborted) {
|
|
272
522
|
log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
|
|
273
|
-
runtime.sessions.forget(
|
|
523
|
+
runtime.sessions.forget(sessionKey);
|
|
524
|
+
recordedSession = undefined;
|
|
274
525
|
result = await exec(build());
|
|
275
526
|
}
|
|
276
|
-
if (!result.ok)
|
|
527
|
+
if (!result.ok) {
|
|
528
|
+
if (!result.timedOut && !result.cancelled)
|
|
529
|
+
forgetRecordedSession();
|
|
277
530
|
return failure(result);
|
|
531
|
+
}
|
|
532
|
+
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
533
|
+
// the reply just quietly lacks whatever the tool would have found. Printing
|
|
534
|
+
// it is the difference between "the agent seems dumb" and "the agent asked
|
|
535
|
+
// for WebSearch and there was nobody to approve it".
|
|
536
|
+
const reportDenials = (denials) => {
|
|
537
|
+
if (denials.length > 0) {
|
|
538
|
+
log(` ! tools the agent asked for and was refused: ${denials.join(', ')}`);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
278
541
|
if (codex) {
|
|
279
542
|
const output = parseCodexOutput(result.stdout);
|
|
543
|
+
reportDenials(output.denials);
|
|
280
544
|
if (!output.ok) {
|
|
281
545
|
return {
|
|
282
546
|
ok: false,
|
|
@@ -287,15 +551,15 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
287
551
|
}
|
|
288
552
|
return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
|
|
289
553
|
}
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
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);
|
|
560
|
+
reportDenials(parsed.denials);
|
|
298
561
|
if (!parsed.ok) {
|
|
562
|
+
forgetRecordedSession();
|
|
299
563
|
return {
|
|
300
564
|
ok: false,
|
|
301
565
|
reason: 'the agent did not produce a usable reply',
|
|
@@ -303,7 +567,13 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
303
567
|
durationMs: elapsed(),
|
|
304
568
|
};
|
|
305
569
|
}
|
|
306
|
-
return {
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
reply: parsed.text,
|
|
573
|
+
durationMs: elapsed(),
|
|
574
|
+
sessionId: parsed.sessionId,
|
|
575
|
+
sessionRecorded: recordedSession !== undefined,
|
|
576
|
+
};
|
|
307
577
|
}
|
|
308
578
|
/**
|
|
309
579
|
* Handle one verified run end-to-end.
|
|
@@ -311,23 +581,54 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
311
581
|
* Every path except a dry run — including every failure — calls the completion
|
|
312
582
|
* endpoint before returning.
|
|
313
583
|
*/
|
|
314
|
-
async function handleRun(run, options, runtime, api, log, sleep) {
|
|
584
|
+
async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
315
585
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
316
586
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
317
587
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
588
|
+
const mcp = runtime.prepareMcp();
|
|
318
589
|
const prompt = buildPrompt(run, {
|
|
319
590
|
agentName: options.credentials.agent?.name ?? 'your agent',
|
|
320
591
|
// Named only when the tools are actually reachable from this invocation.
|
|
321
592
|
// Telling an agent about an endpoint it has no client for is what produced
|
|
322
593
|
// replies describing lookups that never happened.
|
|
323
|
-
mcpServerName:
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
594
|
+
mcpServerName: mcp.mcpWired ? MCP_SERVER_NAME : undefined,
|
|
595
|
+
// Both shaped brains spell MCP tools `mcp__<server>__<tool>` — Codex's was
|
|
596
|
+
// an open question until it was measured against the binary in 0.2.3. A
|
|
597
|
+
// `plain` command is still told the server name only: we have no idea what
|
|
598
|
+
// it is. See PromptContext.mcpToolPattern.
|
|
599
|
+
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
327
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,
|
|
328
603
|
});
|
|
329
|
-
const result = await runTurn(run, prompt, options, runtime, log);
|
|
604
|
+
const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
|
|
330
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
|
+
}
|
|
331
632
|
if (!result.ok) {
|
|
332
633
|
const { reason } = result;
|
|
333
634
|
const detail = result.detail ?? '';
|
|
@@ -378,6 +679,12 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
378
679
|
const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
|
|
379
680
|
if (!posted.ok) {
|
|
380
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));
|
|
381
688
|
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
|
|
382
689
|
return reported.ok ? 'failed' : 'unreported';
|
|
383
690
|
}
|
|
@@ -433,9 +740,79 @@ export async function runAgentHarness(options) {
|
|
|
433
740
|
// duplicate — the run is in flight right here. Only a run in the ledger that
|
|
434
741
|
// this process never handled is a candidate for restart recovery.
|
|
435
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 });
|
|
436
746
|
let queue = Promise.resolve();
|
|
437
747
|
let fatal;
|
|
438
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
|
+
};
|
|
439
816
|
const exhausted = () => options.maxConnections !== undefined && metrics.connections >= options.maxConnections;
|
|
440
817
|
while (!options.signal?.aborted && fatal === undefined && !exhausted()) {
|
|
441
818
|
metrics.connections += 1;
|
|
@@ -449,8 +826,14 @@ export async function runAgentHarness(options) {
|
|
|
449
826
|
log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
|
|
450
827
|
await sleep(delay);
|
|
451
828
|
}
|
|
452
|
-
// 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.
|
|
453
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);
|
|
454
837
|
if (fatal !== undefined) {
|
|
455
838
|
log(`${timestamp()} ✗ ${fatal}`);
|
|
456
839
|
metrics.fatal = fatal;
|
|
@@ -598,13 +981,36 @@ export async function runAgentHarness(options) {
|
|
|
598
981
|
// redelivery execute the agent a second time.
|
|
599
982
|
runs.markStarted(run.run_id, run.completion_path);
|
|
600
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
|
+
});
|
|
601
1001
|
// Serialize execution but never block the socket reader: the ack and
|
|
602
1002
|
// the heartbeat have to keep flowing while the agent thinks.
|
|
603
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;
|
|
604
1009
|
// Shutdown was requested while this sat in the queue. Don't start a
|
|
605
1010
|
// fresh command the operator is waiting to be rid of — but do close
|
|
606
1011
|
// the run out, so the server isn't left waiting on it either.
|
|
607
1012
|
if (options.signal?.aborted) {
|
|
1013
|
+
release(run.run_id);
|
|
608
1014
|
log(`${timestamp()} ⏹ run ${run.run_id.slice(0, 12)} dropped — harness is shutting down`);
|
|
609
1015
|
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: 'the harness shut down before this run started' }, log, sleep);
|
|
610
1016
|
if (closed.ok)
|
|
@@ -612,20 +1018,42 @@ export async function runAgentHarness(options) {
|
|
|
612
1018
|
metrics.runsFailed += 1;
|
|
613
1019
|
return;
|
|
614
1020
|
}
|
|
615
|
-
|
|
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) => {
|
|
616
1033
|
log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
|
|
617
1034
|
return 'failed';
|
|
618
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);
|
|
619
1040
|
// Both terminal outcomes reached the completion endpoint; only then
|
|
620
1041
|
// is the run settled and safe to skip on redelivery.
|
|
621
1042
|
if (outcome === 'succeeded')
|
|
622
1043
|
metrics.runsSucceeded += 1;
|
|
623
|
-
|
|
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') {
|
|
624
1047
|
metrics.runsFailed += 1;
|
|
1048
|
+
}
|
|
625
1049
|
// Only a run the server acknowledged as terminal is settled; an
|
|
626
1050
|
// unreported one stays open so a redelivery can report it.
|
|
627
|
-
if (outcome === 'succeeded' ||
|
|
1051
|
+
if (outcome === 'succeeded' ||
|
|
1052
|
+
outcome === 'failed' ||
|
|
1053
|
+
outcome === 'cancelled' ||
|
|
1054
|
+
outcome === 'resolved') {
|
|
628
1055
|
runs.markCompleted(run.run_id);
|
|
1056
|
+
}
|
|
629
1057
|
});
|
|
630
1058
|
});
|
|
631
1059
|
ws.on('close', () => {
|