mtech-ai-local-runner 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +64 -1
  2. package/dist/src/adapters/claude-code/adapter.js +76 -17
  3. package/dist/src/adapters/codex/adapter.js +146 -30
  4. package/dist/src/adapters/codex/exec-launch-host.js +50 -11
  5. package/dist/src/adapters/codex/stream-parser.js +8 -17
  6. package/dist/src/adapters/completion-report.js +8 -5
  7. package/dist/src/cli.js +23 -0
  8. package/dist/src/config.js +4 -4
  9. package/dist/src/daemon/daemon.js +62 -4
  10. package/dist/src/daemon/execution-coordinator.js +351 -77
  11. package/dist/src/daemon/job-poller.js +4 -1
  12. package/dist/src/daemon/manual-codex.js +10 -3
  13. package/dist/src/daemon/preparation-lease.js +142 -0
  14. package/dist/src/daemon/runtime.js +65 -23
  15. package/dist/src/daemon/terminal-recovery.js +29 -5
  16. package/dist/src/gateway-client.js +120 -22
  17. package/dist/src/platform/macos/launchd.js +13 -2
  18. package/dist/src/process/node-process-group-host.js +89 -34
  19. package/dist/src/process/owned-process-guardian.js +140 -0
  20. package/dist/src/publication/candidate.js +95 -0
  21. package/dist/src/publication/execution-host.js +55 -0
  22. package/dist/src/publication/git-credential-broker.js +150 -0
  23. package/dist/src/publication/git-credential-helper.js +29 -0
  24. package/dist/src/publication/git-snapshot.js +321 -0
  25. package/dist/src/publication/git-transport.js +156 -0
  26. package/dist/src/publication/installed-host.js +120 -0
  27. package/dist/src/publication/isolated-verifier.js +67 -0
  28. package/dist/src/publication/journal.js +87 -0
  29. package/dist/src/publication/production-host.js +155 -0
  30. package/dist/src/publication/project-registration.js +49 -0
  31. package/dist/src/publication/publisher.js +166 -0
  32. package/dist/src/publication/recovery.js +53 -0
  33. package/dist/src/publication/runtime-release.js +91 -0
  34. package/dist/src/publication/server-ci-error.js +15 -0
  35. package/dist/src/publication/server-ci-host.js +160 -0
  36. package/dist/src/runner.js +33 -4
  37. package/dist/src/safety/codex-daemon-host.js +2 -2
  38. package/dist/src/safety/command-policy-core.js +124 -0
  39. package/dist/src/safety/command-policy.js +1 -124
  40. package/dist/src/safety/execution-failure.js +4 -0
  41. package/dist/src/safety/job-tool-policy.js +9 -0
  42. package/dist/src/safety/local-tool-policy.js +1 -1
  43. package/dist/src/safety/terminal-evidence.js +67 -16
  44. package/dist/src/safety/unsupported-supervisor-command.js +12 -0
  45. package/dist/src/state-store.js +50 -7
  46. package/dist/src/workspace/workspace-manager.js +14 -0
  47. package/package.json +3 -2
  48. package/verification/README.md +63 -0
  49. package/verification/runner-policy.cjs +24 -0
@@ -36,9 +36,10 @@ export async function runManualCodexJob(input, deps) {
36
36
  let leaseExpires = claim.job.lease_expires_at;
37
37
  if (!Number.isSafeInteger(leaseExpires))
38
38
  throw Error('MANUAL_PILOT_LEASE_MISSING');
39
- const job = { id: claim.job.id, generation: claim.job.generation, workLoopId: claim.job.work_loop_id, leaseToken: claim.lease_token, launchGrant: claim.launch_grant, taskPackage: claim.task_package, launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint, gatewayTaskPackageFingerprint: claim.task_package_fingerprint };
39
+ const job = { id: claim.job.id, generation: claim.job.generation, leaseExpiresAt: claim.job.lease_expires_at, workLoopId: claim.job.work_loop_id, leaseToken: claim.lease_token, launchGrant: claim.launch_grant, taskPackage: claim.task_package, launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint, gatewayTaskPackageFingerprint: claim.task_package_fingerprint };
40
40
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: 'claimed' });
41
41
  let result = { jobId: job.id, status: 'unacknowledged' };
42
+ const confirmJobLease = (j, expires) => { host?.heartbeat(j.id, j.generation, expires); leaseExpires = expires; claim.job.lease_expires_at = expires; };
42
43
  const coordinator = new ExecutionCoordinator({ state, workspaces: deps?.workspaces ?? new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }), terminalEvidenceCollector: deps?.terminalEvidenceCollector ?? buildTerminalEvidenceCollector(),
43
44
  reportExecutionFailure: d => process.stderr.write(JSON.stringify({ event: 'manual_execution_failed', ...d }) + '\n'),
44
45
  reportPreparationFailure: d => process.stderr.write(JSON.stringify({ event: 'manual_preparation_failed', ...d }) + '\n'),
@@ -61,8 +62,14 @@ export async function runManualCodexJob(input, deps) {
61
62
  adapter = await plugin.create(options);
62
63
  return adapter;
63
64
  } },
