mtech-ai-local-runner 0.1.111 → 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,15 @@
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
+
1
13
  ## 0.1.111: Work Rooms in the menu bar app
2
14
 
3
15
  The menu bar window now has a **Rooms** tab (Gateway Work Item b35d65cb); the menu's **Show Rooms (n unread)…** opens
@@ -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";
@@ -180,7 +181,8 @@ export class ClaudeCodeAdapter {
180
181
  || this.options.securityPolicy?.pin.toolsSha256 !== profile.tools_sha256 || this.options.securityPolicy?.pin.requirementsSha256 !== profile.cli_security_fingerprint
181
182
  || this.options.securityPolicy?.pin.securityMode !== CLAUDE_RELEASE_MODE)
182
183
  throw Error('D4_REVIEW_HOST_REQUIRED');
183
- await host.assertCurrent(structuredClone(descriptor));
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 ?? '' });
184
186
  };
185
187
  if (this.d4Limits)
186
188
  await assertD4Host();
@@ -193,7 +195,8 @@ export class ClaudeCodeAdapter {
193
195
  if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
194
196
  throw new Error("trusted cwd must live outside the assigned worktree");
195
197
  const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
196
- 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)) {
197
200
  throw new Error("refusing to start: no positive remaining contract budget was supplied");
198
201
  }
199
202
  if (!this.options.worktreeRoot) {
@@ -216,9 +219,9 @@ export class ClaudeCodeAdapter {
216
219
  if (this.cancelled)
217
220
  throw new Error("adapter startup cancelled");
218
221
  const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
219
- const maxBudgetUsd = unbounded
222
+ const maxBudgetUsd = unbounded || recordOnly
220
223
  ? null
221
- : Math.min(this.options.maxBudgetUsd, this.d4Limits ? this.d4Limits.max_estimated_cost_microusd / 1_000_000 : 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));
222
225
  const hookPolicy = buildHookPolicy({
223
226
  taskPackage: ctx.taskPackage,
224
227
  worktreeRoot: this.options.worktreeRoot,
@@ -309,6 +312,9 @@ export class ClaudeCodeAdapter {
309
312
  exitInfo.signal = signal;
310
313
  });
311
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;
312
318
  try {
313
319
  const lines = this.d4Guard ? this.d4Lines(handle, this.d4Guard) : handle.stdout;
314
320
  for await (const line of lines) {
@@ -348,7 +354,9 @@ export class ClaudeCodeAdapter {
348
354
  return;
349
355
  }
350
356
  }
351
- let parsed = parseClaudeLine(line, this.options.worktreeRoot, { p1: this.p1 });
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 });
352
360
  if (!parsed)
353
361
  continue;
354
362
  if (parsed.eventType === "completed") {
@@ -357,7 +365,7 @@ export class ClaudeCodeAdapter {
357
365
  if (this.candidateReview)
358
366
  parsed = candidateReviewCompletion(parsed);
359
367
  if (d4Usage)
360
- parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: 'observed_stop_possible_overshoot' } } };
368
+ parsed = { ...parsed, payload: { ...parsed.payload, d4_usage: { ...d4Usage, policy: this.d4Limits && 'max_total_tokens' in this.d4Limits ? 'observed_stop_possible_overshoot' : 'record_only' } } };
361
369
  if (this.p1)
362
370
  parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
363
371
  }
@@ -370,7 +378,8 @@ export class ClaudeCodeAdapter {
370
378
  }
371
379
  }
372
380
  if (!sawTerminal) {
373
- 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") } };
374
383
  }
375
384
  }
