@cat-factory/executor-harness 1.120.0 → 1.122.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/README.md CHANGED
@@ -320,7 +320,7 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
320
320
 
321
321
  | File | Responsibility |
322
322
  | ------------------ | ------------------------------------------------------------------------------------------------------- |
323
- | `src/server.ts` | HTTP entry point; routes `/health`, `/run`, `/bootstrap`, `/blueprint`, `/jobs/{id}`. |
323
+ | `src/harness-server.ts` | HTTP entry point; routes `/health`, `/run`, `/bootstrap`, `/blueprint`, `/jobs/{id}`. Its file name and the `cat-factory-harness` process title it sets are both deliberate: this process is PID 1 beside an agent that runs arbitrary shell as the same user, so it must not answer to a pattern kill aimed at the service the agent just built. |
324
324
  | `src/runner.ts` | `JobRegistry`: async job lifecycle, idempotent on `jobId`, progress tracking, and the three per-job watchdogs (max-duration, inactivity, tool-silence). |
325
325
  | `src/jsonl-stream.ts` | The BOUNDS on a child CLI's streams, shared by both runners: `JsonlLineReader` frames its JSONL stdout while refusing to buffer a runaway record, `BoundedTail` keeps a capped tail of raw output for failure quoting. Both watchdog timers and the poll endpoints share one event loop with this parsing, so an unbounded buffer here is how a container stops answering polls with no watchdog having fired. |
326
326
  | `src/job.ts` | Request types + validators for the job specs. |
@@ -387,8 +387,8 @@ self-contained.
387
387
 
388
388
  ## Published image (GHCR + Docker Hub)
389
389
 
390
- This package is published to npm (its zero-dependency `dist/server.js` is the
391
- entry `@cat-factory/local-server` spawns in local native mode). In addition, its
390
+ This package is published to npm (its zero-dependency `dist/harness-server.js` is
391
+ the entry `@cat-factory/local-server` spawns in local native mode). In addition, its
392
392
  **Docker image** is published publicly, multi-arch (`linux/amd64` +
393
393
  `linux/arm64`), to **both GHCR and Docker Hub** so anyone can pull it without
394
394
  building from source:
@@ -14,6 +14,7 @@ import { codexImageGapNote, createCodexHome, disposeCodexHome } from './codex-ho
14
14
  import { ProgressGuard } from './progress-guard.js';
15
15
  import { BoundedTail, JsonlLineReader } from './jsonl-stream.js';
16
16
  import { killChildProcess, spawnDetached } from './process.js';
17
+ import { abortReasonOf } from './failure.js';
17
18
  import { describeProcessExit } from './process-exit.js';
18
19
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
19
20
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
@@ -112,10 +113,19 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
112
113
  });
113
114
  }
114
115
  if (aborted) {
115
- // Carry the tail on the rejection so a caller that REPLACES this generic message with a
116
- // more specific cause (the no-progress guard's diagnostic) can still append it — the
117
- // stderr is often the only evidence of what the CLI was doing when it was killed.
118
- reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }));
116
+ // SAY WHO ABORTED IT. Every abort reaches this branch, not just a watchdog's: the
117
+ // shutdown handler aborts every running job (`harness shutting down (SIGTERM)`) and so
118
+ // does a backend-requested stop. A watchdog kill is relabelled downstream from the
119
+ // structured `killReason`, so hard-coding "aborted by watchdog" here was wrong for
120
+ // exactly the aborts that have nothing else to say: a job killed because something
121
+ // shut the harness down reported a watchdog that never fired, which is a wrong lead in
122
+ // the one log an operator has. The reason rides `signal.reason` (see `runner.ts`'s
123
+ // `entry.abort`), the way `settlePiRun` already reads it on the Pi path.
124
+ //
125
+ // Carry the tail on the rejection so a caller that REPLACES this message with a more
126
+ // specific cause (the no-progress guard's diagnostic) can still append it: the stderr
127
+ // is often the only evidence of what the CLI was doing when it was killed.
128
+ reject(Object.assign(new Error(abortReasonOf(opts.signal)), { stderrTail }));
119
129
  return;
120
130
  }
