@cat-factory/executor-harness 1.92.0 → 1.94.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/dist/agent-capabilities.d.ts +19 -1
- package/dist/agent-capabilities.js +19 -1
- package/dist/runner.d.ts +18 -0
- package/dist/runner.js +47 -0
- package/dist/server.js +34 -1
- package/package.json +6 -6
- package/src/agent-capabilities.ts +20 -1
- package/src/coding-agent.ts +27 -31
- package/src/runner.ts +46 -0
- package/src/server.ts +33 -1
|
@@ -55,12 +55,30 @@ export declare function mcpServerSecretValues(servers: readonly McpServerSpec[])
|
|
|
55
55
|
* last — so the first wins and the collision is dropped rather than silently mixing two playbooks.
|
|
56
56
|
*/
|
|
57
57
|
export declare function parseSkillSpecs(value: unknown): SkillSpec[] | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
|
|
60
|
+
* `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
|
|
61
|
+
*
|
|
62
|
+
* The gap it closes: an image older than a capability does not fail on it, it ignores the field.
|
|
63
|
+
* The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
|
|
64
|
+
* tools it has no client for: a blind run rather than a failed one, and previously invisible to
|
|
65
|
+
* the backend, which has no way to know what image a self-hosted runner pool pins.
|
|
66
|
+
*
|
|
67
|
+
* Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
|
|
68
|
+
* typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
69
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
70
|
+
* {@link MCP_SERVER_ID_PATTERN} uses.
|
|
71
|
+
*
|
|
72
|
+
* A member is added here in the SAME change that teaches the parser the field, never ahead of it:
|
|
73
|
+
* the whole value of the list is that it is the image's own honest answer.
|
|
74
|
+
*/
|
|
75
|
+
export declare const HARNESS_BODY_CAPABILITIES: readonly string[];
|
|
58
76
|
/**
|
|
59
77
|
* A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
|
|
60
78
|
*
|
|
61
79
|
* Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
|
|
62
80
|
* plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
63
|
-
* against it by `test/agent-capabilities.conformity.test.ts
|
|
81
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
64
82
|
* `src/host-markdown.ts` uses.
|
|
65
83
|
*/
|
|
66
84
|
export declare const MCP_SERVER_ID_PATTERN: RegExp;
|
|
@@ -111,12 +111,30 @@ export function parseSkillSpecs(value) {
|
|
|
111
111
|
}
|
|
112
112
|
return skills.length ? skills : undefined;
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
|
|
116
|
+
* `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
|
|
117
|
+
*
|
|
118
|
+
* The gap it closes: an image older than a capability does not fail on it, it ignores the field.
|
|
119
|
+
* The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
|
|
120
|
+
* tools it has no client for: a blind run rather than a failed one, and previously invisible to
|
|
121
|
+
* the backend, which has no way to know what image a self-hosted runner pool pins.
|
|
122
|
+
*
|
|
123
|
+
* Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
|
|
124
|
+
* typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
125
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
126
|
+
* {@link MCP_SERVER_ID_PATTERN} uses.
|
|
127
|
+
*
|
|
128
|
+
* A member is added here in the SAME change that teaches the parser the field, never ahead of it:
|
|
129
|
+
* the whole value of the list is that it is the image's own honest answer.
|
|
130
|
+
*/
|
|
131
|
+
export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills'];
|
|
114
132
|
/**
|
|
115
133
|
* A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
|
|
116
134
|
*
|
|
117
135
|
* Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
|
|
118
136
|
* plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
119
|
-
* against it by `test/agent-capabilities.conformity.test.ts
|
|
137
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
120
138
|
* `src/host-markdown.ts` uses.
|
|
121
139
|
*/
|
|
122
140
|
export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
package/dist/runner.d.ts
CHANGED
|
@@ -256,6 +256,24 @@ export declare class JobRegistry<TJob = unknown, TResult extends JobResultBase =
|
|
|
256
256
|
* number of jobs aborted.
|
|
257
257
|
*/
|
|
258
258
|
abortAll(reason: string): number;
|
|
259
|
+
/**
|
|
260
|
+
* Abort ONE job and answer with the state it actually reached.
|
|
261
|
+
*
|
|
262
|
+
* The caller is a backend that has decided this job must not run: it refused the dispatch as
|
|
263
|
+
* blind, and the harness starts work on acceptance, so without this the agent runs to completion
|
|
264
|
+
* and can push a branch and open a pull request for a step the engine already failed. Aborting
|
|
265
|
+
* every job ({@link abortAll}) is not an option: a pooled container serves other runs.
|
|
266
|
+
*
|
|
267
|
+
* Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
|
|
268
|
+
* fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
|
|
269
|
+
* reports "stopped" to a human only on the strength of this answer. The window matches the
|
|
270
|
+
* graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
|
|
271
|
+
* cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
|
|
272
|
+
* and a job still `running` when it expires is reported as such rather than assumed dead.
|
|
273
|
+
*
|
|
274
|
+
* Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
|
|
275
|
+
*/
|
|
276
|
+
abort(id: string, reason: string): Promise<JobState | undefined>;
|
|
259
277
|
/**
|
|
260
278
|
* How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
|
|
261
279
|
* aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
|
package/dist/runner.js
CHANGED
|
@@ -33,6 +33,13 @@ function toView(entry) {
|
|
|
33
33
|
const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, callMetricBuffer: _callMetricBuffer, callMetricSeq: _callMetricSeq, abort: _abort, ...view } = entry;
|
|
34
34
|
return { ...view };
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* How long {@link JobRegistry.abort} waits for an aborted job to actually settle before answering
|
|
38
|
+
* with whatever state it is in. Sized like the graceful-shutdown window (and for the same reason):
|
|
39
|
+
* the agent CLI normally honours SIGTERM in milliseconds, and this covers one that had to be
|
|
40
|
+
* force-killed through the 5s SIGTERM→SIGKILL escalation, with a margin.
|
|
41
|
+
*/
|
|
42
|
+
const ABORT_SETTLE_MS = 6_000;
|
|
36
43
|
/**
|
|
37
44
|
* Tracks background jobs by id. Keyed by the backend-supplied job id (the per-step
|
|
38
45
|
* job id) so a re-dispatched start re-attaches to the running job rather than starting
|
|
@@ -124,6 +131,46 @@ export class JobRegistry {
|
|
|
124
131
|
}
|
|
125
132
|
return aborted;
|
|
126
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Abort ONE job and answer with the state it actually reached.
|
|
136
|
+
*
|
|
137
|
+
* The caller is a backend that has decided this job must not run: it refused the dispatch as
|
|
138
|
+
* blind, and the harness starts work on acceptance, so without this the agent runs to completion
|
|
139
|
+
* and can push a branch and open a pull request for a step the engine already failed. Aborting
|
|
140
|
+
* every job ({@link abortAll}) is not an option: a pooled container serves other runs.
|
|
141
|
+
*
|
|
142
|
+
* Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
|
|
143
|
+
* fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
|
|
144
|
+
* reports "stopped" to a human only on the strength of this answer. The window matches the
|
|
145
|
+
* graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
|
|
146
|
+
* cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
|
|
147
|
+
* and a job still `running` when it expires is reported as such rather than assumed dead.
|
|
148
|
+
*
|
|
149
|
+
* Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
|
|
150
|
+
*/
|
|
151
|
+
async abort(id, reason) {
|
|
152
|
+
const entry = this.jobs.get(id);
|
|
153
|
+
if (!entry)
|
|
154
|
+
return undefined;
|
|
155
|
+
// Already terminal: nothing to stop, and re-firing a cleared abort would be a no-op anyway.
|
|
156
|
+
// This is what makes the call idempotent for a caller that retries.
|
|
157
|
+
if (entry.state !== 'running')
|
|
158
|
+
return entry.state;
|
|
159
|
+
entry.abort?.(reason);
|
|
160
|
+
let timer;
|
|
161
|
+
try {
|
|
162
|
+
await Promise.race([
|
|
163
|
+
entry.promise,
|
|
164
|
+
new Promise((resolve) => {
|
|
165
|
+
timer = setTimeout(resolve, ABORT_SETTLE_MS);
|
|
166
|
+
}),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
return entry.state;
|
|
173
|
+
}
|
|
127
174
|
/**
|
|
128
175
|
* How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
|
|
129
176
|
* aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
+
import { HARNESS_BODY_CAPABILITIES } from './agent-capabilities.js';
|
|
3
4
|
import { parseAgentJob, parseInlineJob } from './job.js';
|
|
4
5
|
import { handleAgent } from './agent.js';
|
|
5
6
|
import { handleInline } from './inline.js';
|
|
@@ -103,6 +104,7 @@ const server = createServer((req, res) => {
|
|
|
103
104
|
return send(res, 200, {
|
|
104
105
|
status: 'ok',
|
|
105
106
|
...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
|
|
107
|
+
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
106
108
|
});
|
|
107
109
|
}
|
|
108
110
|
// All non-health endpoints are gated by the optional shared secret.
|
|
@@ -120,6 +122,28 @@ const server = createServer((req, res) => {
|
|
|
120
122
|
}
|
|
121
123
|
return send(res, 404, { error: 'job not found' });
|
|
122
124
|
}
|
|
125
|
+
// Stop one job: DELETE /jobs/{id}. The counterpart of the capability handshake below. A
|
|
126
|
+
// backend that reads the acceptance and decides the body cannot be honoured has, by then,
|
|
127
|
+
// already started an agent, and the only thing that keeps it from running to completion (and
|
|
128
|
+
// opening a pull request for a step the engine has failed) is being told to stop.
|
|
129
|
+
//
|
|
130
|
+
// Scoped to ONE job on purpose: a pooled container serves other runs, so the shutdown-time
|
|
131
|
+
// `abortAll` is not an alternative. The response reports the state the job actually REACHED
|
|
132
|
+
// (the registry waits for it to settle), never merely that the signal was sent, because the
|
|
133
|
+
// caller turns this into a statement to a human about whether anything is still running.
|
|
134
|
+
if (req.method === 'DELETE' && req.url?.startsWith('/jobs/')) {
|
|
135
|
+
const id = decodeURIComponent(req.url.slice('/jobs/'.length));
|
|
136
|
+
// `abort` (not `get`) is the existence probe: `get` DRAINS the job's span / follow-up /
|
|
137
|
+
// call-metric buffers, so probing with it would swallow telemetry the backend never polled.
|
|
138
|
+
for (const { registry } of Object.values(KINDS)) {
|
|
139
|
+
const state = await registry.abort(id, 'stopped by the backend');
|
|
140
|
+
if (state)
|
|
141
|
+
return send(res, 200, { jobId: id, state });
|
|
142
|
+
}
|
|
143
|
+
// No such job here. A 404 is NOT "already stopped": it is also what a caller addressing the
|
|
144
|
+
// wrong runner sees, so it must stay distinguishable from the 200 above.
|
|
145
|
+
return send(res, 404, { error: 'job not found' });
|
|
146
|
+
}
|
|
123
147
|
// Start (or re-attach to) a job: POST /jobs with the kind in the body. The body's
|
|
124
148
|
// `kind` selects the validator + registry; the rest is that kind's job spec.
|
|
125
149
|
// Returns immediately with the job id; the caller polls GET /jobs/{id} for live
|
|
@@ -137,7 +161,16 @@ const server = createServer((req, res) => {
|
|
|
137
161
|
}
|
|
138
162
|
const job = entry.parse(raw);
|
|
139
163
|
const view = entry.registry.start(job.jobId, job);
|
|
140
|
-
|
|
164
|
+
// The capability handshake rides the ACCEPTANCE, not the poll view. The dispatch site
|
|
165
|
+
// is the only place the body it just sent is still in scope, and it is the last moment
|
|
166
|
+
// a blind run can be refused before the agent starts working from a prompt the body
|
|
167
|
+
// cannot back up. It is also a static fact about the IMAGE, so repeating it on every
|
|
168
|
+
// poll of a job that may run for an hour would be noise.
|
|
169
|
+
return send(res, 202, {
|
|
170
|
+
jobId: view.id,
|
|
171
|
+
state: view.state,
|
|
172
|
+
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
173
|
+
});
|
|
141
174
|
}
|
|
142
175
|
catch (error) {
|
|
143
176
|
// Parse failures (incl. host-allowlist rejection) are client errors → 400.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.94.0",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@hono/node-server": "^2.0
|
|
28
|
+
"@hono/node-server": "^2.1.0",
|
|
29
29
|
"@types/node": "^26.1.2",
|
|
30
|
-
"hono": "^4.
|
|
30
|
+
"hono": "^4.13.0",
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
32
|
"vitest": "^4.1.10",
|
|
33
|
-
"@cat-factory/kernel": "0.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.
|
|
33
|
+
"@cat-factory/kernel": "0.249.0",
|
|
34
|
+
"@cat-factory/server": "0.229.0",
|
|
35
|
+
"@cat-factory/spend": "0.15.14"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
|
@@ -163,12 +163,31 @@ export function parseSkillSpecs(value: unknown): SkillSpec[] | undefined {
|
|
|
163
163
|
return skills.length ? skills : undefined
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
|
|
168
|
+
* `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
|
|
169
|
+
*
|
|
170
|
+
* The gap it closes: an image older than a capability does not fail on it, it ignores the field.
|
|
171
|
+
* The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
|
|
172
|
+
* tools it has no client for: a blind run rather than a failed one, and previously invisible to
|
|
173
|
+
* the backend, which has no way to know what image a self-hosted runner pool pins.
|
|
174
|
+
*
|
|
175
|
+
* Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
|
|
176
|
+
* typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
177
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
178
|
+
* {@link MCP_SERVER_ID_PATTERN} uses.
|
|
179
|
+
*
|
|
180
|
+
* A member is added here in the SAME change that teaches the parser the field, never ahead of it:
|
|
181
|
+
* the whole value of the list is that it is the image's own honest answer.
|
|
182
|
+
*/
|
|
183
|
+
export const HARNESS_BODY_CAPABILITIES: readonly string[] = ['mcpServers', 'skills']
|
|
184
|
+
|
|
166
185
|
/**
|
|
167
186
|
* A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
|
|
168
187
|
*
|
|
169
188
|
* Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
|
|
170
189
|
* plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
|
|
171
|
-
* against it by `test/agent-capabilities.conformity.test.ts
|
|
190
|
+
* against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
|
|
172
191
|
* `src/host-markdown.ts` uses.
|
|
173
192
|
*/
|
|
174
193
|
export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
package/src/coding-agent.ts
CHANGED
|
@@ -1101,40 +1101,36 @@ export async function runMultiRepoCoding(
|
|
|
1101
1101
|
baseSha: '',
|
|
1102
1102
|
resumed: false,
|
|
1103
1103
|
},
|
|
1104
|
-
...peers.map(
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
}),
|
|
1120
|
-
),
|
|
1104
|
+
...peers.map((peer): RepoLeg => ({
|
|
1105
|
+
repo: peer.repo,
|
|
1106
|
+
dirName: claimDir(peer.repo),
|
|
1107
|
+
dir: '',
|
|
1108
|
+
cloneBranch: peer.repo.baseBranch,
|
|
1109
|
+
// Coding peers always carry `newBranch` (the backend sets the shared work branch);
|
|
1110
|
+
// fall back to the primary's for the type (read-only peers never reach this path).
|
|
1111
|
+
workBranch: peer.newBranch ?? primaryWorkBranch,
|
|
1112
|
+
ghToken: peer.ghToken ?? job.ghToken,
|
|
1113
|
+
...(peer.pr ? { pr: peer.pr } : {}),
|
|
1114
|
+
...(peer.frameId ? { frameId: peer.frameId } : {}),
|
|
1115
|
+
primary: false,
|
|
1116
|
+
baseSha: '',
|
|
1117
|
+
resumed: false,
|
|
1118
|
+
})),
|
|
1121
1119
|
// Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
|
|
1122
1120
|
// `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
|
|
1123
1121
|
// pushes (guarded by `readOnly` in both the clone and push phases below).
|
|
1124
|
-
...references.map(
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
}),
|
|
1137
|
-
),
|
|
1122
|
+
...references.map((reference): RepoLeg => ({
|
|
1123
|
+
repo: reference.repo,
|
|
1124
|
+
dirName: claimDir(reference.repo),
|
|
1125
|
+
dir: '',
|
|
1126
|
+
cloneBranch: reference.repo.baseBranch,
|
|
1127
|
+
workBranch: reference.repo.baseBranch,
|
|
1128
|
+
ghToken: reference.ghToken ?? job.ghToken,
|
|
1129
|
+
primary: false,
|
|
1130
|
+
readOnly: true,
|
|
1131
|
+
baseSha: '',
|
|
1132
|
+
resumed: false,
|
|
1133
|
+
})),
|
|
1138
1134
|
]
|
|
1139
1135
|
|
|
1140
1136
|
return withWorkspace('multi', async (root) => {
|
package/src/runner.ts
CHANGED
|
@@ -304,6 +304,14 @@ function toView<TResult extends JobResultBase>(entry: JobEntry<TResult>): JobVie
|
|
|
304
304
|
return { ...view }
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* How long {@link JobRegistry.abort} waits for an aborted job to actually settle before answering
|
|
309
|
+
* with whatever state it is in. Sized like the graceful-shutdown window (and for the same reason):
|
|
310
|
+
* the agent CLI normally honours SIGTERM in milliseconds, and this covers one that had to be
|
|
311
|
+
* force-killed through the 5s SIGTERM→SIGKILL escalation, with a margin.
|
|
312
|
+
*/
|
|
313
|
+
const ABORT_SETTLE_MS = 6_000
|
|
314
|
+
|
|
307
315
|
/**
|
|
308
316
|
* Tracks background jobs by id. Keyed by the backend-supplied job id (the per-step
|
|
309
317
|
* job id) so a re-dispatched start re-attaches to the running job rather than starting
|
|
@@ -394,6 +402,44 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
394
402
|
return aborted
|
|
395
403
|
}
|
|
396
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Abort ONE job and answer with the state it actually reached.
|
|
407
|
+
*
|
|
408
|
+
* The caller is a backend that has decided this job must not run: it refused the dispatch as
|
|
409
|
+
* blind, and the harness starts work on acceptance, so without this the agent runs to completion
|
|
410
|
+
* and can push a branch and open a pull request for a step the engine already failed. Aborting
|
|
411
|
+
* every job ({@link abortAll}) is not an option: a pooled container serves other runs.
|
|
412
|
+
*
|
|
413
|
+
* Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
|
|
414
|
+
* fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
|
|
415
|
+
* reports "stopped" to a human only on the strength of this answer. The window matches the
|
|
416
|
+
* graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
|
|
417
|
+
* cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
|
|
418
|
+
* and a job still `running` when it expires is reported as such rather than assumed dead.
|
|
419
|
+
*
|
|
420
|
+
* Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
|
|
421
|
+
*/
|
|
422
|
+
async abort(id: string, reason: string): Promise<JobState | undefined> {
|
|
423
|
+
const entry = this.jobs.get(id)
|
|
424
|
+
if (!entry) return undefined
|
|
425
|
+
// Already terminal: nothing to stop, and re-firing a cleared abort would be a no-op anyway.
|
|
426
|
+
// This is what makes the call idempotent for a caller that retries.
|
|
427
|
+
if (entry.state !== 'running') return entry.state
|
|
428
|
+
entry.abort?.(reason)
|
|
429
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
430
|
+
try {
|
|
431
|
+
await Promise.race([
|
|
432
|
+
entry.promise,
|
|
433
|
+
new Promise<void>((resolve) => {
|
|
434
|
+
timer = setTimeout(resolve, ABORT_SETTLE_MS)
|
|
435
|
+
}),
|
|
436
|
+
])
|
|
437
|
+
} finally {
|
|
438
|
+
clearTimeout(timer)
|
|
439
|
+
}
|
|
440
|
+
return entry.state
|
|
441
|
+
}
|
|
442
|
+
|
|
397
443
|
/**
|
|
398
444
|
* How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
|
|
399
445
|
* aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
3
|
+
import { HARNESS_BODY_CAPABILITIES } from './agent-capabilities.js'
|
|
3
4
|
import { parseAgentJob, parseInlineJob } from './job.js'
|
|
4
5
|
import { handleAgent } from './agent.js'
|
|
5
6
|
import { handleInline } from './inline.js'
|
|
@@ -122,6 +123,7 @@ const server = createServer((req, res) => {
|
|
|
122
123
|
return send(res, 200, {
|
|
123
124
|
status: 'ok',
|
|
124
125
|
...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
|
|
126
|
+
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
125
127
|
})
|
|
126
128
|
}
|
|
127
129
|
// All non-health endpoints are gated by the optional shared secret.
|
|
@@ -138,6 +140,27 @@ const server = createServer((req, res) => {
|
|
|
138
140
|
}
|
|
139
141
|
return send(res, 404, { error: 'job not found' })
|
|
140
142
|
}
|
|
143
|
+
// Stop one job: DELETE /jobs/{id}. The counterpart of the capability handshake below. A
|
|
144
|
+
// backend that reads the acceptance and decides the body cannot be honoured has, by then,
|
|
145
|
+
// already started an agent, and the only thing that keeps it from running to completion (and
|
|
146
|
+
// opening a pull request for a step the engine has failed) is being told to stop.
|
|
147
|
+
//
|
|
148
|
+
// Scoped to ONE job on purpose: a pooled container serves other runs, so the shutdown-time
|
|
149
|
+
// `abortAll` is not an alternative. The response reports the state the job actually REACHED
|
|
150
|
+
// (the registry waits for it to settle), never merely that the signal was sent, because the
|
|
151
|
+
// caller turns this into a statement to a human about whether anything is still running.
|
|
152
|
+
if (req.method === 'DELETE' && req.url?.startsWith('/jobs/')) {
|
|
153
|
+
const id = decodeURIComponent(req.url.slice('/jobs/'.length))
|
|
154
|
+
// `abort` (not `get`) is the existence probe: `get` DRAINS the job's span / follow-up /
|
|
155
|
+
// call-metric buffers, so probing with it would swallow telemetry the backend never polled.
|
|
156
|
+
for (const { registry } of Object.values(KINDS)) {
|
|
157
|
+
const state = await registry.abort(id, 'stopped by the backend')
|
|
158
|
+
if (state) return send(res, 200, { jobId: id, state })
|
|
159
|
+
}
|
|
160
|
+
// No such job here. A 404 is NOT "already stopped": it is also what a caller addressing the
|
|
161
|
+
// wrong runner sees, so it must stay distinguishable from the 200 above.
|
|
162
|
+
return send(res, 404, { error: 'job not found' })
|
|
163
|
+
}
|
|
141
164
|
// Start (or re-attach to) a job: POST /jobs with the kind in the body. The body's
|
|
142
165
|
// `kind` selects the validator + registry; the rest is that kind's job spec.
|
|
143
166
|
// Returns immediately with the job id; the caller polls GET /jobs/{id} for live
|
|
@@ -155,7 +178,16 @@ const server = createServer((req, res) => {
|
|
|
155
178
|
}
|
|
156
179
|
const job = entry.parse(raw)
|
|
157
180
|
const view = entry.registry.start(job.jobId, job as never)
|
|
158
|
-
|
|
181
|
+
// The capability handshake rides the ACCEPTANCE, not the poll view. The dispatch site
|
|
182
|
+
// is the only place the body it just sent is still in scope, and it is the last moment
|
|
183
|
+
// a blind run can be refused before the agent starts working from a prompt the body
|
|
184
|
+
// cannot back up. It is also a static fact about the IMAGE, so repeating it on every
|
|
185
|
+
// poll of a job that may run for an hour would be noise.
|
|
186
|
+
return send(res, 202, {
|
|
187
|
+
jobId: view.id,
|
|
188
|
+
state: view.state,
|
|
189
|
+
capabilities: HARNESS_BODY_CAPABILITIES,
|
|
190
|
+
})
|
|
159
191
|
} catch (error) {
|
|
160
192
|
// Parse failures (incl. host-allowlist rejection) are client errors → 400.
|
|
161
193
|
const message = redactSecrets(error instanceof Error ? error.message : String(error))
|