376
385
  catch (error) {
@@ -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,7 +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';
4
- import { D4_OBSERVED_EXECUTION_SCHEMA } from '../safety/d4-execution-limits.js';
5
+ import { isD4ExecutionSchema } from '../safety/d4-execution-limits.js';
5
6
  import { publicationError } from '../publication/failure.js';
6
7
  import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
7
8
  import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
@@ -179,7 +180,7 @@ export class ExecutionCoordinator {
179
180
  assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
180
181
  if (executionAttempt && binding.bindingReplayed !== false)
181
182
  throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
182
- if (pkg.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
183
+ if (isD4ExecutionSchema(pkg.candidate_review_execution?.schema_version)
183
184
  && (job.generation !== 1 || binding.bindingReplayed !== false))
184
185
  throw Error('D4_BINDING_RECONCILIATION_REQUIRED');
185
186
  binding = immutable(structuredClone(binding));
@@ -449,7 +450,7 @@ export class ExecutionCoordinator {
449
450
  if (isP1 && !hostProof)
450
451
  event = p1ModelEvent(event);
451
452
  const previousStage = executionStage;
452
- const d4StopAck = job.taskPackage.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
453
+ const d4StopAck = isD4ExecutionSchema(job.taskPackage.candidate_review_execution?.schema_version)
453
454
  && ['failed', 'cancelled', 'blocked'].includes(event.eventType);
454
455
  let payload = event.payload;
455
456
  if (isTerminalEventType(event.eventType) && !d4StopAck) {
@@ -833,6 +834,8 @@ export class ExecutionCoordinator {
833
834
  const reportDiagnostic = diagnostic && (status === "blocked"
834
835
  ? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
835
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;
836
839
  const rejectedCounts = reportDiagnostic?.rejection_counts;
837
840
  if (rejectedCompletion || rejectedCounts) {
838
841
  try {
@@ -842,7 +845,7 @@ export class ExecutionCoordinator {
842
845
  }
843
846
  const securityReceipt = adapter.securityReceipt?.();
844
847
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
845
- 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 } : {}) } });
846
849
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
847
850
  phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
848
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,7 +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";
18
- import { D4_OBSERVED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
20
+ import { D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
19
21
  import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
20
22
  import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
21
23
  import { WorkspaceManager } from "../workspace/workspace-manager.js";
@@ -76,6 +78,27 @@ export async function buildDaemon(input) {
76
78
  pendingCodexClaim.job.lease_expires_at = expiresAt;
77
79
  };
78
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
+ }
79
102
  // The menu bar app (Work Item b9218937): counts and clickable notices; osascript stays the fallback.
80
103
  let roomActivityEnabled = false;
81
104
  const menubar = buildMenubar({ stateRoot: input.stateRoot, home: input.env.HOME ?? "", gatewayMcpUrl: input.gatewayMcpUrl,
@@ -124,7 +147,7 @@ export async function buildDaemon(input) {
124
147
  securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
125
148
  pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
126
149
  readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
127
- d4ExecutionHost: input.d4ExecutionHost,
150
+ d4ExecutionHost: d4Host,
128
151
  }),
129
152
  });
130
153
  const codex = new CodexPlugin({
@@ -156,10 +179,15 @@ export async function buildDaemon(input) {
156
179
  shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
157
180
  contractFingerprint: job.gatewayContractFingerprint ?? "",
158
181
  taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
159
- createBinding: async (job, signal) => gateway.createLocalJobBinding({
160
- jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
161
- launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
162
- }, 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
+ },
163
191
  publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
164
192
  syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
165
193
  confirmServerPublicationCompletion: (bindingId, cursor, job, receipt) => gateway.confirmServerPublicationCompletion(bindingId, cursor, job, receipt),
@@ -221,7 +249,22 @@ export async function buildDaemon(input) {
221
249
  return { status: "claimed", job };
222
250
  },
223
251
  execute: async (job) => {
224
- 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);
225
268
  },
226
269
  requestSync: reason => { transport?.requestSync(reason); },
227
270
  // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
@@ -298,10 +341,16 @@ export async function buildDaemon(input) {
298
341
  }
299
342
  const askInbox = await asks.advertisement();
300
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. */ }
301
350
  return {
302
351
  deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
303
352
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
304
- capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], input.d4ExecutionHost), poller.accepting),
353
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], d4Ready), poller.accepting),
305
354
  mode: "full",
