mtech-ai-local-runner 0.1.30 → 0.1.32

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.
Files changed (49) hide show
  1. package/README.md +64 -1
  2. package/dist/src/adapters/claude-code/adapter.js +76 -17
  3. package/dist/src/adapters/codex/adapter.js +146 -30
  4. package/dist/src/adapters/codex/exec-launch-host.js +50 -11
  5. package/dist/src/adapters/codex/stream-parser.js +8 -17
  6. package/dist/src/adapters/completion-report.js +8 -5
  7. package/dist/src/cli.js +23 -0
  8. package/dist/src/config.js +4 -4
  9. package/dist/src/daemon/daemon.js +62 -4
  10. package/dist/src/daemon/execution-coordinator.js +351 -77
  11. package/dist/src/daemon/job-poller.js +4 -1
  12. package/dist/src/daemon/manual-codex.js +10 -3
  13. package/dist/src/daemon/preparation-lease.js +142 -0
  14. package/dist/src/daemon/runtime.js +65 -23
  15. package/dist/src/daemon/terminal-recovery.js +29 -5
  16. package/dist/src/gateway-client.js +120 -22
  17. package/dist/src/platform/macos/launchd.js +13 -2
  18. package/dist/src/process/node-process-group-host.js +89 -34
  19. package/dist/src/process/owned-process-guardian.js +140 -0
  20. package/dist/src/publication/candidate.js +95 -0
  21. package/dist/src/publication/execution-host.js +55 -0
  22. package/dist/src/publication/git-credential-broker.js +150 -0
  23. package/dist/src/publication/git-credential-helper.js +29 -0
  24. package/dist/src/publication/git-snapshot.js +321 -0
  25. package/dist/src/publication/git-transport.js +156 -0
  26. package/dist/src/publication/installed-host.js +120 -0
  27. package/dist/src/publication/isolated-verifier.js +67 -0
  28. package/dist/src/publication/journal.js +87 -0
  29. package/dist/src/publication/production-host.js +155 -0
  30. package/dist/src/publication/project-registration.js +49 -0
  31. package/dist/src/publication/publisher.js +166 -0
  32. package/dist/src/publication/recovery.js +53 -0
  33. package/dist/src/publication/runtime-release.js +91 -0
  34. package/dist/src/publication/server-ci-error.js +15 -0
  35. package/dist/src/publication/server-ci-host.js +160 -0
  36. package/dist/src/runner.js +33 -4
  37. package/dist/src/safety/codex-daemon-host.js +2 -2
  38. package/dist/src/safety/command-policy-core.js +124 -0
  39. package/dist/src/safety/command-policy.js +1 -124
  40. package/dist/src/safety/execution-failure.js +4 -0
  41. package/dist/src/safety/job-tool-policy.js +9 -0
  42. package/dist/src/safety/local-tool-policy.js +1 -1
  43. package/dist/src/safety/terminal-evidence.js +67 -16
  44. package/dist/src/safety/unsupported-supervisor-command.js +12 -0
  45. package/dist/src/state-store.js +50 -7
  46. package/dist/src/workspace/workspace-manager.js +14 -0
  47. package/package.json +3 -2
  48. package/verification/README.md +63 -0
  49. package/verification/runner-policy.cjs +24 -0