64
- gateway: { shadow: j => input.gateway.localJobShadow({ workLoopId: j.workLoopId, contractFingerprint: j.gatewayContractFingerprint, taskPackageFingerprint: j.gatewayTaskPackageFingerprint }), createBinding: j => input.gateway.createLocalJobBinding({ jobId: j.id, generation: j.generation, jobLeaseToken: j.leaseToken, launchGrant: j.launchGrant, workLoopId: j.workLoopId, checkoutPath: j.checkoutPath }), appendEvent: (id, event) => input.gateway.appendEvent(id, event), readCommands: (id, cursor) => input.gateway.readCommands(id, cursor), acknowledgeCommand: (id, sequence) => input.gateway.acknowledgeCommand(id, sequence), async heartbeat(id, j) { await input.gateway.heartbeat(id); const renewed = await input.gateway.heartbeatLocalJob({ jobId: j.id, generation: j.generation, leaseToken: j.leaseToken }); if (!renewed?.job || renewed.job.id !== job.id || renewed.job.generation !== job.generation)
65
- throw Error('MANUAL_PILOT_LEASE_FENCE'); leaseExpires = renewed.job.lease_expires_at; host?.heartbeat(job.id, job.generation, leaseExpires); }, async complete(value) { await input.gateway.completeLocalJob(value); result = { jobId: value.jobId, status: value.status }; } },
65
+ gateway: {
66
+ renewJob: (j, signal) => input.gateway.heartbeatLocalJob({ jobId: j.id, generation: j.generation, leaseToken: j.leaseToken }, signal), confirmJobLease,
67
+ shadow: (j, signal) => input.gateway.localJobShadow({ workLoopId: j.workLoopId, contractFingerprint: j.gatewayContractFingerprint, taskPackageFingerprint: j.gatewayTaskPackageFingerprint }, signal),
68
+ createBinding: (j, signal) => input.gateway.createLocalJobBinding({ jobId: j.id, generation: j.generation, jobLeaseToken: j.leaseToken, launchGrant: j.launchGrant, workLoopId: j.workLoopId, checkoutPath: j.checkoutPath }, signal),
69
+ appendEvent: (id, event) => input.gateway.appendEvent(id, event), readCommands: (id, cursor, signal) => input.gateway.readCommands(id, cursor, signal), acknowledgeCommand: (id, sequence, signal) => input.gateway.acknowledgeCommand(id, sequence, signal),
70
+ async heartbeat(id, j, signal) { await input.gateway.heartbeat(id, signal); signal.throwIfAborted(); return input.gateway.heartbeatLocalJob({ jobId: j.id, generation: j.generation, leaseToken: j.leaseToken }, signal); },
71
+ async complete(value) { await input.gateway.completeLocalJob(value); result = { jobId: value.jobId, status: value.status }; }
72
+ },
66
73
  });
67
74
  await coordinator.execute(job);
68
75
  return result;