306
355
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
307
356
  ...(guidance ? { guidance } : {}),
@@ -471,7 +520,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
471
520
  return probes.map(probe => {
472
521
  const status = adapterStatus(probe);
473
522
  const authenticated = status.authenticated;
474
- 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);
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);
475
524
  if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
476
525
  capabilities.push('local_fix_publication_v1');
477
526
  if (modes.includes('gateway_ci_v1'))
@@ -484,7 +533,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
484
533
  capabilities.push('pull_request_evidence_v1');
485
534
  const d4 = authenticated && probe.id === 'claude_code_cli' && d4Host;
486
535
  if (d4)
487
- capabilities.push(D4_OBSERVED_EXECUTION_CAPABILITY);
536
+ capabilities.push(...(d4.capabilities ?? [D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY]));
488
537
  return { adapter: probe.id, version: probe.version, ...status, capabilities, ...(d4 ? { runner_release_sha256: d4.runnerReleaseSha256, host_policy_sha256: d4.hostPolicySha256 } : {}) };
489
538
  });
490
539
  }
@@ -23,6 +23,14 @@ function textOf(content) {
23
23
  const parts = content.filter((block) => record(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text);
24
24
  return parts.length ? parts.join('') : null;
25
25
  }
26
+ /** Tool evidence accepts text only. Never discard an unrecognized block and salvage a passing JSON object. */
27
+ function toolResultText(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ if (!Array.isArray(content) || content.length === 0 || !content.every((block) => record(block) && Object.keys(block).length === 2 && block.type === 'text' && typeof block.text === 'string'))
31
+ return 'CLAUDE_REVIEW_TOOL_RESULT_CONTENT_REJECTED';
32
+ return content.map(block => block.text).join('');
33
+ }
26
34
  const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
27
35
  /** The user's own text. The real CLI prepends context in system-reminder tags, in its own block or the same one. */
28
36
  function userText(content) {
@@ -131,7 +139,7 @@ export async function startClaudeReviewProvider(script) {
131
139
  continue;
132
140
  toolResultIds.push(block.tool_use_id);
133
141
  if (!toolResults.has(block.tool_use_id))
134
- toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '') });
142
+ toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: toolResultText(block.content) });
135
143
  }
136
144
  const firstUser = messages.find(message => message.role === 'user'), { text: firstUserText, reminders } = firstUser ? userText(firstUser.content) : { text: null, reminders: 0 };
137
145
  // The scripted turn: the launched model, the exact review prompt as the first user text and an offered tool list.
@@ -1,6 +1,14 @@
1
1
  import { protocolHash } from '../publication/candidate.js';
2
2
  import { D4_REVIEW_INVENTORY } from './local-tool-policy.js';
3
3
  const LIMIT_KEYS = ['max_candidates', 'max_process_starts', 'max_runtime_seconds', 'automatic_retries', 'max_total_tokens', 'max_estimated_cost_microusd'];
4
+ export function parseD4RecordLimits(value) {
5
+ const names = LIMIT_KEYS.slice(0, 4);
6
+ if (!object(value) || !keys(value, names) || !names.every(k => integer(value[k]))
7
+ || value.max_candidates !== 1 || value.max_process_starts !== 1 || value.automatic_retries !== 0
8
+ || !integer(value.max_runtime_seconds, 1) || value.max_runtime_seconds > 900)
9
+ return reject('D4_LIMIT_PROFILE_REJECTED');
10
+ return Object.freeze({ ...value });
11
+ }
4
12
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
13
  const keys = (v, expected) => Object.keys(v).length === expected.length && expected.every(k => Object.hasOwn(v, k));
6
14
  const integer = (v, min = 0) => typeof v === 'number' && Number.isSafeInteger(v) && v >= min;