package/README.md CHANGED
@@ -1,3 +1,65 @@
1
+ ## Unreleased: P0 terminal safety baseline (local verification only)
2
+
3
+ The upstream source package is `0.1.31`; P0 does not bump or publish a version.
4
+ Deploy a compatible Gateway receiver before upgrading any runner. Publication,
5
+ installation, restart, flag activation and live acceptance require their own
6
+ delivery gates; this local change authorizes none of them. See the repository
7
+ runbook at `docs/ai-ops-gateway/runbooks/local-agent-daemon-rollout.md`.
8
+
9
+ Pause/replan now report Runtime `cancelled` and Job `cancelled`. Blocked reports,
10
+ required questions and scope conflicts report Runtime `failed` and Job `blocked`,
11
+ with bounded useful diagnostics. Gateway retains compatibility with historical
12
+ blocked pause/replan receipts. Claude revision messages preserve actual
13
+ `acceptance_criteria` and `required_changes`. Unsupported active Codex controls
14
+ stop the owned launch and acknowledge transport; that ACK does not mean the
15
+ requested revision was performed. No automatic relaunch or grant reuse occurs.
16
+
17
+ Terminal receipts are retained and replayed exactly. On restart, claimed/shadow
18
+ attempts report failed `runner_restarted`; binding/binding_uncertain attempts
19
+ report blocked `binding_outcome_unknown`. Running/reconciling attempts stay
20
+ manually suspended because previous OS cleanup is unknown. Server expiry closure
21
+ records failed provenance; it does not kill a process, complete a Loop or attest
22
+ client evidence. Minimal fsynced recovery markers and invalid state (including
23
+ JSON null) are preserved. Do not delete dirty workspaces, recovery or audit state
24
+ to resume claims. State-store mutations are serialized and save/suspend/clear
25
+ snapshot their input before queuing; a hung filesystem can leave durable recovery
26
+ pending, without a false acknowledgement or new claims.
27
+
28
+ A model-free guardian owns each newly created executor group and observes a
29
+ private parent-liveness pipe. Parent EOF invokes TERM, a 2-second grace, then
30
+ KILL for surviving owned processes. The 6-second cleanup-confirmation bound is
31
+ not a model-runtime budget. Guardian death or descendants escaping the group
32
+ remain an unconfirmed-cleanup/manual-reconciliation gap; local tests do not
33
+ establish Task 7/P0 operational acceptance.
34
+
35
+ Tracked diff hashing streams UTF-8-compatible input and retains only hashes and
36
+ manifest metadata; collection is abort-fenced. Preparation immediately renews the
37
+ Job lease with a single-flight heartbeat, checking exact job ID, generation,
38
+ status and expiry. The existing 900000 ms deadline bounds preparation only.
39
+ Transient retries use 1/2/4-second backoff, at most three, only within the last
40
+ confirmed lease and deadline. Authentication (401/403), fingerprint and fence
41
+ failures are fatal. Git workspace preparation has no abort API: late audit
42
+ workspaces are retained, but late completion cannot launch a downstream model.
43
+ OAuth policy, admission and manual pause behavior remain unchanged.
44
+
45
+ Running execution has an independent watchdog for the last server-confirmed Job
46
+ lease. Hung startup, control, heartbeat, stream, evidence or event-response waits
47
+ cannot defer cancellation past that lease. Late responses cannot renew host
48
+ authority, acknowledge a command or advance reporting/state after fence loss;
49
+ an unknown remote append remains uncertain for reconciliation. Healthy validated
50
+ renewals extend execution without a fixed model-runtime limit.
51
+
52
+ Local-fix preparation, owned freeze, publication finalization and server cursor
53
+ synchronization share that fence. A stopped or expired attempt cannot regain
54
+ publication authority through a late response. Once publication may have taken
55
+ effect, retain `publication_pending` recovery instead of falsely completing,
56
+ failing or restarting the attempt. Canonical publication approval remains required.
57
+
58
+ Component, shadow admission, installed-package and live-provider evidence are
59
+ separate. Historical 0.1.10 hook behavior below is not current Claude admission;
60
+ Claude parity requires the actual current isolation/admission gate to pass.
61
+ No live push/hook probe or real CLI/model run is part of this verification.
62
+
1
63
  ## 0.1.30: governed local alert investigation evidence
2
64
 
3
65
  The Gateway candidate offers explicit Local Codex Runner or Local Claude Runner
@@ -337,7 +399,8 @@ npx mtech-ai-local-runner doctor
337
399
  Cancelling a Work Loop also cancels its non-terminal local jobs. Repeating the
338
400
  cancellation repairs jobs left by an earlier Gateway version; terminal results
339
401
  are preserved. Claim/reclaim and lease-gated launch checks reject cancelled
