mtech-ai-local-runner 0.1.31 → 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.
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
  }
@@ -200,33 +245,89 @@ export class CodexAdapter {
200
245
  clearTimeout(timer);
201
246
  }
202
247
  }
203
- async cancel() {
248
+ cancelPromise;
249
+ cancel() {
250
+ if (this.cancelPromise)
251
+ return this.cancelPromise;
204
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) {
261
+ try {
262
+ officialStop = this.ownedExec?.cancel();
263
+ }
264
+ catch {
265
+ failed = true;
266
+ }
267
+ }
205
268
  try {
206
- if (this.usingOfficialExec)
207
- await this.ownedExec?.cancel();
208
269
  this.options.revokeSecurity?.();
209
270
  }
210
- finally {
211
- try {
212
- this.tools?.revoke();
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) {
283
+ try {
284
+ await officialStop;
285
+ }
286
+ catch {
287
+ failed = true;
288
+ }
213
289
  }
214
- finally {
215
- this.startupAbort.abort();
290
+ else {
216
291
  try {
217
- if (!this.usingOfficialExec)
218
- this.handle?.kill("SIGKILL");
292
+ this.handle?.stdin.end();
219
293
  }
220
- finally {
221
- try {
222
- await this.tools?.close();
223
- }
224
- finally {
225
- await this.options.closeOfficialExec?.();
226
- }
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;
227
305
  }
228
306
  }
307
+ })();
308
+ const toolsClose = (async () => { try {
309
+ await this.tools?.close();
229
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;
230
331
  }
231
332
  assertNotCancelled() {
232
333
  if (this.cancelled)
@@ -237,6 +338,6 @@ export class CodexAdapter {
237
338
  return this.cancel();
238
339
  if (command.command === "request_checkpoint")
239
340
  return;
240
- throw new Error("Codex cannot resume or change an active approved launch");
341
+ throw new UnsupportedSupervisorCommandError();
241
342
  }
242
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) => {
@@ -295,16 +295,33 @@ export class CodexExecLaunchHost {
295
295
  cleanupError = true;
296
296
  }
297
297
  this.abort.abort();
298
- try {
299
- this.handle?.kill('SIGKILL');
300
- }
301
- catch {
302
- cleanupError = true;
303
- }
304
- Promise.resolve().then(() => this.deps.closeTools()).then(() => { if (cleanupError)
305
- reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
306
- else
307
- 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
+ });
308
325
  return this.closePromise;
309
326
  }
310
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
  }
@@ -1,15 +1,73 @@
1
1
  export class Daemon {
2
2
  deps;
3
+ stopping = new AbortController();
3
4
  constructor(deps) {
4
5
  this.deps = deps;
5
6
  }
7
+ stop() { this.deps.poller.stop?.(); this.stopping.abort(); }
8
+ async suspend(state, duringExecution = false) {
9
+ const phase = state && typeof state === 'object' && 'phase' in state ? state.phase : undefined;
10
+ const reason = duringExecution ? 'execution_outcome_unknown' : phase === 'binding' || phase === 'binding_uncertain' ? 'binding_outcome_unknown' : 'runner_restarted';
11
+ try {
12
+ await this.deps.state.suspend?.(reason);
13
+ }
14
+ catch { /* Preserve original state and remain alive even if the marker cannot be written. */ }
15
+ try {
16
+ this.deps.reportRecoveryRequired?.(reason);
17
+ }
18
+ catch { /* Logging cannot reopen claims or exit suspended recovery. */ }
19
+ if (this.stopping.signal.aborted)
20
+ return;
21
+ if (this.deps.wait) {
22
+ await this.deps.wait(this.stopping.signal);
23
+ return;
24
+ }
25
+ // A pending promise does not keep Node alive. This timer deliberately stays
26
+ // referenced, holds the process lock and wakes only for explicit close.
27
+ await new Promise(resolve => {
28
+ const timer = setInterval(() => { }, 60_000);
29
+ this.stopping.signal.addEventListener('abort', () => { clearInterval(timer); resolve(); }, { once: true });
30
+ });
31
+ }
6
32
  async run() {
7
33
  await this.deps.lock.acquire();
8
34
  try {
9
- const state = await this.deps.state.load();
10
- if (state)
11
- await this.deps.reconcile(state);
12
- await this.deps.poller.run();
35
+ let state;
36
+ try {
37
+ state = await this.deps.state.load();
38
+ if (state !== null && state !== undefined) {
39
+ await this.deps.reconcile(state);
40
+ state = await this.deps.state.load();
41
+ }
42
+ }
43
+ catch {
44
+ await this.suspend(state);
45
+ return;
46
+ }
47
+ if (state !== null && state !== undefined) {
48
+ await this.suspend(state);
49
+ return;
50
+ }
51
+ if (this.stopping.signal.aborted)
52
+ return;
53
+ try {
54
+ await this.deps.poller.run();
55
+ }
56
+ catch {
57
+ try {
58
+ this.deps.poller.stop?.();
59
+ }
60
+ catch { /* Still inspect retained state and suspend below. */ }
61
+ }
62
+ try {
63
+ state = await this.deps.state.load();
64
+ }
65
+ catch {
66
+ await this.suspend(undefined, true);
67
+ return;
68
+ }
69
+ if (state !== null && state !== undefined)
70
+ await this.suspend(state, true);
13
71
  }
14
72
  finally {
15
73
  await this.deps.lock.release();