@@ -0,0 +1,142 @@
1
+ import { GatewayToolError } from '../gateway-client.js';
2
+ function requireJobIdentity(job) {
3
+ if (typeof job.id !== 'string' || !job.id.trim() || !Number.isSafeInteger(job.generation) || job.generation < 1) {
4
+ throw new GatewayToolError('Job claim identity is invalid', 'local_agent_jobs_wait', 'JOB_LEASE_FENCE_INVALID');
5
+ }
6
+ }
7
+ export function validateJobLease(receipt, job, allowRunning, allowReviewPending = allowRunning) {
8
+ requireJobIdentity(job);
9
+ const renewed = receipt?.job;
10
+ if (!renewed || renewed.id !== job.id || renewed.generation !== job.generation
11
+ || !(allowRunning ? ['claimed', 'shadowing', 'running', ...(allowReviewPending ? ['review_pending'] : [])] : ['claimed', 'shadowing']).includes(renewed.status)
12
+ || !Number.isSafeInteger(renewed.lease_expires_at) || renewed.lease_expires_at * 1000 <= Date.now()) {
13
+ throw new GatewayToolError('Job lease fence is invalid', 'local_agent_job_heartbeat', 'JOB_LEASE_FENCE_INVALID');
14
+ }
15
+ return renewed.lease_expires_at;
16
+ }
17
+ /** Preparation-only rolling Job fence. The Work Loop remains Gateway-owned. */
18
+ export class PreparationLease {
19
+ job;
20
+ deadline;
21
+ renew;
22
+ confirmed;
23
+ controller = new AbortController();
24
+ failure;
25
+ leaseExpiresAt;
26
+ bindingStarted = false;
27
+ stopped = false;
28
+ schedule;
29
+ boundary;
30
+ backoff;
31
+ pending;
32
+ rejectAbort;
33
+ aborted = new Promise((_, reject) => { this.rejectAbort = reject; });
34
+ constructor(job, deadline, renew, confirmed) {
35
+ this.job = job;
36
+ this.deadline = deadline;
37
+ this.renew = renew;
38
+ this.confirmed = confirmed;
39
+ void this.aborted.catch(() => { });
40
+ requireJobIdentity(job);
41
+ if (Number.isSafeInteger(job.leaseExpiresAt))
42
+ this.leaseExpiresAt = job.leaseExpiresAt;
43
+ this.armBoundary();
44
+ }
45
+ get signal() { return this.controller.signal; }
46
+ get hasLiveFence() { return this.leaseExpiresAt !== undefined && this.leaseExpiresAt * 1000 > Date.now() && (!this.failure || this.failure instanceof PreparationDeadlineError); }
47
+ assert() {
48
+ if (this.failure)
49
+ throw this.failure;
50
+ if (Date.now() >= this.deadline)
51
+ this.fail(new PreparationDeadlineError());
52
+ if (this.leaseExpiresAt !== undefined && Date.now() >= this.leaseExpiresAt * 1000)
53
+ this.fail(new Error('Job lease expired during preparation'));
54
+ if (this.failure)
55
+ throw this.failure;
56
+ }
57
+ fail(error) {
58
+ if (this.failure)
59
+ return;
60
+ this.failure = error;
61
+ this.clearTimers();
62
+ this.controller.abort();
63
+ this.rejectAbort(error);
64
+ }
65
+ async step(operation, late) {
66
+ this.assert();
67
+ const work = operation().then(async (value) => {
68
+ if (this.signal.aborted) {
69
+ await late?.(value);
70
+ this.assert();
71
+ }
72
+ this.assert();
73
+ return value;
74
+ });
75
+ return Promise.race([work, this.aborted]);
76
+ }
77
+ start() { return this.renewOnce(); }
78
+ beginBinding() { this.assert(); this.bindingStarted = true; }
79
+ async stop() {
80
+ this.stopped = true;
81
+ if (this.schedule)
82
+ clearTimeout(this.schedule);
83
+ try {
84
+ if (this.pending)
85
+ await this.pending;
86
+ this.assert();
87
+ }
88
+ finally {
89
+ this.clearTimers();
90
+ }
91
+ }
92
+ dispose() { this.stopped = true; this.clearTimers(); this.controller.abort(); }
93
+ clearTimers() { if (this.schedule)
94
+ clearTimeout(this.schedule); if (this.boundary)
95
+ clearTimeout(this.boundary); if (this.backoff)
96
+ clearTimeout(this.backoff); }
97
+ armBoundary() {
98
+ if (this.boundary)
99
+ clearTimeout(this.boundary);
100
+ const expires = this.leaseExpiresAt === undefined ? this.deadline : Math.min(this.deadline, this.leaseExpiresAt * 1000);
101
+ this.boundary = setTimeout(() => this.fail(expires >= this.deadline ? new PreparationDeadlineError() : new Error('Job lease expired during preparation')), Math.max(0, expires - Date.now()));
102
+ this.boundary.unref?.();
103
+ }
104
+ renewOnce() {
105
+ if (this.pending)
106
+ return this.pending;
107
+ const run = async () => {
108
+ let retries = 0;
109
+ while (true) {
110
+ this.assert();
111
+ try {
112
+ const receipt = await Promise.race([this.renew(this.signal), this.aborted]);
113
+ this.assert();
114
+ const expiry = validateJobLease(receipt, this.job, this.bindingStarted, false);
115
+ this.confirmed?.(expiry);
116
+ this.leaseExpiresAt = expiry;
117
+ if (!this.stopped)
118
+ this.armBoundary();
119
+ return;
120
+ }
121
+ catch (error) {
122
+ this.assert();
123
+ const transient = error instanceof GatewayToolError && (error.code === 'GATEWAY_TRANSPORT_ERROR' || /^GATEWAY_HTTP_50[0234]$/.test(error.code));
124
+ if (!transient || !this.hasLiveFence || retries >= 3)
125
+ throw error;
126
+ const delay = Math.min(1000 * 2 ** retries++, 4000);
127
+ await this.step(() => new Promise(resolve => { this.backoff = setTimeout(resolve, delay); }));
128
+ }
129
+ }
130
+ };
131
+ this.pending = run().catch(error => { this.fail(error); throw error; }).finally(() => {
132
+ this.pending = undefined;
133
+ if (!this.stopped && !this.signal.aborted) {
134
+ this.schedule = setTimeout(() => { void this.renewOnce().catch(error => this.fail(error)); }, 30_000);
135
+ this.schedule.unref?.();
136
+ }
137
+ });
138
+ return this.pending;
139
+ }
140
+ }
141
+ export class PreparationDeadlineError extends Error {
142
+ }
@@ -1,3 +1,4 @@
1
+ import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
1
2
  import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
2
3
  import { reconcileTerminalState } from './terminal-recovery.js';
3
4
  import { packageVersion } from '../package-version.js';