121
131
  if (code !== 0) {
package/dist/failure.d.ts CHANGED
@@ -64,3 +64,14 @@ export declare function maxDurationAbortMessage(maxDurationMs: number): string;
64
64
  * is exactly why the inactivity watchdog never fired.
65
65
  */
66
66
  export declare function toolSilenceAbortMessage(toolSilenceMs: number): string;
67
+ /**
68
+ * What an aborted CLI run says about WHY, read off the signal that killed it.
69
+ *
70
+ * Every abort funnels through one `AbortController` whose reason the caller supplies (a
71
+ * watchdog's own phrase, `harness shutting down (SIGTERM)`, a backend-requested stop), so the
72
+ * signal is the only place that distinction survives the kill. A watchdog abort is relabelled
73
+ * further downstream from the structured `killReason`, which is precisely why the fallback here
74
+ * must NOT name one: an abort with nothing else to say is not a timeout, and saying it was sends
75
+ * whoever reads the job's failure looking for a watchdog that never fired.
76
+ */
77
+ export declare function abortReasonOf(signal: AbortSignal | undefined): string;
package/dist/failure.js CHANGED
@@ -95,3 +95,35 @@ export function toolSilenceAbortMessage(toolSilenceMs) {
95
95
  return (`Aborted: the agent produced output but completed no tool call for ` +
96
96
  `${Math.round(toolSilenceMs / 1000)}s`);
97
97
  }
98
+ /**
99
+ * What an aborted CLI run says about WHY, read off the signal that killed it.
100
+ *
101
+ * Every abort funnels through one `AbortController` whose reason the caller supplies (a
102
+ * watchdog's own phrase, `harness shutting down (SIGTERM)`, a backend-requested stop), so the
103
+ * signal is the only place that distinction survives the kill. A watchdog abort is relabelled
104
+ * further downstream from the structured `killReason`, which is precisely why the fallback here
105
+ * must NOT name one: an abort with nothing else to say is not a timeout, and saying it was sends
106
+ * whoever reads the job's failure looking for a watchdog that never fired.
107
+ */
108
+ export function abortReasonOf(signal) {
109
+ const reason = signal?.reason;
110
+ if (isContentlessAbort(reason))
111
+ return 'agent run aborted';
112
+ return reason instanceof Error && reason.message.trim() ? reason.message : 'agent run aborted';
113
+ }
114
+ /**
115
+ * Whether an abort reason is the platform's OWN, i.e. the one a reasonless `abort()` supplies.
116
+ *
117
+ * Without this the fallback above is unreachable. `controller.abort()` with no argument does not
118
+ * leave `signal.reason` empty: it sets an `AbortError` DOMException, which on Node IS an `Error`
119
+ * and whose message is the contentless "This operation was aborted". So every abort that has
120
+ * nothing to say (the no-progress guard's, whose real diagnostic is folded in by its caller)
121
+ * surfaced that sentence instead, which reads like a quoted cause and names nothing.
122
+ *
123
+ * Keyed on the NAME rather than `instanceof DOMException`, so it holds wherever the class is not a
124
+ * global. A timeout abort (`AbortSignal.timeout`) keeps its own `TimeoutError` message, which does
125
+ * say something.
126
+ */
127
+ function isContentlessAbort(reason) {
128
+ return reason instanceof Error && reason.name === 'AbortError';
129
+ }
@@ -0,0 +1,16 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ declare const server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
3
+ /**
4
+ * What this process calls itself once it is running, and the second half of the naming defence
5
+ * described in the header (the file name is the first).
6
+ *
7
+ * Setting it rewrites BOTH halves of what a pattern kill matches on Linux: `/proc/<pid>/cmdline`
8
+ * becomes this string in full, and `/proc/<pid>/comm` its first 15 characters. So neither
9
+ * `pkill -f 'node dist/…'` (cmdline) nor a bare `pkill node` (name) can name the harness, and a
10
+ * hand-rolled `/proc` sweep for the agent's own service finds only that service.
11
+ *
12
+ * It is deliberately NOT a path: an agent looking for what it started searches for what it
13
+ * started, and nothing an agent runs is called this.
14
+ */
15
+ export declare const PROCESS_TITLE = "cat-factory-harness";
16
+ export { server };
@@ -15,6 +15,14 @@ import { HARNESS_VERSION } from './version.js';
15
15
  // Nothing here holds long-lived secrets: the per-job GitHub + proxy tokens arrive
16
16
  // in the request body and live only for the duration of the job in an ephemeral
17
17
  // workspace.
18
+ //
19
+ // THE FILE NAME IS LOAD-BEARING, and so is {@link PROCESS_TITLE} below. This process is
20
+ // PID 1 of the job container, running as the same uid as the agent's own shell, so any
21
+ // pattern kill the agent runs can reach it. It used to be `dist/server.js`, which is
22
+ // also what an ordinary Node service builds to: a coder run that had just smoke-tested
23
+ // the service it wrote ran `pkill -f 'node dist/server.js'` to stop it, matched PID 1,
24
+ // and shut the harness down mid-job. The engine could only see a container that
25
+ // vanished, so it reported an eviction and re-dispatched into the same trap.
18
26
  const PORT = Number(process.env.PORT ?? 8080);
19
27
  // Optional bind address. Default (unset) binds all interfaces — a container needs that for
20
28
  // its published port. The native local transport runs the harness UNSANDBOXED on the
@@ -185,8 +193,23 @@ const server = createServer((req, res) => {
185
193
  return send(res, 404, { error: 'not found' });
186
194
  })();
187
195
  });
