@cat-factory/executor-harness 1.92.2 → 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.
@@ -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` the same copy-plus-pin arrangement
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` the same copy-plus-pin arrangement
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
- return send(res, 202, { jobId: view.id, state: view.state });
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.92.2",
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",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.242.0",
34
- "@cat-factory/server": "0.222.0",
35
- "@cat-factory/spend": "0.15.6"
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` the same copy-plus-pin arrangement
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/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
- return send(res, 202, { jobId: view.id, state: view.state })
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))