@@ -25,7 +26,18 @@ export async function buildDaemon(input) {
25
26
  throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
26
27
  const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
27
28
  let pendingCodexClaim;
29
+ let registeredCodexJob;
30
+ const confirmJobLease = (job, expiresAt) => {
31
+ if (job.taskPackage.runtime_adapter !== 'codex_cli' || !production)
32
+ return;
33
+ if (!pendingCodexClaim?.job || pendingCodexClaim.job.id !== job.id || pendingCodexClaim.job.generation !== job.generation)
34
+ throw new Error('CODEX_JOB_LEASE_FENCE_MISMATCH');
35
+ if (registeredCodexJob === job.id)
36
+ production.heartbeat(job.id, job.generation, expiresAt);
37
+ pendingCodexClaim.job.lease_expires_at = expiresAt;
38
+ };
28
39
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
40
+ const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
29
41
  // The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
30
42
  const hook = packagedHookCommandParts();
31
43
  const plugin = new ClaudeCodePlugin({
@@ -45,36 +57,37 @@ export async function buildDaemon(input) {
45
57
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
46
58
  const state = new FileDaemonStateStore(input.stateRoot);
47
59
  const coordinator = new ExecutionCoordinator({
60
+ localFixHost,
48
61
  reportExecutionFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "execution_failed", ...diagnostic })}\n`); },
49
62
  reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
50
63
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
51
64
  gateway: {
52
- shadow: (job) => gateway.localJobShadow({ workLoopId: job.workLoopId,
65
+ renewJob: (job, signal) => gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken }, signal),
66
+ confirmJobLease,
67
+ shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
53
68
  contractFingerprint: job.gatewayContractFingerprint ?? "",
54
- taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }),
55
- createBinding: async (job) => gateway.createLocalJobBinding({
69
+ taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
70
+ createBinding: async (job, signal) => gateway.createLocalJobBinding({
56
71
  jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
57
72
  launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
58
- }),
59
- readCommands: (bindingId, cursor) => gateway.readCommands(bindingId, cursor),
60
- acknowledgeCommand: (bindingId, sequence) => gateway.acknowledgeCommand(bindingId, sequence),
73
+ }, signal),
74
+ publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
75
+ syncPublicationCursor: (bindingId, cursor) => gateway.syncLocalPublicationCursor(bindingId, cursor),
76
+ readCommands: (bindingId, cursor, signal) => gateway.readCommands(bindingId, cursor, signal),
77
+ acknowledgeCommand: (bindingId, sequence, signal) => gateway.acknowledgeCommand(bindingId, sequence, signal),
61
78
  appendEvent: (bindingId, event) => gateway.appendEvent(bindingId, event),
62
- heartbeat: async (bindingId, job) => {
63
- const [, renewed] = await Promise.all([gateway.heartbeat(bindingId), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken })]);
64
- if (renewed?.job) {
65
- if (renewed.job.id !== job.id || renewed.job.generation !== job.generation)
66
- throw new Error('CODEX_JOB_LEASE_FENCE_MISMATCH');
67
- if (job.taskPackage.runtime_adapter === 'codex_cli')
68
- production?.heartbeat(job.id, job.generation, renewed.job.lease_expires_at);
69
- }
79
+ heartbeat: async (bindingId, job, signal) => {
80
+ const [, renewed] = await Promise.all([gateway.heartbeat(bindingId, signal), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken }, signal)]);
81
+ return renewed;
70
82
  },
71
- complete: async (result) => { await gateway.completeLocalJob(result); await production?.releaseJob(result.jobId); pendingCodexClaim = undefined; },
83
+ complete: async (result) => { await gateway.completeLocalJob(result); await production?.releaseJob(result.jobId); pendingCodexClaim = undefined; registeredCodexJob = undefined; },
72
84
  },
73
85
  registry: { select: async (id, version, options) => {
74
86
  if (production && id === 'codex_cli') {
75
87
  if (!pendingCodexClaim || pendingCodexClaim.job?.id !== options.jobId)
76
88
  throw new Error('CODEX_TRUSTED_CLAIM_REQUIRED');
77
89
  production.registerClaim(pendingCodexClaim);
90
+ registeredCodexJob = options.jobId;
78
91
  }
79
92
  return registry.select(id, version, options);
80
93
  } },
@@ -102,8 +115,7 @@ export async function buildDaemon(input) {
102
115
  await gateway.heartbeatLocalRunner({
103
116
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
104
117
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
105
- capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
106
- capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
118
+ capabilities: localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode),
107
119
  });
108
120
  },
109
121
  wait: async () => {
@@ -111,12 +123,13 @@ export async function buildDaemon(input) {
111
123
  if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
112
124
  return { status: "no_job" };
113
125
  if (production && claim.task_package.runtime_adapter === 'codex_cli')
114
- pendingCodexClaim = claim;
126
+ pendingCodexClaim = structuredClone(claim);
115
127
  const job = {
116
128
  id: claim.job.id, generation: claim.job.generation, leaseToken: claim.lease_token, launchGrant: claim.launch_grant,
117
129
  workLoopId: claim.job.work_loop_id, taskPackage: claim.task_package,
118
130
  launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
119
131
  gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
132
+ leaseExpiresAt: claim.job.lease_expires_at,
120
133
  };
121
134
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
122
135
  return { status: "claimed", job };
@@ -133,21 +146,37 @@ export async function buildDaemon(input) {
133
146
  if (await reconcileTerminalState(previous, { gateway: { complete: request => gateway.completeLocalJob(request) }, state }))
134
147
  return;
135
148
  if (await reconcilePreparationState(previous, {
136
- complete: (result) => gateway.completeLocalJob(result), clear: () => state.clear(),
149
+ complete: (result) => gateway.completeLocalJob(result), clear: () => state.clear(), save: receipt => state.save(receipt),
137
150
  }))
138
151
  return;
139
- await state.clear();
152
+ throw new Error('Runner state requires authoritative reconciliation');
140
153
  },
141
154
  poller,
155
+ reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
142
156
  });
143
157
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
144
- return { daemon, close: async () => { poller.stop(); await production?.close(); } };
158
+ return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
145
159
  }
146
160
  export function runnerDisplayName() { return `${hostname()} local AI`; }
147
161
  /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
148
162
  export async function reconcilePreparationState(previous, deps) {
149
- if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
150
- throw new Error("Binding outcome requires reconciliation before runner polling");
163
+ if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
164
+ throw new Error("Publication outcome requires reconciliation before runner polling");
165
+ if (previous.phase === 'running' || previous.phase === 'reconciling')
166
+ throw new Error('Executor cleanup requires authoritative reconciliation');
167
+ if (['claimed', 'shadow', 'binding', 'binding_uncertain'].includes(previous.phase ?? '')) {
168
+ if (!deps.save)
169
+ throw new Error('Binding outcome requires reconciliation before runner polling');
170
+ if (typeof previous.jobId !== 'string' || !previous.jobId.trim() || !Number.isSafeInteger(previous.generation) || Number(previous.generation) < 1 || typeof previous.leaseToken !== 'string' || !previous.leaseToken.trim())
171
+ throw new Error('Recovery receipt is missing its job fence');
172
+ const reason = previous.phase === 'binding' || previous.phase === 'binding_uncertain' ? 'binding_outcome_unknown' : 'runner_restarted';
173
+ // A Job recovery result records client interruption facts. The server closer proves
174
+ // Job/binding lineage independently and supplies its own server fact reason.
175
+ const completionRequest = { jobId: previous.jobId, generation: previous.generation, leaseToken: previous.leaseToken, status: reason === 'binding_outcome_unknown' ? 'blocked' : 'failed', result: { reason, recovery: { version: 'local-runner-recovery-v1', phase: previous.phase } } };
176
+ await deps.save({ jobId: previous.jobId, generation: previous.generation, leaseToken: previous.leaseToken, phase: 'terminal_pending', completionRequest: structuredClone(completionRequest) });
177
+ await deps.complete(structuredClone(completionRequest));
178
+ await deps.clear();
179
+ return true;
151
180
  }
152
181
  if (!previous.preparationFailure)
153
182
  return false;
@@ -159,3 +188,16 @@ export async function reconcilePreparationState(previous, deps) {
159
188
  await deps.clear();
160
189
  return true;
161
190
  }
191
+ /** Vendor capability text cannot claim a host-owned publication facility. */
192
+ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode) {
193
+ return probes.map(probe => {
194
+ const authenticated = probe.available && probe.authenticated;
195
+ const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1');
196
+ if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli') {
197
+ capabilities.push('local_fix_publication_v1');
198
+ if (verificationMode === 'gateway_ci_v1')
199
+ capabilities.push('local_fix_gateway_ci_v1');
200
+ }
201
+ return { adapter: probe.id, version: probe.version, authenticated, capabilities };
202
+ });
203
+ }
@@ -1,4 +1,5 @@
1
1
  import { isTerminalEventType } from '../protocol.js';
2
+ import { DAEMON_BLOCKED_REASONS, DAEMON_CANCELLED_REASONS } from '../state-store.js';
2
3
  function record(value) {
3
4
  return value !== null && typeof value === 'object' && !Array.isArray(value);
4
5
  }
@@ -11,13 +12,19 @@ export async function reconcileTerminalState(previous, deps) {
11
12
  if (!record(previous))
12
13
  invalid();
13
14
  if (previous.phase !== 'terminal_pending') {
14
- if (!['claimed', 'shadow', 'binding', 'binding_uncertain', 'preparation_failed'].includes(String(previous.phase))
15
+ if (typeof previous.phase !== 'string' || !['claimed', 'shadow', 'binding', 'binding_uncertain', 'preparation_failed', 'running'].includes(previous.phase)
15
16
  || !nonempty(previous.jobId) || !Number.isSafeInteger(previous.generation) || previous.generation < 1
16
17
  || !nonempty(previous.leaseToken))
17
18
  invalid();
18
- // Only preparation states without execution evidence may reach the legacy
19
- // preparation handler (whose caller may clear an unhandled receipt).
19
+ // Validate known states for the recovery handler. A running state remains
20
+ // suspended there: neither a terminal flag nor its fence proves OS cleanup.
20
21
  const allowed = ['phase', 'jobId', 'generation', 'leaseToken', 'workspaceManifest'];
22
+ if (previous.phase === 'running') {
23
+ allowed.push('bindingId', 'eventCursor', 'terminalEventType');
24
+ if (!nonempty(previous.bindingId) || (previous.eventCursor !== undefined && (!Number.isSafeInteger(previous.eventCursor) || previous.eventCursor < 0))
25
+ || (previous.terminalEventType !== undefined && (typeof previous.terminalEventType !== 'string' || !isTerminalEventType(previous.terminalEventType))))
26
+ invalid();
27
+ }
21
28
  if (previous.phase === 'preparation_failed' || previous.phase === 'binding_uncertain')
22
29
  allowed.push('preparationFailure');
23
30
  if (Object.keys(previous).some(key => !allowed.includes(key)))
@@ -38,12 +45,29 @@ export async function reconcileTerminalState(previous, deps) {
38
45
  || request.generation !== previous.generation || request.leaseToken !== previous.leaseToken || !record(request.result))
39
46
  invalid();
40
47
  const result = request.result;
41
- if (previous.bindingId !== undefined) {
48
+ if (result.recovery !== undefined) {
49
+ const recovery = result.recovery;
50
+ if (Object.keys(previous).sort().join(',') !== 'completionRequest,generation,jobId,leaseToken,phase'
51
+ || Object.keys(result).sort().join(',') !== 'reason,recovery' || !record(recovery)
52
+ || Object.keys(recovery).sort().join(',') !== 'phase,version' || recovery.version !== 'local-runner-recovery-v1'
53
+ || typeof recovery.phase !== 'string' || !['claimed', 'shadow', 'binding', 'binding_uncertain'].includes(recovery.phase)
54
+ || result.reason !== (recovery.phase === 'binding' || recovery.phase === 'binding_uncertain' ? 'binding_outcome_unknown' : 'runner_restarted')
55
+ || request.status !== (result.reason === 'binding_outcome_unknown' ? 'blocked' : 'failed'))
56
+ invalid();
57
+ }
58
+ else if (previous.bindingId !== undefined) {
42
59
  const eventType = result.event_type;
43
60
  if (!nonempty(previous.bindingId) || result.binding_id !== previous.bindingId || typeof eventType !== 'string'
44
61
  || (!isTerminalEventType(eventType) && eventType !== 'blocked' && eventType !== 'scope_conflict') || previous.terminalEventType !== eventType || typeof result.summary !== 'string')
45
62
  invalid();
46
- const status = eventType === 'completed' ? 'completed' : eventType === 'cancelled' ? 'cancelled'
63
+ const reason = result.reason;
64
+ const blocked = eventType === 'failed' && typeof reason === 'string' && DAEMON_BLOCKED_REASONS.includes(reason);
65
+ // Old blocked/scope_conflict receipts remain replayable byte-for-byte. New
66
+ // failed/blocked pairs require one explicit daemon outcome reason.
67
+ if (reason !== undefined && eventType !== 'blocked' && eventType !== 'scope_conflict' && !(blocked || (eventType === 'cancelled' && typeof reason === 'string'
68
+ && DAEMON_CANCELLED_REASONS.includes(reason))))
69
+ invalid();
70
+ const status = blocked ? 'blocked' : eventType === 'completed' ? 'completed' : eventType === 'cancelled' ? 'cancelled'
47
71
  : eventType === 'blocked' || eventType === 'scope_conflict' ? 'blocked' : 'failed';
48
72
  if (request.status !== status)
49
73
  invalid();
@@ -122,16 +122,25 @@ export class McpGatewayClient {
122
122
  }
123
123
  throw new Error("No token provider or static token configured");
124
124
  }
125
- async callTool(name, args) {
125
+ async callTool(name, args, signal) {
126
126
  let failureCode = "GATEWAY_TRANSPORT_ERROR";
127
127
  try {
128
128
  const token = await this.resolveToken();
129
+ signal?.throwIfAborted();
129
130
  const response = await this.fetchFn(this.mcpUrl, {
131
+ ...(signal ? { signal } : {}),
130
132
  method: "POST",
131
133
  headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
132
134
  body: JSON.stringify({ jsonrpc: "2.0", id: ++this.requestId, method: "tools/call", params: { name, arguments: args } }),
133
135
  });
136
+ signal?.throwIfAborted();
137
+ if (!response.ok) {
138
+ failureCode = `GATEWAY_HTTP_${response.status}`;
139
+ throw new Error(`Gateway MCP ${name} failed with status ${response.status}`);
140
+ }
141
+ failureCode = 'GATEWAY_INVALID_TOOL_RESULT';
134
142
  const body = (await response.json());
143
+ signal?.throwIfAborted();
135
144
  if (!response.ok || body.error) {
136
145
  failureCode = !response.ok ? `GATEWAY_HTTP_${response.status}` : "GATEWAY_RPC_ERROR";
137
146
  throw new Error(`Gateway MCP ${name} failed with status ${response.status}: ${JSON.stringify(body.error ?? body)}`);
@@ -146,6 +155,7 @@ export class McpGatewayClient {
146
155
  failureCode = envelope.status === "failed" ? "GATEWAY_TOOL_REJECTED" : "GATEWAY_INVALID_TOOL_RESULT";
147
156
  throw new Error(`Gateway MCP ${name} did not complete: ${JSON.stringify(envelope)}`);
148
157
  }
158
+ signal?.throwIfAborted();
149
159
  return nested(envelope, "result.data");
150
160
  }
151
161
  catch (error) {
@@ -377,28 +387,29 @@ export class McpGatewayClient {
377
387
  : {}),
378
388
  };
379
389
  }
380
- async readCommands(bindingId, afterSequence) {
390
+ async readCommands(bindingId, afterSequence, signal) {
381
391
  const result = await this.callTool("agent_runtime_commands_read", {
382
392
  binding_id: bindingId,
383
393
  runner_id: this.runnerId,
384
394
  after_sequence: afterSequence,
385
395
  limit: 100,
386
- });
396
+ }, signal);
387
397
  return result.commands ?? [];
388
398
  }
389
- async acknowledgeCommand(bindingId, sequence) {
399
+ async acknowledgeCommand(bindingId, sequence, signal) {
390
400
  await this.callTool("agent_runtime_command_acknowledge", {
391
401
  binding_id: bindingId,
392
402
  runner_id: this.runnerId,
393
403
  command_sequence: sequence,
394
- });
404
+ }, signal);
395
405
  }
396
- async heartbeat(bindingId) {
406
+ async heartbeat(bindingId, signal) {
397
407
  const context = this.requireContext(bindingId);
398
408
  const result = await this.callTool("work_loop_heartbeat", {
399
409
  work_loop_id: context.workLoopId,
400
410
  lease_token: context.leaseToken,
401
- });
411
+ }, signal);
412
+ signal?.throwIfAborted();
402
413
  context.leaseExpiresAt = epochToIso(result.loop.lease_expires_at);
403
414
  return { leaseExpiresAt: context.leaseExpiresAt };
404
415
  }
@@ -432,26 +443,31 @@ export class McpGatewayClient {
432
443
  async localRunnerStatus() {
433
444
  return this.callTool("local_agent_runner_status", { runner_id: this.runnerId });
434
445
  }
435
- async localJobShadow(input) {
436
- const shown = await this.callTool("work_loop_show", { work_loop_id: input.workLoopId });
437
- if (shown.loop.environment !== "development")
438
- throw new Error("local agent runtime is development-only");
446
+ async localJobShadow(input, signal) {
447
+ const shown = await this.callTool("work_loop_show", { work_loop_id: input.workLoopId }, signal);
448
+ if (shown?.loop?.environment !== "development")
449
+ throw new GatewayToolError("local agent runtime is development-only", 'work_loop_show', 'GATEWAY_INVALID_TOOL_RESULT');
439
450
  if (shown.loop.status === "cancelled")
440
451
  throw new GatewayToolError("Work Loop is cancelled", "work_loop_show", "WORK_LOOP_CANCELLED");
452
+ const contractId = shown.loop.work_execution_contract_id;
453
+ if (typeof contractId !== 'string' || !contractId)
454
+ throw new GatewayToolError('Gateway response is missing execution contract ID', 'work_loop_show', 'GATEWAY_INVALID_TOOL_RESULT');
441
455
  const result = await this.callTool("work_execution_contract_show", {
442
- contract_id: requiredString(shown.loop.work_execution_contract_id, "execution contract ID"),
443
- });
444
- if (result.contract.status !== "approved")
445
- throw new Error("execution contract is not approved");
456
+ contract_id: contractId,
457
+ }, signal);
458
+ if (result?.contract?.status !== "approved")
459
+ throw new GatewayToolError("execution contract is not approved", 'work_execution_contract_show', 'GATEWAY_TOOL_REJECTED');
446
460
  const payload = result.contract.contract_payload;
447
461
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
448
- throw new Error("Gateway response is missing contract payload");
449
- const storedFingerprint = requiredString(result.contract.content_fingerprint, "contract fingerprint");
462
+ throw new GatewayToolError("Gateway response is missing contract payload", 'work_execution_contract_show', 'GATEWAY_INVALID_TOOL_RESULT');
463
+ const storedFingerprint = result.contract.content_fingerprint;
464
+ if (typeof storedFingerprint !== 'string' || !storedFingerprint)
465
+ throw new GatewayToolError('Gateway response is missing contract fingerprint', 'work_execution_contract_show', 'GATEWAY_INVALID_TOOL_RESULT');
450
466
  if (storedFingerprint !== input.contractFingerprint)
451
- throw new Error("claimed contract fingerprint differs from approved contract");
467
+ throw new GatewayToolError("claimed contract fingerprint differs from approved contract", 'work_execution_contract_show', 'GATEWAY_INVALID_TOOL_RESULT');
452
468
  return { contractFingerprint: storedFingerprint, taskPackageFingerprint: input.taskPackageFingerprint, contractPayload: payload };
453
469
  }
454
- async createLocalJobBinding(input) {
470
+ async createLocalJobBinding(input, signal) {
455
471
  const clientSessionReference = uuidv5(`local-agent-job:${input.jobId}:${input.generation}`, uuidv5.URL);
456
472
  const result = await this.callTool("local_agent_job_binding_create", {
457
473
  job_id: input.jobId,
@@ -460,9 +476,10 @@ export class McpGatewayClient {
460
476
  launch_grant: input.launchGrant,
461
477
  runner_id: this.runnerId,
462
478
  client_session_reference: clientSessionReference,
463
- });
479
+ }, signal);
464
480
  const bindingId = requiredString(result.binding.id, "binding ID");
465
481
  const iterationId = requiredString(result.binding.iteration_id, "iteration ID");
482
+ signal?.throwIfAborted();
466
483
  this.context = {
467
484
  workLoopId: requiredString(result.work_loop_id, "Work Loop ID"),
468
485
  leaseToken: requiredString(result.lease_token, "Work Loop lease token"),
@@ -480,6 +497,87 @@ export class McpGatewayClient {
480
497
  resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
481
498
  };
482
499
  }
500
+ /** Account for the server-authored verification event before appending a
501
+ * terminal event. Never infer a cursor from model output or a local counter. */
502
+ async syncLocalPublicationCursor(bindingId, afterSequence) {
503
+ const page = await this.callTool('agent_runtime_events_read', {
504
+ binding_id: bindingId, after_sequence: afterSequence, limit: 100,
505
+ });
506
+ const events = page.events ?? [];
507
+ if (page.terminal || events.length === 0 || events.length >= 100)
508
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
509
+ let cursor = afterSequence, verified = false;
510
+ for (const event of events) {
511
+ if (!Number.isSafeInteger(event.sequence) || event.sequence !== cursor + 1 || isTerminalEventType(String(event.event_type)))
512
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
513
+ cursor = event.sequence;
514
+ const proof = event.payload?.local_publication;
515
+ if (event.event_type === 'test_result' && event.stage === 'local_publication_verification' && typeof proof?.gateway_mac === 'string' && /^[a-f0-9]{64}$/.test(proof.gateway_mac))
516
+ verified = true;
517
+ }
518
+ if (!verified)
519
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
520
+ return cursor;
521
+ }
522
+ /** Host-only publication envelope. This client is never a model MCP relay. */
523
+ localPublicationFence(input) {
524
+ return { job_id: input.jobId, generation: input.generation, lease_token: input.leaseToken,
525
+ launch_grant: input.launchGrant, binding_id: input.bindingId, runner_id: this.runnerId };
526
+ }
527
+ exportLocalPublicationRecoveryContext(bindingId) {
528
+ const c = this.requireContext(bindingId);
529
+ return structuredClone({ ...c, bundle: { contract: { content_fingerprint: requiredString(nested(c.bundle, 'contract.content_fingerprint'), 'contract fingerprint') } } });
530
+ }
531
+ async restoreLocalPublicationRecoveryContext(input, stored, terminalReplay = false) {
532
+ if (terminalReplay) {
533
+ if (stored.bindingId !== input.bindingId || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
534
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
535
+ this.context = structuredClone(stored);
536
+ return;
537
+ }
538
+ const current = (await this.localPublicationContext(input)).context;
539
+ if (stored.bindingId !== input.bindingId || stored.workLoopId !== current.work_loop_id || nested(stored.bundle, 'contract.content_fingerprint') !== current.contract_fingerprint || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
540
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
541
+ this.context = structuredClone(stored);
542
+ }
543
+ async localPublicationContext(input) {
544
+ return this.callTool('local_agent_publication_context', this.localPublicationFence(input));
545
+ }
546
+ async executeServerCiPublication(input) {
547
+ const body = { ...this.localPublicationFence(input), operation: input.operation };
548
+ if (input.operation === 'stage') {
549
+ const receipt = { ...input.securityReceipt };
550
+ if (receipt.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(receipt.receipt_version))) {
551
+ delete receipt.preparation_timing;
552
+ delete receipt.launch_process;
553
+ }
554
+ Object.assign(body, { files: input.files, security_receipt: receipt, host_receipt: input.hostReceipt });
555
+ }
556
+ else
557
+ Object.assign(body, { publication_id: input.publicationId, ...(input.canonicalApprovalId ? { canonical_approval_id: input.canonicalApprovalId } : {}) });
558
+ return this.callTool('local_agent_server_publication', body);
559
+ }
560
+ async executeLocalPublicationPr(input) {
561
+ return this.callTool('local_agent_publication_execute_pr', { ...this.localPublicationFence(input), publication_id: input.publicationId, signed_request: input.signedRequest, ...(input.canonicalApprovalId ? { canonical_approval_id: input.canonicalApprovalId } : {}) });
562
+ }
563
+ async authorizeLocalPublication(input) {
564
+ const receipt = { ...input.securityReceipt };
565
+ if (receipt.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(receipt.receipt_version))) {
566
+ delete receipt.preparation_timing;
567
+ delete receipt.launch_process;
568
+ }
569
+ return this.callTool('local_agent_publication_authorize', { ...this.localPublicationFence(input),
570
+ candidate: input.candidate, test_receipts: input.testReceipts, security_receipt: receipt, host_receipt: input.hostReceipt });
571
+ }
572
+ async issueLocalPublicationAction(input) {
573
+ return this.callTool('local_agent_publication_issue_action', { ...this.localPublicationFence(input),
574
+ publication_id: input.publicationId, action: input.action, commit_message: input.commitMessage,
575
+ pull_request_title: input.pullRequestTitle, pull_request_body: input.pullRequestBody });
576
+ }
577
+ async acceptLocalPublicationResult(input) {
578
+ return this.callTool('local_agent_publication_accept_result', { ...this.localPublicationFence(input),
579
+ publication_id: input.publicationId, action: input.action, result: input.result });
580
+ }
483
581
  async completeLocalJob(input) {
484
582
  let result = input.result;
485
583
  const receipt = result.security_receipt;
@@ -502,13 +600,13 @@ export class McpGatewayClient {
502
600
  result,
503
601
  });
504
602
  }
505
- async heartbeatLocalJob(input) {
603
+ async heartbeatLocalJob(input, signal) {
506
604
  return this.callTool("local_agent_job_heartbeat", {
507
605
  job_id: input.jobId,
508
606
  generation: input.generation,
509
607
  lease_token: input.leaseToken,
510
608
  runner_id: this.runnerId,
511
- });
609
+ }, signal);
512
610
  }
513
611
  requireContext(bindingId) {
514
612
  if (!this.context || this.context.bindingId !== bindingId) {