@@ -26,9 +34,12 @@ const d4 = (p) => ['candidate_review', 'candidate_review_record', 'candidate_rev
26
34
  export const D4_OBSERVED_EXECUTION_SCHEMA = 'd4-review-execution-v2';
27
35
  export const D4_OBSERVED_EXECUTION_STAGE = 'cli_observed_usage_v1';
28
36
  export const D4_OBSERVED_EXECUTION_CAPABILITY = 'd4_review_observed_usage_v1';
37
+ export const D4_RECORDED_EXECUTION_SCHEMA = 'd4-review-execution-v3';
38
+ export const D4_RECORDED_EXECUTION_CAPABILITY = 'd4_review_recorded_usage_v1';
39
+ export const isD4ExecutionSchema = (s) => s === D4_OBSERVED_EXECUTION_SCHEMA || s === D4_RECORDED_EXECUTION_SCHEMA;
29
40
  const PROFILE_KEYS = ['schema_version', 'decision_revision_id', 'runtime_adapter', 'provider', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint',
30
41
  'runner_release_sha256', 'host_policy_sha256', 'review_status', 'limits', 'usage_enforcement'];
31
- /** Version 2 accepts observed-stop limits only. It does not make strict caps available or authorize a launch. */
42
+ /** Immutable v2 observed-stop and v3 recording-only packages; neither grants launch authority. */
32
43
  export function assertD4PackageLimitSupport(bound, approved, resume) {
33
44
  if (![bound, approved].some(p => object(p) && d4(p)))
34
45
  return;
@@ -43,19 +54,20 @@ export function assertD4PackageLimitSupport(bound, approved, resume) {
43
54
  return reject('D4_LIMIT_PROFILE_REQUIRED');
44
55
  if (protocolHash(a) !== protocolHash(b))
45
56
  return reject('D4_LIMIT_PROFILE_CHANGED');
46
- if (a.schema_version !== D4_OBSERVED_EXECUTION_SCHEMA)
57
+ if (!isD4ExecutionSchema(a.schema_version))
47
58
  return assertCurrentD4LimitSupport();
59
+ const recordOnly = a.schema_version === D4_RECORDED_EXECUTION_SCHEMA;
48
60
  const p = a.profile, u = p.usage_enforcement;
49
- if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== D4_OBSERVED_EXECUTION_STAGE
50
- || !keys(p, PROFILE_KEYS) || p.schema_version !== 'd4-review-execution-profile-v2' || p.review_status !== 'reviewed'
61
+ if (!keys(a, ['schema_version', 'stage', 'profile', 'profile_fingerprint', 'registration_fingerprint']) || a.stage !== (recordOnly ? 'cli_recorded_usage_v1' : D4_OBSERVED_EXECUTION_STAGE)
62
+ || !keys(p, PROFILE_KEYS) || p.schema_version !== (recordOnly ? 'd4-review-execution-profile-v3' : 'd4-review-execution-profile-v2') || p.review_status !== 'reviewed'
51
63
  || p.runtime_adapter !== 'claude_code_cli' || p.provider !== 'anthropic' || p.model_id !== 'claude-sonnet-5-5' || p.tool_inventory !== D4_REVIEW_INVENTORY
52
64
  || typeof p.decision_revision_id !== 'string' || !(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/).test(p.decision_revision_id)
53
65
  || !['tools_sha256', 'cli_security_fingerprint', 'runner_release_sha256', 'host_policy_sha256'].every(k => typeof p[k] === 'string' && /^[a-f0-9]{64}$/.test(p[k]))
54
66
  || typeof a.registration_fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(a.registration_fingerprint)
55
67
  || a.profile_fingerprint !== protocolHash(p) || !object(u) || !keys(u, ['tokens', 'estimated_cost', 'hard_stop'])
56
- || u.tokens !== 'observed_stop' || u.estimated_cost !== 'observed_stop' || u.hard_stop !== false)
68
+ || u.tokens !== (recordOnly ? 'record_only' : 'observed_stop') || u.estimated_cost !== (recordOnly ? 'record_only' : 'observed_stop') || u.hard_stop !== false)
57
69
  return reject('D4_LIMIT_PROFILE_REJECTED');
58
- return parseD4Limits(p.limits);
70
+ return recordOnly ? parseD4RecordLimits(p.limits) : parseD4Limits(p.limits);
59
71
  }
60
72
  /** Conservative diagnostic interpretation only. All token partitions and the CLI's estimated cost must be explicit.
61
73
  * This does not establish that a CLI result is cumulative, timely, complete or an invoice/weekly-limit measurement. */
@@ -106,8 +118,8 @@ export class D4CliUsageTracker {
106
118
  }
107
119
  }