340
- Loops. An active adapter stops when the next lease heartbeat fails; cancellation
402
+ Loops. An active adapter stops on a failed heartbeat or expiry of its last
403
+ confirmed Job lease, even when the current request never responds. Cancellation
341
404
  is not an immediate process-kill acknowledgement.
342
405
 
343
406
  Before binding, the Gateway permits at most three queue deliveries and a
@@ -44,6 +44,9 @@ export class ClaudeCodeAdapter {
44
44
  tools;
45
45
  cancelled = false;
46
46
  started = false;
47
+ startupAcquisition;
48
+ revokedTools;
49
+ revokeFailed = false;
47
50
  receipt;
48
51
  toolEndpoint;
49
52
  readSnapshot;
@@ -59,6 +62,8 @@ export class ClaudeCodeAdapter {
59
62
  this.options = options;
60
63
  }
61
64
  async start(ctx) {
65
+ if (this.cancelled)
66
+ throw new Error('adapter startup cancelled');
62
67
  const resolvedModelId = ctx.resolvedModelId;
63
68
  if (!resolvedModelId) {
64
69
  throw new Error("refusing to start: no Gateway-resolved model ID was supplied");
@@ -100,6 +105,10 @@ export class ClaudeCodeAdapter {
100
105
  }
101
106
  const hookCommand = this.requireTrustedHookCommand();
102
107
  const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
108
+ if (this.cancelled)
109
+ throw new Error('adapter startup cancelled');
110
+ let acquired;
111
+ this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
103
112
  try {
104
113
  if (!this.options.createTools || !this.options.writeLocalMcpConfig)
105
114
  throw new Error("job-local tool setup is required");
@@ -165,9 +174,15 @@ export class ClaudeCodeAdapter {
165
174
  handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: prompt }] } })}\n`);
166
175
  }
167
176
  catch (error) {
177
+ // Settle only the ownership-acquisition fence before cleanup. Waiting for
178
+ // all of start() here would deadlock cancellation against this catch.
179
+ acquired();
168
180
  await this.cleanup();
169
181
  throw error;
170
182
  }
183
+ finally {
184
+ acquired();
185
+ }
171
186
  }
172
187
  async *events() {
173
188
  const handle = this.requireHandle();
@@ -223,38 +238,82 @@ export class ClaudeCodeAdapter {
223
238
  if (command.command === "answer_question" && typeof command.arguments?.answer === "string") {
224
239
  payload.answer = command.arguments.answer;
225
240
  }
226
- if (command.command === "request_revision" && Array.isArray(command.arguments?.acceptance_criterion_ids)) {
227
- payload.acceptance_criterion_ids = command.arguments?.acceptance_criterion_ids;
241
+ if (command.command === "request_revision") {
242
+ // The Gateway validates these exact string lists against the approved brief.
243
+ for (const key of ["acceptance_criteria", "required_changes"]) {
244
+ if (Array.isArray(command.arguments?.[key]))
245
+ payload[key] = command.arguments[key];
246
+ }
228
247
  }
229
248
  handle.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: JSON.stringify(payload) }] } })}\n`);
230
249
  }