196
+ /**
197
+ * What this process calls itself once it is running, and the second half of the naming defence
198
+ * described in the header (the file name is the first).
199
+ *
200
+ * Setting it rewrites BOTH halves of what a pattern kill matches on Linux: `/proc/<pid>/cmdline`
201
+ * becomes this string in full, and `/proc/<pid>/comm` its first 15 characters. So neither
202
+ * `pkill -f 'node dist/…'` (cmdline) nor a bare `pkill node` (name) can name the harness, and a
203
+ * hand-rolled `/proc` sweep for the agent's own service finds only that service.
204
+ *
205
+ * It is deliberately NOT a path: an agent looking for what it started searches for what it
206
+ * started, and nothing an agent runs is called this.
207
+ */
208
+ export const PROCESS_TITLE = 'cat-factory-harness';
188
209
  // Only auto-listen when run as the entry point (tests import handleRun directly).
189
210
  if (process.env.NODE_ENV !== 'test') {
211
+ // Before `listen`, so no job can be accepted while this process still answers to `node`.
212
+ process.title = PROCESS_TITLE;
190
213
  server.listen(PORT, BIND_HOST, () => {
191
214
  console.log(`executor-harness listening on ${BIND_HOST ?? ''}:${PORT}`);
192
215
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.120.0",
3
+ "version": "1.122.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",
@@ -12,9 +12,9 @@
12
12
  "src"
13
13
  ],
14
14
  "type": "module",
15
- "main": "./dist/server.js",
15
+ "main": "./dist/harness-server.js",
16
16
  "exports": {
17
- ".": "./dist/server.js",
17
+ ".": "./dist/harness-server.js",
18
18
  "./embed": "./src/embed.ts",
19
19
  "./claude-call-aggregator": {
20
20
  "types": "./dist/claude-call-aggregator.d.ts",
@@ -30,14 +30,14 @@
30
30
  "hono": "^4.13.1",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.299.1",
34
- "@cat-factory/server": "0.286.0",
35
- "@cat-factory/spend": "0.15.93"
33
+ "@cat-factory/kernel": "0.300.0",
34
+ "@cat-factory/server": "0.287.0",
35
+ "@cat-factory/spend": "0.15.94"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
39
39
  "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
40
- "start": "node dist/server.js",
40
+ "start": "node dist/harness-server.js",
41
41
  "test": "vitest run",
42
42
  "test:run": "vitest run",
43
43
  "test:acceptance": "vitest run --config vitest.acceptance.config.ts",
@@ -37,6 +37,7 @@ import { codexImageGapNote, createCodexHome, disposeCodexHome } from './codex-ho
37
37
  import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
38
38
  import { BoundedTail, JsonlLineReader } from './jsonl-stream.js'
39
39
  import { killChildProcess, spawnDetached } from './process.js'
40
+ import { abortReasonOf } from './failure.js'
40
41
  import { describeProcessExit } from './process-exit.js'
41
42
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
42
43
  import { createSliceTracker, startSubagentWatcher, type SliceReview } from './subagents.js'
@@ -307,10 +308,19 @@ function streamCli(
307
308
  })
308
309
  }
309
310
  if (aborted) {
310
- // Carry the tail on the rejection so a caller that REPLACES this generic message with a
311
- // more specific cause (the no-progress guard's diagnostic) can still append it — the
312
- // stderr is often the only evidence of what the CLI was doing when it was killed.
313
- reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }))
311
+ // SAY WHO ABORTED IT. Every abort reaches this branch, not just a watchdog's: the
312
+ // shutdown handler aborts every running job (`harness shutting down (SIGTERM)`) and so
313
+ // does a backend-requested stop. A watchdog kill is relabelled downstream from the
314
+ // structured `killReason`, so hard-coding "aborted by watchdog" here was wrong for
315
+ // exactly the aborts that have nothing else to say: a job killed because something
316
+ // shut the harness down reported a watchdog that never fired, which is a wrong lead in
317
+ // the one log an operator has. The reason rides `signal.reason` (see `runner.ts`'s
318
+ // `entry.abort`), the way `settlePiRun` already reads it on the Pi path.
319
+ //
320
+ // Carry the tail on the rejection so a caller that REPLACES this message with a more
321
+ // specific cause (the no-progress guard's diagnostic) can still append it: the stderr
322
+ // is often the only evidence of what the CLI was doing when it was killed.
323
+ reject(Object.assign(new Error(abortReasonOf(opts.signal)), { stderrTail }))
314
324
  return
315
325
  }