108
120
  const STOP_TIMEOUT_MS = 6000;
109
- /** Local owned-process primitive for synthetic feasibility tests and a future reviewed runtime.
110
- * It grants no admission; the production adapter above remains blocked. Observed caps may already have overshot.
121
+ /** Local owned-process watchdog. It grants no admission; the adapter also requires a trusted reviewed host.
122
+ * V2 stops on observed thresholds (possible overshoot); V3 records usage without numeric stopping.
111
123
  * In-memory start accounting is not a durable restart fence; existing Job/lease owners must provide that later. */
112
124
  export class D4ObservedLimitWatchdog {
113
125
  host;
@@ -124,7 +136,7 @@ export class D4ObservedLimitWatchdog {
124
136
  signalStopped;
125
137
  constructor(limits, host) {
126
138
  this.host = host;
127
- this.limits = parseD4Limits(limits);
139
+ this.limits = object(limits) && !Object.hasOwn(limits, 'max_total_tokens') ? parseD4RecordLimits(limits) : parseD4Limits(limits);
128
140
  this.stoppedSignal = new Promise(resolve => { this.signalStopped = resolve; });
129
141
  }
130
142
  now() { const n = (this.host.now ?? (() => performance.now()))(); if (!Number.isFinite(n) || n < this.lastNow)
@@ -167,9 +179,9 @@ export class D4ObservedLimitWatchdog {
167
179
  return this.stop('D4_USAGE_UNAVAILABLE');
168
180
  }
169
181
  this.last = { ...s };
170
- if (s.total_tokens >= this.limits.max_total_tokens)
182
+ if ('max_total_tokens' in this.limits && s.total_tokens >= this.limits.max_total_tokens)
171
183
  return this.stop('D4_OBSERVED_TOKEN_LIMIT');
172
- if (s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
184
+ if ('max_estimated_cost_microusd' in this.limits && s.estimated_cost_microusd >= this.limits.max_estimated_cost_microusd)
173
185
  return this.stop('D4_OBSERVED_COST_LIMIT');
174
186
  }
175
187
  cancel() { return this.stop('D4_CANCELLED'); }
@@ -0,0 +1,100 @@
1
+ import { D4_REVIEW_REGISTRY_FILE, d4RuntimeReleaseFingerprint } from './d4-runtime-release.js';
2
+ import path from 'node:path';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { protocolHash } from '../publication/candidate.js';
6
+ import { measureLocalFixRuntime } from '../publication/runtime-release.js';
7
+ import { measureCommandFile } from './registered-command-integrity.js';
8
+ import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from './claude-binary-identity.js';
9
+ import { localToolInventory } from './local-tool-server.js';
10
+ import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
11
+ import { assertD4PackageLimitSupport, D4_RECORDED_EXECUTION_SCHEMA, D4_RECORDED_EXECUTION_CAPABILITY } from './d4-execution-limits.js';
12
+ import { REVIEWED_D4_HOST_REGISTRATIONS } from './d4-reviewed-hosts.js';
13
+ const denied = () => Error('D4_HOST_INPUT_REJECTED');
14
+ const hex = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
15
+ const uuid = (v) => typeof v === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v);
16
+ const exact = (v, names) => Object.keys(v).sort().join() === names.sort().join();
17
+ /** Reuses the full packaged runtime/dependency measurement. A matching digest is not a review:
18
+ * only the compiled reviewed registration supplies authority, and Gateway still owns execution grants. */
19
+ export async function composeReviewedD4Host(input) {
20
+ const registrations = input.registrations ?? REVIEWED_D4_HOST_REGISTRATIONS;
21
+ if (registrations.length === 0)
22
+ return { status: 'absent' };
23
+ if (registrations.length !== 1)
24
+ return { status: 'mismatch' };
25
+ try {
26
+ const r = structuredClone(registrations[0]), p = r.policy;
27
+ if (!exact(r, ['schema_version', 'review_status', 'review_receipt_id', 'runner_release_sha256', 'host_policy_sha256', 'policy'])
28
+ || r.schema_version !== 'd4-trusted-host-registration-v1' || r.review_status !== 'reviewed' || !uuid(r.review_receipt_id)
29
+ || !hex(r.runner_release_sha256) || !hex(r.host_policy_sha256) || !p || typeof p !== 'object'
30
+ || !exact(p, ['schema_version', 'decision_revision_id', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint', 'claude_binary_sha256', 'claude_launch_config_sha256', 'node', 'usage_policy', 'max_candidates', 'max_process_starts', 'max_runtime_seconds', 'automatic_retries', 'automatic_resume'])
31
+ || p.schema_version !== 'd4-trusted-readonly-policy-v1' || !uuid(p.decision_revision_id) || p.model_id !== 'claude-sonnet-5-5'
32
+ || p.tool_inventory !== 'local-tools-d4-review-v1' || p.tools_sha256 !== protocolHash({ tools: localToolInventory('local-tools-d4-review-v1') })
33
+ || !hex(p.cli_security_fingerprint) || !hex(p.claude_binary_sha256) || p.claude_launch_config_sha256 !== protocolHash(CLAUDE_LAUNCH_CONFIG)
34
+ || !p.node || !exact(p.node, ['executable', 'sha256']) || !path.isAbsolute(p.node.executable) || !hex(p.node.sha256)
35
+ || p.usage_policy !== 'record_only' || p.max_candidates !== 1 || p.max_process_starts !== 1 || p.max_runtime_seconds !== 900 || p.automatic_retries !== 0 || p.automatic_resume !== false
36
+ || r.host_policy_sha256 !== protocolHash(p))
37
+ throw denied();
38
+ const root = await realpath(input.packageRoot ?? fileURLToPath(new URL('../../../', import.meta.url)));
39
+ if (input.packageRoot !== undefined && root !== input.packageRoot)
40
+ throw denied();
41
+ const registrySha256 = await measureCommandFile(path.join(root, D4_REVIEW_REGISTRY_FILE));
42
+ const runtimeCurrent = async () => {
43
+ if (await realpath(process.execPath) !== p.node.executable || await measureCommandFile(p.node.executable) !== p.node.sha256)
44
+ throw denied();
45
+ const { files } = await measureLocalFixRuntime(root);
46
+ if (files[D4_REVIEW_REGISTRY_FILE] !== registrySha256 || d4RuntimeReleaseFingerprint(files) !== r.runner_release_sha256)
47
+ throw denied();
48
+ };
49
+ await runtimeCurrent();
50
+ const localCurrent = async () => {
51
+ await runtimeCurrent();
52
+ const identity = await input.binaryIdentity();
53
+ if (identity.sha256 !== p.claude_binary_sha256)
54
+ throw denied();
55
+ await assertCurrentClaudeBinary(identity, claudeBinaryLaunchPath(identity), p.claude_binary_sha256);
56
+ };
57
+ const descriptorCurrent = (d) => {
58
+ if (d.schema_version !== D4_RECORDED_EXECUTION_SCHEMA)
59
+ throw denied();
60
+ assertD4PackageLimitSupport({ candidate_review_execution: d }, { candidate_review_execution: d });
61
+ const profile = d.profile;
62
+ for (const key of ['decision_revision_id', 'model_id', 'tool_inventory', 'tools_sha256', 'cli_security_fingerprint'])
63
+ if (profile[key] !== p[key])
64
+ throw denied();
65
+ if (profile.runner_release_sha256 !== r.runner_release_sha256 || profile.host_policy_sha256 !== r.host_policy_sha256)
66
+ throw denied();
67
+ };
68
+ const host = Object.freeze({ capabilities: Object.freeze([D4_RECORDED_EXECUTION_CAPABILITY]), runnerReleaseSha256: r.runner_release_sha256, hostPolicySha256: r.host_policy_sha256,
69
+ ready: async () => { try {
70
+ await localCurrent();
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ } },
76
+ assertCurrent: async (d, job) => {
77
+ try {
78
+ const descriptor = structuredClone(d), context = structuredClone(job);
79
+ if (!context || !context.jobId || !context.bindingId || context.generation !== 1
80
+ || !hex(context.launchFingerprint) || !hex(context.contractFingerprint) || !hex(context.taskPackageFingerprint))
81
+ throw denied();
82
+ descriptorCurrent(descriptor);
83
+ await localCurrent();
84
+ await input.revalidateJob(descriptor, context);
85
+ // Recheck original inputs after the asynchronous authority lookup, before returning to spawn.
86
+ if (protocolHash(d) !== protocolHash(descriptor) || protocolHash(job) !== protocolHash(context))
87
+ throw denied();
88
+ descriptorCurrent(descriptor);
89
+ await localCurrent();
90
+ }
91
+ catch {
92
+ throw denied();
93
+ }
94
+ } });
95
+ return { status: 'composed', host };
96
+ }
97
+ catch {
98
+ return { status: 'mismatch' };
99
+ }
100
+ }
@@ -0,0 +1,2 @@
1
+ /** Release review input, not employee config. No operational D4 host is registered by this source change. */
2
+ export const REVIEWED_D4_HOST_REGISTRATIONS = Object.freeze([]);
@@ -0,0 +1,9 @@
1
+ import { protocolHash } from '../publication/candidate.js';
2
+ /** The compiled owner-reviewed registration is the trust anchor, not runtime payload.
3
+ * Including its embedded expected digest in that digest would require a fixed point.
4
+ * The composer separately pins these exact file bytes for every current-host check. */
5
+ export const D4_REVIEW_REGISTRY_FILE = 'dist/src/safety/d4-reviewed-hosts.js';
6
+ export function d4RuntimeReleaseFingerprint(files) {
7
+ const runtime = Object.fromEntries(Object.entries(files).filter(([file]) => file !== D4_REVIEW_REGISTRY_FILE));
8
+ return protocolHash({ role: 'd4_review_host', files: runtime });
9
+ }
@@ -3,6 +3,7 @@ import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner
3
3
  import { isP1WorkingBranch } from '../publication/git-snapshot.js';
4
4
  import { protocolHash } from '../publication/candidate.js';
5
5
  import { resolveRunnerVerification, assertVerificationHostVersion, REVIEWED_POLICY, REVIEWED_LOOP_TYPE } from './reviewed-runner-contract.js';
6
+ import { runtimeFailureDiagnostic } from '../adapters/runtime-failure.js';
6
7
  export class RunnerObservedExecutionHost {
7
8
  privateRoot;
8
9
  supportedVerificationSchemas;
@@ -101,6 +102,9 @@ export function p1ModelEvent(event) {
101
102
  }
102
103
  if (Array.isArray(event.payload?.memory_usage_items))
103
104
  payload.memory_usage_items = event.payload.memory_usage_items;
105
+ const failure = event.eventType === 'failed' ? runtimeFailureDiagnostic(event.payload?.runtime_failure) : undefined;
106
+ if (failure)
107
+ payload.runtime_failure = failure;
104
108
  // Only parsers can attach these provider observations; never completion text.
105
109
  const usage = event.payload?.usage;
106
110
  if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {