mtech-ai-local-runner 0.1.110 → 0.1.112

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,33 @@
1
+ ## 0.1.112: a failed Claude job says why it stopped
2
+
3
+ When a Claude job fails, the runner now tells the Gateway why, as one code: `auth` (the Claude account's sign-in was
4
+ refused or expired), `usage_limit`, `model_unavailable`, `rate_limited`, `provider_error`, `budget_exceeded`,
5
+ `cli_error` or `unknown` (Gateway Work Item 2ee28765). The Sentry "AI fix failed" card then says, for example, "Claude
6
+ stopped before the first answer: usage limit reached on the runner's Claude account." The runner reads only the Claude
7
+ CLI's error kind and HTTP status; it never sends the CLI's own text. If the card says `auth`, sign Claude in again on
8
+ this Mac (or renew the runner's token with `setup --claude-token`) and retry.
9
+
10
+ 0.1.112 also carries the D4 work merged after 0.1.111 (PRs #943, #949, #957). Reviewed-host registration stays empty,
11
+ so D4 execution stays closed.
12
+
13
+ ## 0.1.111: Work Rooms in the menu bar app
14
+
15
+ The menu bar window now has a **Rooms** tab (Gateway Work Item b35d65cb); the menu's **Show Rooms (n unread)…** opens
16
+ it. Pick a Room, then a thread: it shows the newest 20 messages and every unread one, with unread ones marked.
17
+ **Mark read (n)** marks them read. Write a reply and press **Review reply**; it is posted only after you confirm the
18
+ members it goes to ("Post to 3 members: …?"). If the members changed meanwhile, the window shows the new list and asks
19
+ again. A Room notice opens the Rooms tab at that Room while the runner is connected.
20
+
21
+ - For the Rooms tab the runner calls only `work_room_read`, `work_room_receipt_report` and `work_room_write` operation
22
+ `post`, one at a time, with your own runner sign-in. Room creation, members, new threads and outcome shares stay in
23
+ your AI session; archived Rooms are read-only.
24
+ - Room names, titles and messages stay in memory: neither the runner nor the app writes them to disk or a log.
25
+ Notifications stay generic.
26
+ - A reply is at most 8,000 bytes, the Gateway's own limit.
27
+
28
+ 0.1.111 also carries the D4 bounded CLI review wiring merged after 0.1.110 (PR #938). Nothing supplies a D4 execution
29
+ host yet, so D4 execution stays closed.
30
+
1
31
  ## 0.1.110: Claude returns its report through `--json-schema`
2
32
 
3
33
  Claude jobs now pass `--json-schema`, and the model returns its completion report by calling Claude Code's
@@ -1,4 +1,4 @@
1
- import { assertD4PackageLimitSupport, assertCurrentD4LimitSupport } from '../../safety/d4-execution-limits.js';
1
+ import { assertD4PackageLimitSupport, assertCurrentD4LimitSupport, D4ObservedLimitWatchdog, D4CliUsageTracker } from '../../safety/d4-execution-limits.js';
2
2
  import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from "../../safety/claude-runner-token.js";
3
3
  import { candidateReviewCompletion } from '../../safety/candidate-review-report.js';
4
4
  import path from "node:path";
@@ -7,6 +7,7 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCa
7
7
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
8
8
  import { isTerminalEventType } from "../../protocol.js";
9
9
  import { parseClaudeLine } from "./stream-parser.js";
10
+ import { runtimeFailure } from "../runtime-failure.js";
10
11
  import { p1ModelEvent } from '../../verification/p1-execution-host.js';
11
12
  import { compilePrompt } from "./prompt-compiler.js";
12
13
  import { CLAUDE_COMPLETION_SCHEMA } from "../completion-report.js";
@@ -104,6 +105,9 @@ export class ClaudeCodeAdapter {
104
105
  started = false;
105
106
  p1 = false;
106
107
  candidateReview = false;
108
+ d4Limits;
109
+ d4Guard;
110
+ d4Usage = new D4CliUsageTracker();
107
111
  startupAcquisition;
108
112
  revokedTools;
109
113
  revokeFailed = false;
@@ -135,7 +139,9 @@ export class ClaudeCodeAdapter {
135
139
  let acquired;
136
140
  this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
137
141
  try {
138
- assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
142
+ this.d4Limits = assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
143
+ if (this.d4Limits && !this.options.d4ExecutionHost)
144
+ throw Error('D4_REVIEW_HOST_REQUIRED');
139
145
  const generation = ctx.generation;
140
146
  if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
141
147
  throw new Error("JOB_SECURITY_REJECTED");
@@ -160,8 +166,26 @@ export class ClaudeCodeAdapter {
160
166
  assertAdmission();
161
167
  const toolInventory = claudeJobToolInventory(this.options.securityPolicy);
162
168
  this.candidateReview = toolInventory === 'local-tools-d4-review-v1';
163
- if (this.candidateReview)
169
+ if (this.candidateReview && !this.d4Limits)
164
170
  assertCurrentD4LimitSupport();
171
+ if (this.d4Limits && !this.candidateReview)
172
+ throw Error('D4_REVIEW_INVENTORY_REJECTED');
173
+ const assertD4Host = async () => {
174
+ if (!this.d4Limits)
175
+ return;
176
+ // Recheck both immutable packages around asynchronous preparation; equality alone is not host authority.
177
+ assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
178
+ const descriptor = ctx.approvedTaskPackage.candidate_review_execution;
179
+ const profile = descriptor.profile, host = this.options.d4ExecutionHost;
180
+ if (ctx.generation !== 1 || resolvedModelId !== profile.model_id || host.runnerReleaseSha256 !== profile.runner_release_sha256 || host.hostPolicySha256 !== profile.host_policy_sha256
181
+ || this.options.securityPolicy?.pin.toolsSha256 !== profile.tools_sha256 || this.options.securityPolicy?.pin.requirementsSha256 !== profile.cli_security_fingerprint
182
+ || this.options.securityPolicy?.pin.securityMode !== CLAUDE_RELEASE_MODE)
183
+ throw Error('D4_REVIEW_HOST_REQUIRED');
184
+ await host.assertCurrent(structuredClone(descriptor), { jobId: this.options.jobId ?? ctx.bindingId, bindingId: ctx.bindingId, generation: ctx.generation,
185
+ launchFingerprint: ctx.securityBinding?.launchFingerprint ?? '', contractFingerprint: ctx.securityBinding?.contractFingerprint ?? '', taskPackageFingerprint: ctx.securityBinding?.taskPackageFingerprint ?? '' });
186
+ };
187
+ if (this.d4Limits)
188
+ await assertD4Host();
165
189
  if (!ctx.securityBinding)
166
190
  throw new Error('SECURITY_REQUIREMENTS_REJECTED');
167
191
  this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
@@ -171,7 +195,8 @@ export class ClaudeCodeAdapter {
171
195
  if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
172
196
  throw new Error("trusted cwd must live outside the assigned worktree");
173
197
  const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
174
- if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
198
+ const recordOnly = !!this.d4Limits && !('max_total_tokens' in this.d4Limits);
199
+ if (!unbounded && !recordOnly && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
175
200
  throw new Error("refusing to start: no positive remaining contract budget was supplied");
176
201
  }
177
202
  if (!this.options.worktreeRoot) {
@@ -194,9 +219,9 @@ export class ClaudeCodeAdapter {
194
219
  if (this.cancelled)
195
220
  throw new Error("adapter startup cancelled");
196
221
  const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
197
- const maxBudgetUsd = unbounded
222
+ const maxBudgetUsd = unbounded || recordOnly
198
223
  ? null
199
- : Math.min(this.options.maxBudgetUsd, contractBudget(ctx.taskPackage));
224
+ : Math.min(this.options.maxBudgetUsd, this.d4Limits && 'max_estimated_cost_microusd' in this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : contractBudget(ctx.taskPackage));
200
225
  const hookPolicy = buildHookPolicy({
201
226
  taskPackage: ctx.taskPackage,
202
227
  worktreeRoot: this.options.worktreeRoot,
@@ -227,12 +252,43 @@ export class ClaudeCodeAdapter {
227
252
  if (this.cancelled)
228
253
  throw new Error('adapter startup cancelled');
229
254
  assertAdmission();
255
+ if (this.d4Limits)
256
+ await assertD4Host();
257
+ if (this.d4Limits)
258
+ await this.options.assertCurrentInputs?.();
259
+ if (this.cancelled)
260
+ throw new Error('adapter startup cancelled');
261
+ if (this.d4Limits) {
262
+ assertD4PackageLimitSupport(ctx.taskPackage, ctx.approvedTaskPackage, ctx.resumeSessionId);
263
+ assertAdmission();
264
+ }
230
265
  const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
231
266
  cwd,
232
267
  env,
233
268
  ...(claudeToken === null ? {} : { secretFd3: claudeToken }),
234
269
  });
235
270
  this.handle = handle;
271
+ if (this.d4Limits) {
272
+ this.d4Guard = new D4ObservedLimitWatchdog(this.d4Limits, { revoke: () => {
273
+ this.localReadObservations();
274
+ this.revokeSecurity();
275
+ this.revokeTools(this.tools);
276
+ if (this.revokeFailed)
277
+ throw Error('D4_STOP_UNCONFIRMED');
278
+ } });
279
+ try {
280
+ this.d4Guard.attach(handle);
281
+ }
282
+ catch (error) {
283
+ // We own this spawn but cannot certify its process group. Stop the child best-effort;
284
+ // cleanup remains unconfirmed and must never turn into a passing review.
285
+ try {
286
+ handle.kill('SIGKILL');
287
+ }
288
+ catch { /* Preserve the failed ownership proof. */ }
289
+ throw error;
290
+ }
291
+ }
236
292
  if (this.cancelled)
237
293
  throw new Error("adapter startup cancelled");
238
294
  handle.stdin.write(claudeUserInput(prompt));
@@ -256,8 +312,12 @@ export class ClaudeCodeAdapter {
256
312
  exitInfo.signal = signal;
257
313
  });
258
314
  let sawTerminal = false;
315
+ // The CLI names an API error's kind on the assistant message before the result (stream-parser.ts); a later
316
+ // assistant message without one replaces it.
317
+ let assistantError;
259
318
  try {
260
- for await (const line of handle.stdout) {
319
+ const lines = this.d4Guard ? this.d4Lines(handle, this.d4Guard) : handle.stdout;
320
+ for await (const line of lines) {
261
321
  let message;
262
322
  try {
263
323
  const value = JSON.parse(line);
@@ -271,7 +331,32 @@ export class ClaudeCodeAdapter {
271
331
  // A nested agent message is not evidence that the executor has finished.
272
332
  if (message.parent_tool_use_id != null)
273
333
  continue;
274
- let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1 });
334
+ if (this.d4Guard && message.type === 'assistant') {
335
+ const observed = this.d4Usage.assistant(message);
336
+ if (observed) {
337
+ await this.d4Guard.observe(observed);
338
+ if (this.d4Guard.diagnostic().status !== 'active') {
339
+ yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
340
+ return;
341
+ }
342
+ }
343
+ }
344
+ let d4Usage;
345
+ if (this.d4Guard && message.type === 'result') {
346
+ try {
347
+ d4Usage = this.d4Usage.final(message);
348
+ await this.d4Guard.complete(d4Usage);
349
+ }
350
+ catch (error) {
351
+ if (this.d4Guard.diagnostic().status === 'active')
352
+ await this.d4Guard.complete().catch(() => { });
353
+ yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
354
+ return;
355
+ }
356
+ }
357
+ if (message.type === "assistant")
358
+ assistantError = typeof message.error === "string" ? message.error : undefined;
359
+ let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1, assistantError });
275
360
  if (!parsed)
276
361
  continue;
277
362
  if (parsed.eventType === "completed") {
@@ -279,6 +364,8 @@ export class ClaudeCodeAdapter {
279
364
  parsed = structuredCompletionEvent(message, parsed.payload?.cost_usd);
280
365
  if (this.candidateReview)
281
366
  parsed = candidateReviewCompletion(parsed);
367
+ if (d4Usage)
368
+ parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: this.d4Limits && 'max_total_tokens' in this.d4Limits ? 'observed_stop_possible_overshoot' : 'record_only' } } };
282
369
  if (this.p1)
283
370
  parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
284
371
  }
@@ -291,14 +378,24 @@ export class ClaudeCodeAdapter {
291
378
  }
292
379
  }
293
380
  if (!sawTerminal) {
294
- yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})` };
381
+ yield { eventType: "failed", summary: `claude process exited unexpectedly (code ${exitInfo.code ?? "unknown"})`,
382
+ payload: { runtime_failure: runtimeFailure("cli_error") } };
295
383
  }
296
384
  }
385
+ catch (error) {
386
+ if (!this.d4Guard)
387
+ throw error;
388
+ if (this.d4Guard.diagnostic().status === 'active')
389
+ await this.d4Guard.complete().catch(() => { });
390
+ yield { eventType: 'failed', summary: this.d4Guard.diagnostic().reason_code ?? 'D4_USAGE_UNAVAILABLE' };
391
+ }
297
392
  finally {
298
393
  await this.cleanup();
299
394
  }
300
395
  }
301
396
  async command(command) {
397
+ if (this.candidateReview)
398
+ throw Error('D4_SUPERVISOR_CONTINUATION_REJECTED');
302
399
  if (!ALLOWED_SUPERVISOR_COMMANDS.has(command.command)) {
303
400
  throw new Error(`unsupported supervisor command: ${command.command} is not allowed for this adapter`);
304
401
  }
@@ -394,7 +491,9 @@ export class ClaudeCodeAdapter {
394
491
  failed = true;
395
492
  }
396
493
  try {
397
- if (handle?.terminateOwned)
494
+ if (this.d4Guard)
495
+ await this.d4Guard.cancel();
496
+ else if (handle?.terminateOwned)
398
497
  await handle.terminateOwned();
399
498
  else
400
499
  handle?.kill('SIGKILL');
@@ -415,6 +514,18 @@ export class ClaudeCodeAdapter {
415
514
  this.cleanupPromise = operation;
416
515
  return operation;
417
516
  }
517
+ /** Race a silent CLI against the owned watchdog; stop evidence never depends on another stdout line. */
518
+ async *d4Lines(handle, guard) {
519
+ const iterator = handle.stdout[Symbol.asyncIterator]();
520
+ for (;;) {
521
+ const next = await Promise.race([iterator.next(), guard.stopped().then(() => null)]);
522
+ if (next === null)
523
+ throw Error(guard.diagnostic().reason_code ?? 'D4_STOP_UNCONFIRMED');
524
+ if (next.done)
525
+ return;
526
+ yield next.value;
527
+ }
528
+ }
418
529
  /** Claude Code treats a hook it cannot run as no objection, so the hook runs through Node. */
419
530
  requireTrustedHookCommand() {
420
531
  return formatHookCommand({
@@ -73,6 +73,7 @@ export function createClaudeCodeAdapterOptions(input) {
73
73
  throw new Error('Claude account configuration path must be absolute');
74
74
  const tokenStore = new ClaudeRunnerTokenStore({ stateRoot: state });
75
75
  return {
76
+ d4ExecutionHost: input.d4ExecutionHost,
76
77
  processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, claudeToken: () => tokenStore.load(), keychainStub: () => ensureClaudeKeychainStub(state), get securityPolicy() { return securityPolicy; },
77
78
  ...(input.securityHost ? { prepareSecurity: async (ctx) => {
78
79
  try {
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
2
  import { MAX_EVENT_SUMMARY_CHARS } from "../../protocol.js";
3
3
  import { p1ModelEvent } from '../../verification/p1-execution-host.js';
4
+ import { runtimeFailure } from "../runtime-failure.js";
4
5
  const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
5
6
  function bounded(summary) {
6
7
  return summary.length > MAX_EVENT_SUMMARY_CHARS ? summary.slice(0, MAX_EVENT_SUMMARY_CHARS) : summary;
@@ -69,7 +70,66 @@ function parseSystemMessage(message) {
69
70
  }
70
71
  return null;
71
72
  }
72
- function parseResultMessage(message) {
73
+ /*
74
+ * How Claude Code 2.1.308 (the listed release) says why a turn failed. An API error comes as a top-level assistant
75
+ * message with `error`, a coarse kind such as "authentication_failed", then a result with subtype "success",
76
+ * is_error true, `api_error_status` and, for some causes, `api_error`, a finer kind such as "usage_limit_reached".
77
+ * The CLI's own stops end with an error_* subtype. Only these identifiers and the status are read; the result text and
78
+ * `errors` hold provider wording and are never copied. Kinds this table does not name fall through to the status.
79
+ */
80
+ const AUTH_KINDS = new Set(["authentication_failed", "oauth_org_not_allowed", "account_on_hold", "verification_required",
81
+ "cloud_credential_error"]);
82
+ const AUTH_API_ERRORS = new Set(["invalid_credential_header", "org_disabled_credential", "api_key_auth_disabled",
83
+ "provider_credentials", "gateway_signin_required", "gateway_session_expired"]);
84
+ const USAGE_API_ERRORS = new Set(["usage_limit_reached", "model_requires_usage_credits", "long_context_credits_required"]);
85
+ const KIND_CODES = new Map([["billing_error", "usage_limit"], ["rate_limit", "rate_limited"],
86
+ ["model_not_found", "model_unavailable"], ["overloaded", "provider_error"], ["server_error", "provider_error"],
87
+ ["max_output_tokens", "cli_error"]]);
88
+ const FAILURE_SUMMARIES = {
89
+ auth: "execution failed: Claude account sign-in refused",
90
+ usage_limit: "execution failed: Claude usage limit reached",
91
+ model_unavailable: "execution failed: model unavailable to the Claude account",
92
+ rate_limited: "execution failed: rate limited",
93
+ provider_error: "execution failed: provider API error",
94
+ budget_exceeded: "execution failed: budget exceeded",
95
+ cli_error: "execution failed: Claude CLI error",
96
+ unknown: "execution failed",
97
+ };
98
+ /** `assistantError` is the `error` of the last top-level assistant message before this result, if it had one. */
99
+ export function classifyClaudeFailure(message, assistantError) {
100
+ const subtype = String(message.subtype ?? "");
101
+ // The CLI ended the turn itself; no API error can be the cause.
102
+ if (subtype === "error_max_budget_usd" || subtype === "error_max_budget_exceeded")
103
+ return "budget_exceeded";
104
+ if (subtype === "error_max_turns")
105
+ return "cli_error";
106
+ const apiError = typeof message.api_error === "string" ? message.api_error : "";
107
+ if (AUTH_API_ERRORS.has(apiError))
108
+ return "auth";
109
+ if (USAGE_API_ERRORS.has(apiError))
110
+ return "usage_limit";
111
+ const kind = assistantError ?? "";
112
+ if (AUTH_KINDS.has(kind))
113
+ return "auth";
114
+ const mapped = KIND_CODES.get(kind);
115
+ if (mapped)
116
+ return mapped;
117
+ const status = message.api_error_status;
118
+ if (status === 401 || status === 403)
119
+ return "auth";
120
+ if (status === 402)
121
+ return "usage_limit";
122
+ if (status === 404)
123
+ return "model_unavailable";
124
+ if (status === 429)
125
+ return "rate_limited";
126
+ if (typeof status === "number" && Number.isInteger(status) && status >= 500 && status <= 599)
127
+ return "provider_error";
128
+ if (kind === "invalid_request" || subtype === "error_during_execution" || message.terminal_reason === "turn_setup_failed")
129
+ return "cli_error";
130
+ return "unknown";
131
+ }
132
+ function parseResultMessage(message, assistantError) {
73
133
  const isError = message.is_error === true;
74
134
  const costUsd = typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined;
75
135
  if (!isError) {
@@ -90,8 +150,9 @@ function parseResultMessage(message) {
90
150
  return { eventType: "failed", summary: "execution failed: structured output missing",
91
151
  payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), completion_rejection: { version: "completion-rejection-v1", code } } };
92
152
  }
93
- const summary = subtype === "error_max_budget_exceeded" ? "execution failed: budget exceeded" : "execution failed";
94
- return { eventType: "failed", summary, payload: costUsd !== undefined ? { cost_usd: costUsd } : undefined };
153
+ const code = classifyClaudeFailure(message, assistantError);
154
+ return { eventType: "failed", summary: FAILURE_SUMMARIES[code],
155
+ payload: { ...(costUsd !== undefined ? { cost_usd: costUsd } : {}), runtime_failure: runtimeFailure(code, message.api_error_status) } };
95
156
  }
96
157
  export function parseClaudeLine(line, worktreeRoot, options = {}) {
97
158
  let message;
@@ -110,7 +171,7 @@ export function parseClaudeLine(line, worktreeRoot, options = {}) {
110
171
  case "user":
111
172
  return parseUserMessage(message.message ?? {});
112
173
  case "result":
113
- return parseResultMessage(message);
174
+ return parseResultMessage(message, options.assistantError);
114
175
  default:
115
176
  return null;
116
177
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Why a provider CLI stopped, as one bounded code (no provider text). The Claude parser sets it on a failed terminal
3
+ * event; the daemon copies it into the job completion so the Gateway can tell an account problem from a code problem.
4
+ *
5
+ * - auth: the CLI's account was signed out, expired or refused.
6
+ * - usage_limit: the account's usage limit or credit ran out.
7
+ * - model_unavailable: the account cannot use the requested model.
8
+ * - rate_limited: the provider throttled the account for a short time.
9
+ * - provider_error: the provider's API failed or was overloaded.
10
+ * - budget_exceeded: the job's own cost budget ran out.
11
+ * - cli_error: the CLI stopped on its own error, not the account's or the provider's.
12
+ * - unknown: an error the CLI reported without a recognisable kind.
13
+ */
14
+ export const RUNTIME_FAILURE_CODES = ["auth", "usage_limit", "model_unavailable", "rate_limited", "provider_error",
15
+ "budget_exceeded", "cli_error", "unknown"];
16
+ export function runtimeFailure(code, httpStatus) {
17
+ return { version: "runtime-failure-v1", code, ...(isHttpErrorStatus(httpStatus) ? { http_status: httpStatus } : {}) };
18
+ }
19
+ /** The exact v1 shape or undefined; copies nothing else. */
20
+ export function runtimeFailureDiagnostic(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value))
22
+ return undefined;
23
+ const d = value;
24
+ if (d.version !== "runtime-failure-v1" || !RUNTIME_FAILURE_CODES.includes(d.code))
25
+ return undefined;
26
+ if (Object.keys(d).some(key => !["version", "code", "http_status"].includes(key)))
27
+ return undefined;
28
+ if (d.http_status !== undefined && !isHttpErrorStatus(d.http_status))
29
+ return undefined;
30
+ return runtimeFailure(d.code, d.http_status);
31
+ }
32
+ function isHttpErrorStatus(value) {
33
+ return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599;
34
+ }
@@ -1,6 +1,8 @@
1
1
  import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt } from '../verification/continuation-runner-contract.js';
2
2
  import { completionRejectionDiagnostic } from '../adapters/completion-report.js';
3
+ import { runtimeFailureDiagnostic } from '../adapters/runtime-failure.js';
3
4
  import { assertCandidateReviewPackages, candidateReviewReport } from '../safety/candidate-review-report.js';
5
+ import { isD4ExecutionSchema } from '../safety/d4-execution-limits.js';
4
6
  import { publicationError } from '../publication/failure.js';
5
7
  import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
6
8
  import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
@@ -178,6 +180,9 @@ export class ExecutionCoordinator {
178
180
  assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
179
181
  if (executionAttempt && binding.bindingReplayed !== false)
180
182
  throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
183
+ if (isD4ExecutionSchema(pkg.candidate_review_execution?.schema_version)
184
+ && (job.generation !== 1 || binding.bindingReplayed !== false))
185
+ throw Error('D4_BINDING_RECONCILIATION_REQUIRED');
181
186
  binding = immutable(structuredClone(binding));
182
187
  assertCandidateReviewPackages(pkg, binding.taskPackage);
183
188
  if (isP1)
@@ -445,8 +450,10 @@ export class ExecutionCoordinator {
445
450
  if (isP1 && !hostProof)
446
451
  event = p1ModelEvent(event);
447
452
  const previousStage = executionStage;
453
+ const d4StopAck = isD4ExecutionSchema(job.taskPackage.candidate_review_execution?.schema_version)
454
+ && ['failed', 'cancelled', 'blocked'].includes(event.eventType);
448
455
  let payload = event.payload;
449
- if (isTerminalEventType(event.eventType)) {
456
+ if (isTerminalEventType(event.eventType) && !d4StopAck) {
450
457
  await fenced(observeCleanup());
451
458
  executionStage = 'terminal_evidence';
452
459
  requireControlFence();
@@ -456,6 +463,8 @@ export class ExecutionCoordinator {
456
463
  if (isP1 && event.eventType === 'completed' && p1Result)
457
464
  payload = { ...payload, candidate_commit_sha: p1Result.candidate_commit_sha, snapshot_fingerprint: p1Result.snapshot_fingerprint, changed_paths: p1Result.changed_paths, verification_receipt_ids: [...p1ReceiptIds] };
458
465
  }
466
+ if (d4StopAck)
467
+ await fenced(observeCleanup());
459
468
  // A vendor report cannot supply host observations, even when its shape looks valid.
460
469
  if (payload) {
461
470
  payload = { ...payload };
@@ -463,10 +472,10 @@ export class ExecutionCoordinator {
463
472
  delete payload.local_fix_publication;
464
473
  }
465
474
  // Memory use is bound to this binding's bundle receipt by the runner, never taken from the model (WI 35a84e52).
466
- payload = withMemoryUsage(payload, binding.taskPackage, isTerminalEventType(event.eventType));
475
+ payload = d4StopAck ? undefined : withMemoryUsage(payload, binding.taskPackage, isTerminalEventType(event.eventType));
467
476
  if (event.eventType === "completed" && localFixPublication)
468
477
  payload = { ...payload, local_fix_publication: structuredClone(localFixPublication) };
469
- if (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict') {
478
+ if (!d4StopAck && (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict')) {
470
479
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
471
480
  if (reads)
472
481
  payload = { ...payload, local_read_observations: reads };
@@ -825,6 +834,8 @@ export class ExecutionCoordinator {
825
834
  const reportDiagnostic = diagnostic && (status === "blocked"
826
835
  ? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
827
836
  const rejectedCompletion = completionRejectionDiagnostic(terminal.payload?.completion_rejection);
837
+ // Why the provider CLI stopped, as a bounded code; only on a failed job.
838
+ const runtimeFailure = status === 'failed' ? runtimeFailureDiagnostic(terminal.payload?.runtime_failure) : undefined;
828
839
  const rejectedCounts = reportDiagnostic?.rejection_counts;
829
840
  if (rejectedCompletion || rejectedCounts) {
830
841
  try {
@@ -834,7 +845,7 @@ export class ExecutionCoordinator {
834
845
  }
835
846
  const securityReceipt = adapter.securityReceipt?.();
836
847
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
837
- const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
848
+ const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(runtimeFailure ? { runtime_failure: runtimeFailure } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
838
849
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
839
850
  phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
840
851
  completionRequest: structuredClone(completionRequest) });
@@ -1,3 +1,5 @@
1
+ import { composeReviewedD4Host } from '../safety/d4-host-composition.js';
2
+ import { protocolHash } from '../publication/candidate.js';
1
3
  import { createClaudeProductionHost } from '../safety/claude-production-host.js';
2
4
  import { adapterStatus } from '../adapters/status.js';
3
5
  import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
@@ -15,6 +17,7 @@ import { CodexPlugin } from "../adapters/codex/plugin.js";
15
17
  import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
16
18
  import { AdapterRegistry } from "../adapters/registry.js";
17
19
  import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
20
+ import { D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
18
21
  import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
19
22
  import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
20
23
  import { WorkspaceManager } from "../workspace/workspace-manager.js";
@@ -75,6 +78,27 @@ export async function buildDaemon(input) {
75
78
  pendingCodexClaim.job.lease_expires_at = expiresAt;
76
79
  };
77
80
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
81
+ // Only this daemon's currently executing claim may revalidate a D4 pre-spawn request.
82
+ let activeD4Job, activeD4Binding;
83
+ let d4Host = input.d4ExecutionHost;
84
+ if (!d4Host) {
85
+ const composition = await composeReviewedD4Host({ binaryIdentity: () => claude.binaryIdentity(), revalidateJob: async (descriptor, context) => {
86
+ const job = activeD4Job;
87
+ if (!job || context.jobId !== job.id || context.bindingId !== activeD4Binding || context.generation !== 1 || job.generation !== 1
88
+ || context.launchFingerprint !== job.launchFingerprint || context.contractFingerprint !== job.gatewayContractFingerprint
89
+ || context.taskPackageFingerprint !== job.gatewayTaskPackageFingerprint
90
+ || protocolHash(job.taskPackage) !== job.gatewayTaskPackageFingerprint
91
+ || protocolHash(job.taskPackage.candidate_review_execution) !== protocolHash(descriptor))
92
+ throw Error('D4_JOB_CONTEXT_REJECTED');
93
+ const { job: current } = await gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken });
94
+ if (activeD4Job !== job || activeD4Binding !== context.bindingId || current.id !== job.id || current.generation !== 1
95
+ || !['claimed', 'shadowing', 'running'].includes(current.status) || !Number.isSafeInteger(current.lease_expires_at)
96
+ || current.lease_expires_at <= Math.floor(Date.now() / 1000))
97
+ throw Error('D4_JOB_CONTEXT_REJECTED');
98
+ } });
99
+ d4Host = composition.host;
100
+ process.stderr.write(`${JSON.stringify({ event: 'd4_host', status: composition.status })}\n`);
101
+ }
78
102
  // The menu bar app (Work Item b9218937): counts and clickable notices; osascript stays the fallback.
79
103
  let roomActivityEnabled = false;
80
104
  const menubar = buildMenubar({ stateRoot: input.stateRoot, home: input.env.HOME ?? "", gatewayMcpUrl: input.gatewayMcpUrl,
@@ -123,6 +147,7 @@ export async function buildDaemon(input) {
123
147
  securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
124
148
  pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
125
149
  readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
150
+ d4ExecutionHost: d4Host,
126
151
  }),
127
152
  });
128
153
  const codex = new CodexPlugin({
@@ -154,10 +179,15 @@ export async function buildDaemon(input) {
154
179
  shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
155
180
  contractFingerprint: job.gatewayContractFingerprint ?? "",
156
181
  taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
157
- createBinding: async (job, signal) => gateway.createLocalJobBinding({
158
- jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
159
- launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
160
- }, signal),
182
+ createBinding: async (job, signal) => {
183
+ const binding = await gateway.createLocalJobBinding({
184
+ jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
185
+ launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
186
+ }, signal);
187
+ if (activeD4Job?.id === job.id)
188
+ activeD4Binding = binding.bindingId;
189
+ return binding;
190
+ },
161
191
  publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
162
192
  syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
163
193
  confirmServerPublicationCompletion: (bindingId, cursor, job, receipt) => gateway.confirmServerPublicationCompletion(bindingId, cursor, job, receipt),
@@ -219,7 +249,22 @@ export async function buildDaemon(input) {
219
249
  return { status: "claimed", job };
220
250
  },
221
251
  execute: async (job) => {
222
- await coordinator.execute(job);
252
+ const claimed = job;
253
+ if (claimed.taskPackage.candidate_review_execution) {
254
+ if (activeD4Job)
255
+ throw Error('D4_JOB_CONTEXT_REJECTED');
256
+ activeD4Job = structuredClone(claimed);
257
+ activeD4Binding = undefined;
258
+ try {
259
+ await coordinator.execute(claimed);
260
+ }
261
+ finally {
262
+ activeD4Job = undefined;
263
+ activeD4Binding = undefined;
264
+ }
265
+ }
266
+ else
267
+ await coordinator.execute(claimed);
223
268
  },
224
269
  requestSync: reason => { transport?.requestSync(reason); },
225
270
  // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
@@ -296,10 +341,16 @@ export async function buildDaemon(input) {
296
341
  }
297
342
  const askInbox = await asks.advertisement();
298
343
  const guidance = await guide.advertisement();
344
+ let d4Ready;
345
+ try {
346
+ if (d4Host && (!d4Host.ready || await d4Host.ready()))
347
+ d4Ready = d4Host;
348
+ }
349
+ catch { /* D4 readiness never disables other workflows. */ }
299
350
  return {
300
351
  deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
301
352
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
302
- capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])]), poller.accepting),
353
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], d4Ready), poller.accepting),
303
354
  mode: "full",
304
355
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
305
356
  ...(guidance ? { guidance } : {}),
@@ -462,12 +513,14 @@ export async function reconcilePreparationState(previous, deps) {
462
513
  return true;
463
514
  }
464
515
  /** Vendor capability text cannot claim a host-owned publication facility. */
465
- export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode, pullRequestEvidenceAdapters = []) {
516
+ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode, pullRequestEvidenceAdapters = [], d4Host) {
517
+ if (d4Host && (!/^[a-f0-9]{64}$/.test(d4Host.runnerReleaseSha256) || !/^[a-f0-9]{64}$/.test(d4Host.hostPolicySha256) || typeof d4Host.assertCurrent !== 'function'))
518
+ throw Error('D4_REVIEW_HOST_REQUIRED');
466
519
  const modes = verificationMode === undefined ? [] : typeof verificationMode === 'string' ? [verificationMode] : verificationMode;
467
520
  return probes.map(probe => {
468
521
  const status = adapterStatus(probe);
469
522
  const authenticated = status.authenticated;
470
- const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1');
523
+ const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY && value !== D4_RECORDED_EXECUTION_CAPABILITY);
471
524
  if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
472
525
  capabilities.push('local_fix_publication_v1');
473
526
  if (modes.includes('gateway_ci_v1'))
@@ -478,6 +531,9 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
478
531
  // The host owns the Gateway call behind local_pull_request_read, so only the host can claim it (Work Decision 842179e6).
479
532
  if (authenticated && pullRequestEvidenceAdapters.includes(probe.id))
480
533
  capabilities.push('pull_request_evidence_v1');
481
- return { adapter: probe.id, version: probe.version, ...status, capabilities };
534
+ const d4 = authenticated && probe.id === 'claude_code_cli' && d4Host;
535
+ if (d4)
536
+ capabilities.push(...(d4.capabilities ?? [D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY]));
537
+ return { adapter: probe.id, version: probe.version, ...status, capabilities, ...(d4 ? { runner_release_sha256: d4.runnerReleaseSha256, host_policy_sha256: d4.hostPolicySha256 } : {}) };
482
538
  });
483
539
  }