231
- async cancel() {
250
+ cancelPromise;
251
+ cancel() {
232
252
  this.cancelled = true;
233
- await this.cleanup();
253
+ if (this.cancelPromise)
254
+ return this.cancelPromise;
255
+ this.localReadObservations();
256
+ this.revokeTools(this.tools);
257
+ const cleanup = (async () => { await this.startupAcquisition; await this.cleanup(); })();
258
+ // This bounds cleanup confirmation, never admitted model runtime. Unknown
259
+ // acquisition stays unconfirmed; any late owner is still disposed by start.
260
+ this.cancelPromise = new Promise((resolve, reject) => {
261
+ const timer = setTimeout(() => reject(new Error('Provider process group cleanup could not be confirmed')), 6000);
262
+ void cleanup.then(() => { clearTimeout(timer); resolve(); }, () => { clearTimeout(timer); reject(new Error('Provider process group cleanup could not be confirmed')); });
263
+ });
264
+ return this.cancelPromise;
265
+ }
266
+ revokeTools(tools) {
267
+ if (!tools || this.revokedTools === tools)
268
+ return;
269
+ this.revokedTools = tools;
270
+ try {
271
+ tools.revoke();
272
+ }
273
+ catch {
274
+ this.revokeFailed = true;
275
+ }
234
276
  }
235
- async cleanup() {
277
+ cleanupPromise;
278
+ cleanup() {
279
+ if (this.cleanupPromise)
280
+ return this.cleanupPromise;
236
281
  this.localReadObservations();
237
282
  this.toolEndpoint = undefined;
238
283
  const tools = this.tools;
239
284
  const handle = this.handle;
240
285
  this.tools = undefined;
241
286
  this.handle = null;
242
- try {
243
- tools?.revoke();
244
- }
245
- finally {
287
+ const operation = Promise.resolve().then(async () => {
288
+ this.revokeTools(tools);
289
+ let failed = this.revokeFailed;
246
290
  try {
247
291
  handle?.stdin.end();
248
292
  }
249
- finally {
250
- try {
251
- handle?.kill("SIGKILL");
252
- }
253
- finally {
254
- await tools?.close();
255
- }
293
+ catch {
294
+ failed = true;
256
295
  }
257
- }
296
+ try {
297
+ if (handle?.terminateOwned)
298
+ await handle.terminateOwned();
299
+ else
300
+ handle?.kill('SIGKILL');
301
+ }
302
+ catch {
303
+ failed = true;
304
+ }
305
+ try {
306
+ await tools?.close();
307
+ }
308
+ catch {
309
+ failed = true;
310
+ }
311
+ if (failed)
312
+ throw new Error('Provider process group cleanup could not be confirmed');
313
+ });
314
+ if (tools || handle)
315
+ this.cleanupPromise = operation;
316
+ return operation;
258
317
  }
259
318
  /** Claude Code treats a hook it cannot run as no objection, so the hook runs through Node. */
260
319
  requireTrustedHookCommand() {
@@ -1,4 +1,5 @@
1
1
  import { executionError } from '../../safety/execution-failure.js';
2
+ import { UnsupportedSupervisorCommandError } from '../../safety/unsupported-supervisor-command.js';
2
3
  import { CodexExecLaunchHost } from './exec-launch-host.js';
3
4
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
4
5
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
@@ -27,6 +28,8 @@ export class CodexAdapter {
27
28
  parser = new CodexStreamParser();
28
29
  startupAbort = new AbortController();
29
30
  securityPolicy;
31
+ /** Only the retained transport's pending owned-handle acquisition. */
32
+ pendingHandle;
30
33
  constructor(options) {
31
34
  this.options = options;
32
35
  }
@@ -117,6 +120,29 @@ export class CodexAdapter {
117
120
  await this.cancel();
118
121
  throw error;
119
122
  }
123
+ let lateCleanupFailed = false;
124
+ const stopReturnedHandle = async (handle) => {
125
+ let failed = false;
126
+ try {
127
+ handle.stdin.end();
128
+ }
129
+ catch {
130
+ failed = true;
131
+ }
132
+ try {
133
+ if (handle.terminateOwned)
134
+ await handle.terminateOwned();
135
+ else
136
+ handle.kill('SIGKILL');
137
+ }
138
+ catch {
139
+ failed = true;
140
+ }
141
+ if (failed) {
142
+ lateCleanupFailed = true;
143
+ throw new Error('Provider process group cleanup could not be confirmed');
144
+ }
145
+ };
120
146
  try {
121
147
  const prompt = compileCodexPrompt(pkg, this.options.jobId);
122
148
  this.tools = this.options.createTools(pkg, approved);
@@ -128,27 +154,46 @@ export class CodexAdapter {
128
154
  worktreeRoot: this.options.cwd ?? this.options.worktreeRoot, sourceRoot: this.options.worktreeRoot, schemaPath, endpoint, env: this.options.env, scratch: this.options.scratch });
129
155
  if (!deferred)
130
156
  assertAdmission();
131
- const handle = await this.options.startVerifiedSession(invocation, prompt, this.startupAbort.signal, assertAdmission);
157
+ this.assertNotCancelled();
158
+ let settleHandle;
159
+ this.pendingHandle = new Promise((resolve, reject) => { settleHandle = error => { if (error)
160
+ reject(error);
161
+ else
162
+ resolve(); }; });
163
+ void this.pendingHandle.catch(() => { });
132
164
  try {
133
- assertAdmission();
134
- }
135
- catch (error) {
136
- this.tools.revoke();
137
- handle.kill('SIGKILL');
138
- throw error;
165
+ const handle = await this.options.startVerifiedSession(invocation, prompt, this.startupAbort.signal, assertAdmission);
166
+ try {
167
+ assertAdmission();
168
+ }
169
+ catch (error) {
170
+ try {
171
+ this.tools.revoke();
172
+ }
173
+ finally {
174
+ await stopReturnedHandle(handle);
175
+ }
176
+ throw error;
177
+ }
178
+ // Publish either the adopted handle or its exact disposal outcome before
179
+ // releasing cancellation. The outer catch may then await shared cancel.
180
+ if (this.cancelled) {
181
+ await stopReturnedHandle(handle);
182
+ this.assertNotCancelled();
183
+ }
184
+ this.handle = handle;
139
185
  }
140
- // Cancellation may race with a transport returning its newly created handle.
141
- if (this.cancelled) {
142
- handle.kill("SIGKILL");
143
- this.assertNotCancelled();
186
+ finally {
187
+ settleHandle(lateCleanupFailed ? new Error('Provider process group cleanup could not be confirmed') : undefined);
144
188
  }
145
- this.handle = handle;
146
189
  if (deferred)
147
190
  this.receipt = createJobSecurityReceipt(this.securityPolicy, { jobId: this.options.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId, sourceSha: String(source.base_commit_sha), ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
148
191
  }
149
192
  catch {
150
193
  const wasCancelled = this.cancelled;
151
194
  await this.cancel();
195
+ if (lateCleanupFailed)
196
+ throw new Error('Provider process group cleanup could not be confirmed');
152
197
  throw new Error(wasCancelled ? "Codex launch cancelled during startup" : "Codex job setup failed before execution could be confirmed");
153
198
  }
154
199
  }
@@ -185,33 +230,104 @@ export class CodexAdapter {
185
230
  await this.cancel();
186
231
  }
187
232
  }
188
- async cancel() {
189
- this.cancelled = true;
233
+ async freezeForPublication() {
234
+ if (!this.usingOfficialExec || !this.ownedExec)
235
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
236
+ let timer;
190
237
  try {
191
- if (this.usingOfficialExec)
192
- await this.ownedExec?.cancel();
193
- this.options.revokeSecurity?.();
238
+ await Promise.race([Promise.all([this.ownedExec.freezeForPublication(), this.cancel()]), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED')), 10000); })]);
239
+ }
240
+ catch {
241
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
194
242
  }
195
243
  finally {
244
+ if (timer)
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+ cancelPromise;
249
+ cancel() {
250
+ if (this.cancelPromise)
251
+ return this.cancelPromise;
252
+ this.cancelled = true;
253
+ let finish, reject, failed = false;
254
+ this.cancelPromise = new Promise((resolve, rejectPromise) => { finish = resolve; reject = rejectPromise; });
255
+ const timer = setTimeout(() => reject(new Error('Provider process group cleanup could not be confirmed')), 6000);
256
+ // Let the official owner freeze its observations before external revocation.
257
+ // Known owner cleanup and endpoint closure start without waiting for a late
258
+ // retained handle; only confirmation waits for its acquisition/disposal.
259
+ let officialStop;
260
+ if (this.usingOfficialExec) {
196
261
  try {
197
- this.tools?.revoke();
262
+ officialStop = this.ownedExec?.cancel();
198
263
  }
199
- finally {
200
- this.startupAbort.abort();
264
+ catch {
265
+ failed = true;
266
+ }
267
+ }
268
+ try {
269
+ this.options.revokeSecurity?.();
270
+ }
271
+ catch {
272
+ failed = true;
273
+ }
274
+ try {
275
+ this.tools?.revoke();
276
+ }
277
+ catch {
278
+ failed = true;
279
+ }
280
+ this.startupAbort.abort();
281
+ const ownerStop = (async () => {
282
+ if (this.usingOfficialExec) {
201
283
  try {
202
- if (!this.usingOfficialExec)
203
- this.handle?.kill("SIGKILL");
284
+ await officialStop;
204
285
  }
205
- finally {
206
- try {
207
- await this.tools?.close();
208
- }
209
- finally {
210
- await this.options.closeOfficialExec?.();
211
- }
286
+ catch {
287
+ failed = true;
288
+ }
289
+ }
290
+ else {
291
+ try {
292
+ this.handle?.stdin.end();
293
+ }
294
+ catch {
295
+ failed = true;
296
+ }
297
+ try {
298
+ if (this.handle?.terminateOwned)
299
+ await this.handle.terminateOwned();
300
+ else
301
+ this.handle?.kill('SIGKILL');
302
+ }
303
+ catch {
304
+ failed = true;
212
305
  }
213
306
  }
307
+ })();
308
+ const toolsClose = (async () => { try {
309
+ await this.tools?.close();
214
310
  }
311
+ catch {
312
+ failed = true;
313
+ } })();
314
+ void (async () => {
315
+ const outcomes = await Promise.allSettled([ownerStop, toolsClose, this.pendingHandle]);
316
+ if (outcomes.some(outcome => outcome.status === 'rejected'))
317
+ failed = true;
318
+ try {
319
+ await this.options.closeOfficialExec?.();
320
+ }
321
+ catch {
322
+ failed = true;
323
+ }
324
+ clearTimeout(timer);
325
+ if (failed)
326
+ reject(new Error('Provider process group cleanup could not be confirmed'));
327
+ else
328
+ finish();
329
+ })();
330
+ return this.cancelPromise;
215
331
  }
216
332
  assertNotCancelled() {
217
333
  if (this.cancelled)
@@ -222,6 +338,6 @@ export class CodexAdapter {
222
338
  return this.cancel();
223
339
  if (command.command === "request_checkpoint")
224
340
  return;
225
- throw new Error("Codex cannot resume or change an active approved launch");
341
+ throw new UnsupportedSupervisorCommandError();
226
342
  }
227
343
  }
@@ -120,7 +120,7 @@ export class CodexExecLaunchHost {
120
120
  this.handle = h;
121
121
  timing.mark('spawn_returned');
122
122
  if (this.stopped) {
123
- h.kill('SIGKILL');
123
+ await this.cancel();
124
124
  throw fail();
125
125
  }
126
126
  h.once('exit', (code, signal) => {
@@ -246,6 +246,28 @@ export class CodexExecLaunchHost {
246
246
  }
247
247
  return this.readAudit ? structuredClone(this.readAudit) : undefined;
248
248
  }
249
+ /** A kill request is not an exit proof. Publication alone requires the trusted
250
+ * process-group host to observe real exit, closed pipes and group disappearance.
251
+ * This deadline bounds teardown only, never model execution duration. */
252
+ async freezeForPublication() {
253
+ let timer;
254
+ try {
255
+ if (!this.ownsAttempt || !this.handle?.waitForOwnedExit)
256
+ throw fail();
257
+ const handle = this.handle;
258
+ await Promise.race([
259
+ Promise.all([this.cancel(), handle.waitForOwnedExit(10000)]),
260
+ new Promise((_, reject) => { timer = setTimeout(() => reject(fail()), 10000); }),
261
+ ]);
262
+ }
263
+ catch {
264
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
265
+ }
266
+ finally {
267
+ if (timer)
268
+ clearTimeout(timer);
269
+ }
270
+ }
249
271
  cancel() {
250
272
  if (this.closePromise)
251
273
  return this.closePromise;
@@ -273,16 +295,33 @@ export class CodexExecLaunchHost {
273
295
  cleanupError = true;
274
296
  }
275
297
  this.abort.abort();
276
- try {
277
- this.handle?.kill('SIGKILL');
278
- }
279
- catch {
280
- cleanupError = true;
281
- }
282
- Promise.resolve().then(() => this.deps.closeTools()).then(() => { if (cleanupError)
283
- reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
284
- else
285
- finish(); }, () => reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED')));
298
+ Promise.resolve().then(async () => {
299
+ try {
300
+ this.handle?.stdin.end();
301
+ }
302
+ catch {
303
+ cleanupError = true;
304
+ }
305
+ try {
306
+ if (this.handle?.terminateOwned)
307
+ await this.handle.terminateOwned();
308
+ else
309
+ this.handle?.kill('SIGKILL');
310
+ }
311
+ catch {
312
+ cleanupError = true;
313
+ }
314
+ try {
315
+ await this.deps.closeTools();
316
+ }
317
+ catch {
318
+ cleanupError = true;
319
+ }
320
+ if (cleanupError)
321
+ reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
322
+ else
323
+ finish();
324
+ });
286
325
  return this.closePromise;
287
326
  }
288
327
  }
@@ -1,21 +1,12 @@
1
1
  import { createHmac, randomBytes } from "node:crypto";
2
- import { completionEnvelope, completionEvent } from "../completion-report.js";
2
+ import { completionEvent } from "../completion-report.js";
3
3
  function record(value) {
4
4
  return value !== null && typeof value === "object" && !Array.isArray(value)
5
5
  ? value : undefined;
6
6
  }
7
- function finalReport(text) {
8
- const complete = completionEvent(text);
9
- if (complete.eventType === "completed")
10
- return complete;
11
- const value = completionEnvelope(text);
12
- const bounded = (v, size) => typeof v === "string" && v.trim().length > 0 && v.length <= size;
13
- const entries = (v, min) => Array.isArray(v) && v.length >= min && v.length <= 20 && v.every(item => bounded(item, 500));
14
- if (!value || Object.keys(value).some(key => !["status", "report", "evidence", "pending_work"].includes(key))
15
- || value.status !== "blocked" || !bounded(value.report, 2000)
16
- || !entries(value.evidence, 0) || !entries(value.pending_work, 1))
17
- return complete;
18
- return { eventType: "blocked", summary: value.report.trim(), payload: { pending_work: value.pending_work, completion_evidence: value.evidence } };
7
+ /** Valid blocked reports are terminal failures, but still count for uniqueness. */
8
+ function acceptedReport(event) {
9
+ return event.eventType === "completed" || event.eventType === "failed" && event.payload?.reason === "executor_blocked";
19
10
  }
20
11
  /** Codex wire parsing is independent of Claude. Never forward reasoning or raw errors. */
21
12
  export class CodexStreamParser {
@@ -52,8 +43,8 @@ export class CodexStreamParser {
52
43
  }
53
44
  if (event.type === "item.completed" && item.type === "agent_message") {
54
45
  this.messages += 1;
55
- this.report = finalReport(item.text);
56
- if (["completed", "blocked"].includes(this.report.eventType)) {
46
+ this.report = completionEvent(item.text);
47
+ if (acceptedReport(this.report)) {
57
48
  this.candidates += 1;
58
49
  // Bounded diagnostic sample only. Neither raw IDs nor text digests
59
50
  // leave this parser, and sampling never changes report acceptance.
@@ -73,9 +64,9 @@ export class CodexStreamParser {
73
64
  const terminal = this.candidates === 0
74
65
  ? this.reportFailure(this.messages === 0 ? "CODEX_COMPLETION_REPORT_MISSING" : "CODEX_COMPLETION_REPORT_INVALID")
75
66
  : this.candidates > 1 ? this.reportFailure("CODEX_COMPLETION_REPORT_MULTIPLE")
76
- : this.report && ["completed", "blocked"].includes(this.report.eventType) ? this.report
67
+ : this.report && acceptedReport(this.report) ? this.report
77
68
  : this.reportFailure("CODEX_COMPLETION_REPORT_NOT_FINAL");
78
- if (terminal.eventType === "blocked")
69
+ if (terminal.eventType === "failed" && terminal.payload?.reason === "executor_blocked")
79
70
  terminal.payload = { ...terminal.payload, completion_report: {
80
71
  version: "codex-completion-report-v1", code: "CODEX_COMPLETION_REPORT_BLOCKED", agent_message_count: this.messages,
81
72
  valid_report_count: this.candidates, invalid_message_count: this.messages - this.candidates, identity: this.reportIdentity(),
@@ -93,15 +93,18 @@ export function completionEvent(result, costUsd) {
93
93
  if (!value)
94
94
  return failed;
95
95
  if (Object.keys(value).some((key) => !["status", "report", "evidence", "pending_work"].includes(key))
96
- || value.status !== "completed"
96
+ || value.status !== "completed" && value.status !== "blocked"
97
97
  || !boundedText(value.report, MAX_EVENT_SUMMARY_CHARS)
98
- || !Array.isArray(value.pending_work) || value.pending_work.length !== 0
99
- || !Array.isArray(value.evidence) || value.evidence.length < 1 || value.evidence.length > MAX_EVIDENCE_ITEMS
98
+ || !Array.isArray(value.pending_work) || value.pending_work.length > MAX_EVIDENCE_ITEMS
99
+ || (value.status === "completed" ? value.pending_work.length !== 0 : value.pending_work.length < 1)
100
+ || !value.pending_work.every((item) => boundedText(item, MAX_EVIDENCE_CHARS))
101
+ || !Array.isArray(value.evidence) || value.evidence.length < (value.status === "completed" ? 1 : 0) || value.evidence.length > MAX_EVIDENCE_ITEMS
100
102
  || !value.evidence.every((item) => boundedText(item, MAX_EVIDENCE_CHARS)))
101
103
  return failed;
102
104
  return {
103
- eventType: "completed",
105
+ eventType: value.status === "completed" ? "completed" : "failed",
104
106
  summary: value.report.trim(),
105
- payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence },
107
+ payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence,
108
+ ...(value.status === "blocked" ? { reason: "executor_blocked", pending_work: value.pending_work } : {}) },
106
109
  };
107
110
  }
package/dist/src/cli.js CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { recoverLocalFixPublication } from './publication/recovery.js';
3
+ import { loadInstalledLocalFixHost } from './publication/installed-host.js';
4
+ import { FileDaemonStateStore } from './state-store.js';
5
+ import { ProcessLock } from './daemon/process-lock.js';
2
6
  import { runManualCodexJob } from './daemon/manual-codex.js';
3
7
  import { unlinkSync, realpathSync } from "node:fs";
4
8
  import { join } from "node:path";
@@ -228,6 +232,25 @@ export async function main(argv, env, io = {}) {
228
232
  const credentialStore = new FileOAuthCredentialStore(config.stateRoot);
229
233
  const oauthClient = new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn });
230
234
  const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, allowRefresh: config.mode !== 'manual-codex', clock: () => Date.now() });
235
+ if (config.mode === 'publication-recover') {
236
+ const lock = new ProcessLock(join(config.stateRoot, 'daemon.lock'));
237
+ await lock.acquire();
238
+ try {
239
+ const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, runnerId: local.runnerId, tokenProvider, fetchFn });
240
+ const host = await loadInstalledLocalFixHost({ stateRoot: config.stateRoot, runnerId: local.runnerId, gateway });
241
+ if (!host)
242
+ throw new Error('LOCAL_FIX_INSTALLATION_REQUIRED');
243
+ const state = new FileDaemonStateStore(config.stateRoot), stored = await state.load();
244
+ if (!stored)
245
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_STATE_REQUIRED');
246
+ await recoverLocalFixPublication({ jobId: config.manualJobId, stored: stored, host, gateway, state });
247
+ stdout(JSON.stringify({ status: 'completed', jobId: config.manualJobId, modelStarted: false }));
248
+ return 0;
249
+ }
250
+ finally {
251
+ await lock.release();
252
+ }
253
+ }
231
254
  if (config.mode === "doctor") {
232
255
  const adapters = doctorAdapters;
233
256
  const localChecks = Promise.allSettled([