316
326
  if (code !== 0) {
package/src/failure.ts CHANGED
@@ -111,3 +111,36 @@ export function toolSilenceAbortMessage(toolSilenceMs: number): string {
111
111
  `${Math.round(toolSilenceMs / 1000)}s`
112
112
  )
113
113
  }
114
+
115
+ /**
116
+ * What an aborted CLI run says about WHY, read off the signal that killed it.
117
+ *
118
+ * Every abort funnels through one `AbortController` whose reason the caller supplies (a
119
+ * watchdog's own phrase, `harness shutting down (SIGTERM)`, a backend-requested stop), so the
120
+ * signal is the only place that distinction survives the kill. A watchdog abort is relabelled
121
+ * further downstream from the structured `killReason`, which is precisely why the fallback here
122
+ * must NOT name one: an abort with nothing else to say is not a timeout, and saying it was sends
123
+ * whoever reads the job's failure looking for a watchdog that never fired.
124
+ */
125
+ export function abortReasonOf(signal: AbortSignal | undefined): string {
126
+ const reason = signal?.reason
127
+ if (isContentlessAbort(reason)) return 'agent run aborted'
128
+ return reason instanceof Error && reason.message.trim() ? reason.message : 'agent run aborted'
129
+ }
130
+
131
+ /**
132
+ * Whether an abort reason is the platform's OWN, i.e. the one a reasonless `abort()` supplies.
133
+ *
134
+ * Without this the fallback above is unreachable. `controller.abort()` with no argument does not
135
+ * leave `signal.reason` empty: it sets an `AbortError` DOMException, which on Node IS an `Error`
136
+ * and whose message is the contentless "This operation was aborted". So every abort that has
137
+ * nothing to say (the no-progress guard's, whose real diagnostic is folded in by its caller)
138
+ * surfaced that sentence instead, which reads like a quoted cause and names nothing.
139
+ *
140
+ * Keyed on the NAME rather than `instanceof DOMException`, so it holds wherever the class is not a
141
+ * global. A timeout abort (`AbortSignal.timeout`) keeps its own `TimeoutError` message, which does
142
+ * say something.
143
+ */
144
+ function isContentlessAbort(reason: unknown): boolean {
145
+ return reason instanceof Error && reason.name === 'AbortError'
146
+ }
@@ -16,6 +16,14 @@ import { HARNESS_VERSION } from './version.js'
16
16
  // Nothing here holds long-lived secrets: the per-job GitHub + proxy tokens arrive
17
17
  // in the request body and live only for the duration of the job in an ephemeral
18
18
  // workspace.
19
+ //
20
+ // THE FILE NAME IS LOAD-BEARING, and so is {@link PROCESS_TITLE} below. This process is
21
+ // PID 1 of the job container, running as the same uid as the agent's own shell, so any
22
+ // pattern kill the agent runs can reach it. It used to be `dist/server.js`, which is
23
+ // also what an ordinary Node service builds to: a coder run that had just smoke-tested
24
+ // the service it wrote ran `pkill -f 'node dist/server.js'` to stop it, matched PID 1,
25
+ // and shut the harness down mid-job. The engine could only see a container that
26
+ // vanished, so it reported an eviction and re-dispatched into the same trap.
19
27
 
20
28
  const PORT = Number(process.env.PORT ?? 8080)
21
29
 
@@ -202,8 +210,24 @@ const server = createServer((req, res) => {
202
210
  })()
203
211
  })
204
212
 
213
+ /**
214
+ * What this process calls itself once it is running, and the second half of the naming defence
215
+ * described in the header (the file name is the first).
216
+ *
217
+ * Setting it rewrites BOTH halves of what a pattern kill matches on Linux: `/proc/<pid>/cmdline`
218
+ * becomes this string in full, and `/proc/<pid>/comm` its first 15 characters. So neither
219
+ * `pkill -f 'node dist/…'` (cmdline) nor a bare `pkill node` (name) can name the harness, and a
220
+ * hand-rolled `/proc` sweep for the agent's own service finds only that service.
221
+ *
222
+ * It is deliberately NOT a path: an agent looking for what it started searches for what it
223
+ * started, and nothing an agent runs is called this.
224
+ */
225
+ export const PROCESS_TITLE = 'cat-factory-harness'
226
+
205
227
  // Only auto-listen when run as the entry point (tests import handleRun directly).
206
228
  if (process.env.NODE_ENV !== 'test') {
229
+ // Before `listen`, so no job can be accepted while this process still answers to `node`.
230
+ process.title = PROCESS_TITLE
207
231
  server.listen(PORT, BIND_HOST, () => {
208
232
  console.log(`executor-harness listening on ${BIND_HOST ?? ''}:${PORT}`)
209
233
  })
package/dist/server.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- declare const server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
3
- export { server };