mtech-ai-local-runner 0.1.72 → 0.1.74

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 (33) hide show
  1. package/README.md +28 -0
  2. package/dist/src/adapters/claude-code/prompt-compiler.js +3 -1
  3. package/dist/src/adapters/codex/exec-invocation.js +24 -0
  4. package/dist/src/adapters/codex/exec-launch-host.js +5 -0
  5. package/dist/src/adapters/codex/exec-runtime-options.js +1 -1
  6. package/dist/src/adapters/codex/prompt-compiler.js +3 -1
  7. package/dist/src/asks/runtime.js +22 -1
  8. package/dist/src/cli.js +4 -0
  9. package/dist/src/commands/fix.js +106 -26
  10. package/dist/src/daemon/execution-coordinator.js +65 -9
  11. package/dist/src/daemon/fix-consent.js +98 -0
  12. package/dist/src/daemon/manual-codex.js +2 -2
  13. package/dist/src/daemon/runtime.js +31 -5
  14. package/dist/src/gateway-client.js +88 -5
  15. package/dist/src/protocol.js +33 -0
  16. package/dist/src/publication/server-ci-host.js +5 -6
  17. package/dist/src/rooms/activity.js +84 -0
  18. package/dist/src/rooms/notifier.js +21 -0
  19. package/dist/src/rooms/store.js +56 -0
  20. package/dist/src/safety/claude-managed-scope.js +7 -19
  21. package/dist/src/safety/claude-release.js +0 -2
  22. package/dist/src/safety/claude-review-evidence.js +2 -2
  23. package/dist/src/safety/codex-exec-production-host.js +15 -2
  24. package/dist/src/safety/job-tool-policy.js +4 -3
  25. package/dist/src/transport/sync-dispatch.js +9 -0
  26. package/dist/src/verification/continuation-runner-contract.js +228 -0
  27. package/dist/src/verification/p1-execution-host.js +25 -6
  28. package/dist/src/verification/p1-host-composition.js +2 -0
  29. package/dist/src/verification/reviewed-runner-contract.js +7 -0
  30. package/dist/src/verification/runner-observed-verifier.js +29 -5
  31. package/dist/src/workspace/registered-submodules.js +204 -0
  32. package/dist/src/workspace/workspace-manager.js +128 -18
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 0.1.74: exact-parent reviewed correction attempts (P3D)
2
+
3
+ This local source supports newly approved executable V3 launches behind the
4
+ Gateway's default-off `correction_execution_enabled` switch. It does not activate
5
+ an installed runner or upgrade older V1/V2/unavailable-V3 approvals.
6
+
7
+ Authenticated claim context arrives before checkout. The approved task package,
8
+ canonical repository lease and binding retain ROOT identity; each attempt checks
9
+ out its exact Gateway-reviewed parent P and emits fresh context-bound receipts,
10
+ snapshot and handoff. The original approval fingerprint differs from a successor
11
+ job's reservation fingerprint and remains preserved through at most three attempts.
12
+ Native launch seals the exact context and binding in the host-owned immutable
13
+ plan, measures P immediately before dispatch, and keeps existing ROOT security
14
+ receipt semantics. A replayed binding or uncertain model outcome pauses.
15
+
16
+ A measured trusted host registration supplies registered submodule providers.
17
+ The runner fetches exact P gitlinks through owned mirrors and refuses unknown
18
+ providers, metadata redirects and nested P-child gitlinks. No profile is installed
19
+ by these source changes. Correction findings are bounded untrusted data scoped to
20
+ the canonical bundle paths; they cannot expand authority or become commands.
21
+
22
+ Local fake transport/provider/process tests are distinct from real CI or model
23
+ acceptance. A final release commit/version, package manifest/hash and compatible
24
+ reviewed host registration remain pending delivery approval. See the
25
+ [P3D-4 review](../../docs/superpowers/reviews/2026-10-30-local-runner-p3d4-implementation-review.md)
26
+ for verification and compatibility limitations. No setup/update/restart is needed
27
+ to inspect this source tree.
28
+
1
29
  ## 0.1.60: answers go back to the session that asked
2
30
 
3
31
  An answer to a colleague ask now goes first to the session that made the ask, when the asking AI passed its client
@@ -1,3 +1,4 @@
1
+ import { renderBundleCorrectionBrief } from '../../verification/continuation-runner-contract.js';
1
2
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
3
  import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
3
4
  // Claude's working directory is the job's private control directory, so without these rules it sends that directory's
@@ -16,5 +17,6 @@ const CLAUDE_PATH_RULES = "## Local tool path rules\n" +
16
17
  "command: send a corrected call instead of concluding that source is unavailable. LOCAL_TOOL_FAILED means the call " +
17
18
  "failed for another reason, such as a file that does not exist.";
18
19
  export function compilePrompt(taskPackage, options) {
19
- return `${compileBrief(taskPackage, options)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${CLAUDE_PATH_RULES}`;
20
+ const correction = renderBundleCorrectionBrief(taskPackage);
21
+ return `${compileBrief(taskPackage, options)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${CLAUDE_PATH_RULES}${correction}`;
20
22
  }
@@ -1,3 +1,4 @@
1
+ import { validateAttemptContext, attemptContextFingerprint } from '../../verification/continuation-runner-contract.js';
1
2
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
2
3
  import path from 'node:path';
3
4
  import { CODEX_GLOBAL_INSTRUCTION_WRAPPER, globalInstructionProfile } from './global-instructions.js';
@@ -10,10 +11,18 @@ export const EXEC_MODE = 'supported_cli_exec_policy_v1';
10
11
  export const isReleaseExecMode = (mode) => mode === EXEC_RELEASE_MODE || mode === UNMANAGED_EXEC_MODE;
11
12
  const plans = new WeakMap();
12
13
  const consumed = new WeakSet();
14
+ const executionAttempts = new WeakMap();
13
15
  const fail = () => new Error('CODEX_EXEC_PLAN_REJECTED');
14
16
  export function buildOfficialExecPlan(input) {
15
17
  try {
16
18
  const i = structuredClone(input.identity), inv = structuredClone(input.invocation);
19
+ const attempt = input.executionAttempt === undefined ? undefined : validateAttemptContext(input.executionAttempt);
20
+ if ((attempt === undefined) !== (input.executionAttemptFingerprint === undefined) || (attempt === undefined) !== (input.executionBindingId === undefined))
21
+ throw fail();
22
+ if (attempt && (typeof input.executionBindingId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(input.executionBindingId)))
23
+ throw fail();
24
+ if (attempt && (attemptContextFingerprint(attempt) !== input.executionAttemptFingerprint || attempt.job_id !== i.jobId || attempt.generation !== i.generation || attempt.contract_base_commit_sha !== i.sourceSha || attempt.contract_fingerprint !== i.contractFingerprint || (attempt.attempt_number === 1 && attempt.launch_approval_fingerprint !== i.launchFingerprint)))
25
+ throw fail();
17
26
  if (input.policyMode !== undefined && !isReleaseExecMode(input.policyMode))
18
27
  throw fail();
19
28
  const keys = ['jobId', 'generation', 'actorId', 'runnerId', 'sourceSha', 'binarySha256', 'releaseManifestFingerprint', 'requirementsFingerprint', 'contractFingerprint', 'taskPackageFingerprint', 'launchFingerprint', 'commandsFingerprint', 'toolSchemaFingerprint', 'barrierFingerprint'];
@@ -36,6 +45,8 @@ export function buildOfficialExecPlan(input) {
36
45
  const actual = isReleaseExecMode(input.policyMode) ? { binary: CODEX_GLOBAL_INSTRUCTION_WRAPPER, args: ['-p', globalInstructionProfile(accountRoot), input.binary, ...generated.args], env: generated.env } : { binary: input.binary, ...generated };
37
46
  const executionNonce = randomBytes(32).toString('hex');
38
47
  const plan = Object.freeze({ transport: EXEC_TRANSPORT, mode: input.policyMode ?? EXEC_MODE, executionNonce, identity: Object.freeze(i), modelId: inv.modelId, home: inv.env.HOME, accountRoot, credentialBackend: input.credentialBackend, binary: input.binary, cwd, endpoint: inv.endpoint.url, invocationFingerprint: hash({ ...actual }) });
48
+ if (attempt)
49
+ executionAttempts.set(plan, { fingerprint: input.executionAttemptFingerprint, bindingId: input.executionBindingId, sourceRoot: source });
39
50
  plans.set(plan, structuredClone(actual));
40
51
  return plan;
41
52
  }
@@ -60,3 +71,16 @@ export function assertOfficialExecPlanAvailable(plan) {
60
71
  if (consumed.has(plan))
61
72
  throw fail();
62
73
  }
74
+ /** Private plan seal is execution identity, separate from legacy ROOT approval
75
+ * and security receipt identity. Native host still verifies Gateway authority. */
76
+ export function assertOfficialExecAttempt(plan, context, fingerprint, bindingId, sourceRoot) {
77
+ requireOfficialExecPlan(plan);
78
+ const expected = executionAttempts.get(plan);
79
+ if (expected === undefined) {
80
+ if (context !== undefined || fingerprint !== undefined)
81
+ throw fail();
82
+ return;
83
+ }
84
+ if (context === undefined || fingerprint !== expected.fingerprint || attemptContextFingerprint(context) !== expected.fingerprint || bindingId !== expected.bindingId || sourceRoot !== undefined && path.resolve(sourceRoot) !== expected.sourceRoot)
85
+ throw fail();
86
+ }
@@ -1,3 +1,4 @@
1
+ import { assertOfficialExecAttempt } from './exec-invocation.js';
1
2
  import { performance } from 'node:perf_hooks';
2
3
  import { PreparationClock, CODEX_PREPARATION_BUDGET_MS } from '../../safety/preparation-timing.js';
3
4
  import { localEndpointObservations, localEndpointReadObservations } from '../../safety/local-tool-server.js';
@@ -42,6 +43,7 @@ export class CodexExecLaunchHost {
42
43
  throw fail();
43
44
  } }
44
45
  assertContext(ctx, jobId) {
46
+ assertOfficialExecAttempt(this.plan, ctx.executionAttempt, ctx.executionAttemptFingerprint, ctx.bindingId);
45
47
  const a = ctx.approvedTaskPackage, r = a?.security_requirements, b = ctx.taskPackage, execution = b?.execution;
46
48
  if (isReleaseExecMode(this.plan.mode)) {
47
49
  if (!r)
@@ -63,6 +65,7 @@ export class CodexExecLaunchHost {
63
65
  async start(ctx, jobId, prompt, signal) {
64
66
  if (this.used || this.stopped)
65
67
  throw fail();
68
+ ctx = structuredClone(ctx);
66
69
  this.assertAvailableContext(ctx, jobId);
67
70
  claimOfficialExecPlan(this.plan);
68
71
  this.ownsAttempt = true;
@@ -114,6 +117,8 @@ export class CodexExecLaunchHost {
114
117
  this.inspection.beforeSpawn?.();
115
118
  timing.mark('before_spawn_complete');
116
119
  assertPreparationBudget();
120
+ this.current();
121
+ this.assertContext(ctx, jobId);
117
122
  stage = 'codex_spawn';
118
123
  timing.mark('spawn_requested');
119
124
  const h = this.deps.processHost.spawn(invocation.binary, invocation.args, { cwd: this.plan.cwd, env: invocation.env });
@@ -99,7 +99,7 @@ export function buildCodexExecJobOptions(input) {
99
99
  if (closed || signal.aborted)
100
100
  throw reject();
101
101
  stage = 'codex_invocation';
102
- const plan = buildOfficialExecPlan({ policyMode: admitted.release.securityMode, binary: admitted.binary, credentialBackend: admitted.release.credentialBackend, identity: { jobId: input.jobId, generation: ctx.generation, actorId: admitted.actorId, runnerId: admitted.runnerId, sourceSha: String(approved.repository.base_commit_sha), binarySha256: admitted.release.binarySha256, releaseManifestFingerprint: String(requirements.release_manifest_sha256), requirementsFingerprint: String(requirements.fingerprint), contractFingerprint: ctx.securityBinding.contractFingerprint, taskPackageFingerprint: ctx.securityBinding.taskPackageFingerprint, launchFingerprint: ctx.securityBinding.launchFingerprint, commandsFingerprint: commandRegistryFingerprint(admitted.release.commands), toolSchemaFingerprint: fingerprint({ tools: localToolInventory() }), barrierFingerprint: admitted.release.fixturesFingerprint }, invocation: { modelId: ctx.resolvedModelId, worktreeRoot: cwd, sourceRoot: source, schemaPath: schema, endpoint, env, scratch } });
102
+ const plan = buildOfficialExecPlan({ executionAttempt: ctx.executionAttempt, executionAttemptFingerprint: ctx.executionAttemptFingerprint, ...(ctx.executionAttempt ? { executionBindingId: ctx.bindingId } : {}), policyMode: admitted.release.securityMode, binary: admitted.binary, credentialBackend: admitted.release.credentialBackend, identity: { jobId: input.jobId, generation: ctx.generation, actorId: admitted.actorId, runnerId: admitted.runnerId, sourceSha: String(approved.repository.base_commit_sha), binarySha256: admitted.release.binarySha256, releaseManifestFingerprint: String(requirements.release_manifest_sha256), requirementsFingerprint: String(requirements.fingerprint), contractFingerprint: ctx.securityBinding.contractFingerprint, taskPackageFingerprint: ctx.securityBinding.taskPackageFingerprint, launchFingerprint: ctx.securityBinding.launchFingerprint, commandsFingerprint: commandRegistryFingerprint(admitted.release.commands), toolSchemaFingerprint: fingerprint({ tools: localToolInventory() }), barrierFingerprint: admitted.release.fixturesFingerprint }, invocation: { modelId: ctx.resolvedModelId, worktreeRoot: cwd, sourceRoot: source, schemaPath: schema, endpoint, env, scratch } });
103
103
  gate = new ExecToolGate(plan);
104
104
  const host = new CodexExecLaunchHost(plan, { processHost, inspectRelease: p => job.inspect(p), execTools: gate, revokeTools: () => { gate?.revoke(); server?.revoke(); policy?.revoke(); job?.revoke(); }, closeTools: dispose });
105
105
  launchHost = host;
@@ -1,7 +1,9 @@
1
+ import { renderBundleCorrectionBrief } from '../../verification/continuation-runner-contract.js';
1
2
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
3
  import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
3
4
  export function compileCodexPrompt(taskPackage, label) {
4
- return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}`;
5
+ const correction = renderBundleCorrectionBrief(taskPackage);
6
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}${correction}`;
5
7
  }
6
8
  export const CODEX_REPORT_SCHEMA = {
7
9
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -21,6 +21,25 @@ const SHUTDOWN_WAIT_MS = 5000;
21
21
  * would end. Bounded, so a heartbeat, and the job claim behind it, is never held up longer than this.
22
22
  */
23
23
  export const ADVERTISEMENT_START_WAIT_MS = 2000;
24
+ /** Room notices use only a human prompt already arriving at the Ask hub; they never wake a model. */
25
+ export function withRoomNotice(handler, claim) {
26
+ return {
27
+ status: () => handler.status(),
28
+ watch: (request, watcher) => handler.watch(request, watcher),
29
+ event: async (request, client) => {
30
+ const text = await handler.event(request, client);
31
+ if (request.event !== 'prompt' || request.human !== true || client.gone)
32
+ return text;
33
+ try {
34
+ const notice = await claim();
35
+ return notice === null ? text : text === null ? notice : `${text}\n${notice}`;
36
+ }
37
+ catch {
38
+ return text;
39
+ }
40
+ },
41
+ };
42
+ }
24
43
  function errorCode(error) {
25
44
  const code = error?.code;
26
45
  if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
@@ -51,7 +70,9 @@ export class AskRuntime {
51
70
  openSession: deps.openSession ?? createSessionOpener({ home: deps.env.HOME ?? "" }),
52
71
  presenceRecheckMs: deps.presenceRecheckMs,
53
72
  onChange: () => this.flushSoon(), log: event => this.log(event) });
54
- this.hub = new AskHub({ socketPath: hubSocketPath(deps.stateRoot), handler: this.inbox, log: event => this.log(event) });
73
+ this.hub = new AskHub({ socketPath: hubSocketPath(deps.stateRoot),
74
+ handler: deps.claimRoomNotice ? withRoomNotice(this.inbox, deps.claimRoomNotice) : this.inbox,
75
+ log: event => this.log(event) });
55
76
  }
56
77
  /** True while the hub listens; only then are deliveries taken and ask_inbox advertised. */
57
78
  get ready() {
package/dist/src/cli.js CHANGED
@@ -518,5 +518,9 @@ export function isDirectCliInvocation(moduleUrl, argvPath) {
518
518
  if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
519
519
  main(process.argv.slice(2), process.env).then((code) => {
520
520
  process.exitCode = code;
521
+ // A daemon that stopped (to apply Fix consent, decision baf0cd89) must leave so launchd starts a fresh one.
522
+ // The unref'd timer only fires if a socket or timer still holds the event loop five seconds later.
523
+ if (process.argv[2] === "run")
524
+ setTimeout(() => process.exit(code), 5000).unref();
521
525
  });
522
526
  }
@@ -25,19 +25,108 @@ function flags(argv) {
25
25
  return values;
26
26
  }
27
27
  /** A job owns the host until it ends: never replace or restart under it. */
28
- async function idle(io) {
28
+ export async function fixHostBusy(stateRoot) {
29
29
  for (const file of BUSY)
30
- if (await exists(path.join(io.stateRoot, file))) {
31
- io.stderr('FIX_HOST_JOB_ACTIVE: wait until the current job ends, then run this again.');
32
- return false;
33
- }
30
+ if (await exists(path.join(stateRoot, file)))
31
+ return true;
32
+ return false;
33
+ }
34
+ const JOB_ACTIVE = 'FIX_HOST_JOB_ACTIVE: wait until the current job ends, then run this again.';
35
+ async function idle(io) {
36
+ if (await fixHostBusy(io.stateRoot)) {
37
+ io.stderr(JOB_ACTIVE);
38
+ return false;
39
+ }
34
40
  return true;
35
41
  }
36
- async function backup(io, file) {
37
- const dir = path.join(io.stateRoot, 'backups');
42
+ async function backup(stateRoot, file) {
43
+ const dir = path.join(stateRoot, 'backups');
38
44
  await mkdir(dir, { recursive: true, mode: 0o700 });
39
45
  await copyFile(file, path.join(dir, `local-fix-host-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
40
46
  }
47
+ /** The existing host, when it is a v3 host this runner would load; anything else is replaced whole. */
48
+ async function currentConsentHost(file, runnerId) {
49
+ let raw;
50
+ try {
51
+ const s = await lstat(file);
52
+ if (!s.isFile() || s.isSymbolicLink() || s.size > 128 * 1024)
53
+ return undefined;
54
+ raw = JSON.parse(await readFile(file, 'utf8'));
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join() !== 'consent,enabled,git,runnerId,schema')
60
+ return undefined;
61
+ if (raw.schema !== 'local-fix-host-installation-v3' || raw.enabled !== true || raw.runnerId !== runnerId)
62
+ return undefined;
63
+ const git = raw.git;
64
+ if (!git || typeof git !== 'object' || Array.isArray(git) || Object.keys(git).sort().join() !== 'executable,sha256' || typeof git.executable !== 'string' || typeof git.sha256 !== 'string')
65
+ return undefined;
66
+ try {
67
+ assertLocalFixConsent(raw.consent);
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ return raw;
73
+ }
74
+ const union = (first, second) => [...first, ...second.filter(value => !first.includes(value))];
75
+ /**
76
+ * The one writer of local-fix-host.json consent, shared by `fix enable` (replace) and the daemon applying a Slack
77
+ * grant (extend; Work Item 6f9e64ae, decision baf0cd89). Refuses while a job or recovery is pending. Extend merges
78
+ * into a v3 host this runner would load and keeps its pinned Git; any other file is backed up and replaced by a
79
+ * fresh v3 host with newly pinned Git, as `fix enable` writes. Writes are owner-only and atomic; a replaced file is
80
+ * kept under backups/. Restarting the runner is the caller's job.
81
+ */
82
+ export async function applyFixConsent(input) {
83
+ const requested = { repository_keys: [...input.repositoryKeys], verification_modes: [...input.verificationModes] };
84
+ try {
85
+ assertLocalFixConsent(requested);
86
+ }
87
+ catch {
88
+ return { ok: false, code: 'FIX_CONSENT_INVALID' };
89
+ }
90
+ if (await fixHostBusy(input.stateRoot))
91
+ return { ok: false, code: 'FIX_HOST_JOB_ACTIVE' };
92
+ const file = path.join(input.stateRoot, HOST_FILE);
93
+ const existing = input.mode === 'extend' ? await currentConsentHost(file, input.runnerId) : undefined;
94
+ let host;
95
+ if (existing) {
96
+ const consent = { repository_keys: union(existing.consent.repository_keys, requested.repository_keys), verification_modes: union(existing.consent.verification_modes, requested.verification_modes) };
97
+ try {
98
+ assertLocalFixConsent(consent);
99
+ }
100
+ catch {
101
+ return { ok: false, code: 'FIX_CONSENT_INVALID' };
102
+ }
103
+ if (consent.repository_keys.length === existing.consent.repository_keys.length && consent.verification_modes.length === existing.consent.verification_modes.length)
104
+ return { ok: true, changed: false, consent: existing.consent };
105
+ host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git: { executable: existing.git.executable, sha256: existing.git.sha256 }, consent };
106
+ }
107
+ else {
108
+ const executable = await realpath(input.git ?? await (input.findGit ?? defaultGit)());
109
+ const git = { executable, sha256: await measureCommandFile(executable) };
110
+ await assertPinnedGit(git);
111
+ host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git, consent: requested };
112
+ }
113
+ const privateRoot = path.join(input.stateRoot, 'local-fix-publication');
114
+ if (!await exists(privateRoot))
115
+ await mkdir(privateRoot, { mode: 0o700 });
116
+ const s = await lstat(privateRoot);
117
+ if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0)
118
+ return { ok: false, code: 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE', privateRoot };
119
+ const serialized = JSON.stringify(host, null, 1);
120
+ let previous;
121
+ if (await exists(file)) {
122
+ previous = await readFile(file, 'utf8').catch(() => undefined);
123
+ await backup(input.stateRoot, file);
124
+ }
125
+ const temporary = path.join(input.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
126
+ await writeFile(temporary, serialized, { mode: 0o600, flag: 'wx' });
127
+ await rename(temporary, file);
128
+ return { ok: true, changed: previous !== serialized, consent: host.consent };
129
+ }
41
130
  async function restart(io, summary) {
42
131
  if (await io.serviceLoaded()) {
43
132
  await io.restartService();
@@ -77,7 +166,7 @@ export async function runFixCommand(argv, io) {
77
166
  io.stdout(JSON.stringify({ ok: true, removed: false }));
78
167
  return 0;
79
168
  }
80
- await backup(io, file);
169
+ await backup(io.stateRoot, file);
81
170
  await rm(file);
82
171
  return restart(io, { ok: true, removed: true });
83
172
  }
@@ -103,24 +192,15 @@ export async function runFixCommand(argv, io) {
103
192
  io.stderr('MESSENGER_MODE_HAS_NO_EXECUTOR: log in to Claude Code or Codex, then rerun npx mtech-ai-local-runner@latest setup before fix enable.');
104
193
  return 1;
105
194
  }
106
- if (!await idle(io))
107
- return 1;
108
- const executable = await realpath(values.get('git') ?? await (io.findGit ?? defaultGit)());
109
- const git = { executable, sha256: await measureCommandFile(executable) };
110
- await assertPinnedGit(git);
111
- const privateRoot = path.join(io.stateRoot, 'local-fix-publication');
112
- if (!await exists(privateRoot))
113
- await mkdir(privateRoot, { mode: 0o700 });
114
- const s = await lstat(privateRoot);
115
- if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0) {
116
- io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + privateRoot + ' must be a 0700 directory owned by this user.');
195
+ const applied = await applyFixConsent({ stateRoot: io.stateRoot, runnerId: local.runnerId, repositoryKeys: consent.repository_keys, verificationModes: consent.verification_modes, mode: 'replace', git: values.get('git'), findGit: io.findGit });
196
+ if (!applied.ok) {
197
+ if (applied.code === 'FIX_HOST_JOB_ACTIVE')
198
+ io.stderr(JOB_ACTIVE);
199
+ else if (applied.code === 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE')
200
+ io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + applied.privateRoot + ' must be a 0700 directory owned by this user.');
201
+ else
202
+ io.stderr('FIX_CONSENT_INVALID: give registered repository keys (lowercase, comma separated, no repeats) and modes gateway_publish_v1 or gateway_ci_v1.');
117
203
  return 1;
118
204
  }
119
- const host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: local.runnerId, git, consent };
120
- if (await exists(file))
121
- await backup(io, file);
122
- const temporary = path.join(io.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
123
- await writeFile(temporary, JSON.stringify(host, null, 1), { mode: 0o600, flag: 'wx' });
124
- await rename(temporary, file);
125
- return restart(io, { ok: true, repository_keys: consent.repository_keys, verification_modes: consent.verification_modes });
205
+ return restart(io, { ok: true, repository_keys: applied.consent.repository_keys, verification_modes: applied.consent.verification_modes });
126
206
  }
@@ -1,3 +1,4 @@
1
+ import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt } from '../verification/continuation-runner-contract.js';
1
2
  import { completionRejectionDiagnostic } from '../adapters/completion-report.js';
2
3
  import { publicationError } from '../publication/failure.js';
3
4
  import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
@@ -9,7 +10,7 @@ import { requireSecurityRequirements } from '../safety/security-requirements.js'
9
10
  import { GatewayToolError } from "../gateway-client.js";
10
11
  import { createHash } from "node:crypto";
11
12
  import { completionReportDiagnostic } from "../adapters/completion-report.js";
12
- import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
13
+ import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, validateAttemptEvidence, validateAttemptHandoff, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
13
14
  import { DAEMON_BLOCKED_REASONS, DAEMON_CANCELLED_REASONS } from "../state-store.js";
14
15
  import { PreparationDeadlineError, PreparationLease, validateJobLease } from './preparation-lease.js';
15
16
  function canonical(value) {
@@ -66,8 +67,24 @@ export class ExecutionCoordinator {
66
67
  constructor(deps) {
67
68
  this.deps = deps;
68
69
  }
69
- async execute(job) {
70
- job = { ...job, taskPackage: immutable(structuredClone(job.taskPackage)) };
70
+ active;
71
+ execute(job) {
72
+ job = immutable(structuredClone(job));
73
+ const key = `${job.id}:${job.generation}`;
74
+ if (this.active) {
75
+ if (this.active.key !== key)
76
+ return Promise.reject(Error('EXECUTION_ALREADY_ACTIVE'));
77
+ if (this.active.identity !== fingerprint(job))
78
+ return Promise.reject(Error('EXECUTION_CLAIM_MISMATCH'));
79
+ return this.active.promise;
80
+ }
81
+ const promise = Promise.resolve().then(() => this.executeOwned(job));
82
+ this.active = { key, identity: fingerprint(job), promise };
83
+ void promise.finally(() => { if (this.active?.promise === promise)
84
+ this.active = undefined; }).catch(() => { });
85
+ return promise;
86
+ }
87
+ async executeOwned(job) {
71
88
  let stage = "package";
72
89
  let workspace;
73
90
  let adapter;
@@ -77,8 +94,9 @@ export class ExecutionCoordinator {
77
94
  const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
78
95
  const isP1 = hasP1(job.taskPackage);
79
96
  const envelope = job.taskPackage.verification;
80
- const runnerBinding = envelope?.schema_version === 'local-runner-verification-v2' ? envelope.runner_binding : envelope;
97
+ const runnerBinding = ['local-runner-verification-v2', 'local-runner-verification-v3'].includes(String(envelope?.schema_version)) ? envelope?.runner_binding : envelope;
81
98
  const isP2 = isP1 && runnerBinding?.gateway_verification !== undefined;
99
+ let executionAttempt;
82
100
  let p1Result;
83
101
  let p2Handoff;
84
102
  const p1ReceiptIds = [];
@@ -89,6 +107,19 @@ export class ExecutionCoordinator {
89
107
  const step = (operation) => preparation.step(operation);
90
108
  let bindingAttempted = false;
91
109
  try {
110
+ try {
111
+ executionAttempt = assertClaimedExecutionAttempt(job);
112
+ }
113
+ catch {
114
+ throw new GatewayToolError('Continuation execution is unavailable', 'local_agent_jobs_wait', 'CORRECTION_EXECUTION_UNAVAILABLE');
115
+ }
116
+ // Trusted host admission and measurement precede checkout. Package/schema/hash alone is never authority.
117
+ if (executionAttempt) {
118
+ if (!this.deps.p1Host?.assertCurrent || !this.deps.gateway.readCommands || !this.deps.gateway.heartbeat)
119
+ throw Error('CORRECTION_HOST_INCOMPATIBLE');
120
+ this.deps.p1Host.assertEligible(job);
121
+ await step(() => this.deps.p1Host.assertCurrent(job));
122
+ }
92
123
  await preparation.start();
93
124
  const pkg = job.taskPackage;
94
125
  if (isP1) {
@@ -116,8 +147,9 @@ export class ExecutionCoordinator {
116
147
  repositoryKey: text(repository?.key, "repository key"),
117
148
  providerUrl: text(repository?.provider_url, "repository provider URL"),
118
149
  baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
119
- ...(isLocalFix || isP1 ? { workingBranch: text(repository?.working_branch, "approved fix branch") } : {}),
120
- ...(isP1 ? { p1WorkingBranch: text(repository?.working_branch, 'approved P1 branch') } : {})
150
+ ...(isLocalFix || isP1 ? { workingBranch: executionAttempt?.attempt_working_branch ?? text(repository?.working_branch, "approved fix branch") } : {}),
151
+ ...(isP1 ? { p1WorkingBranch: executionAttempt?.attempt_working_branch ?? text(repository?.working_branch, 'approved P1 branch') } : {}),
152
+ ...(executionAttempt ? { attemptContext: executionAttempt, attemptContextFingerprint: job.executionAttemptFingerprint } : {})
121
153
  }));
122
154
  stage = "adapter";
123
155
  adapter = await preparation.step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }), late => late.cancel());
@@ -137,6 +169,10 @@ export class ExecutionCoordinator {
137
169
  preparation.beginBinding();
138
170
  bindingAttempted = true;
139
171
  binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }, preparation.signal));
172
+ assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
173
+ if (executionAttempt && binding.bindingReplayed !== false)
174
+ throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
175
+ binding = immutable(structuredClone(binding));
140
176
  if (isP1)
141
177
  assertP1Bindings(pkg, binding.taskPackage, true);
142
178
  await step(() => this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest }));
@@ -474,8 +510,25 @@ export class ExecutionCoordinator {
474
510
  return session;
475
511
  }));
476
512
  }
513
+ if (executionAttempt) {
514
+ await readControls();
515
+ requireControlFence();
516
+ if (!requestedTerminal) {
517
+ const renewed = await fenced(this.deps.gateway.heartbeat(binding.bindingId, job, controlAbort.signal));
518
+ requireControlFence();
519
+ const expiry = validateJobLease(renewed, job, true);
520
+ if (!['running', 'review_pending'].includes(renewed.job.status))
521
+ throw Error('JOB_LEASE_FENCE_INVALID');
522
+ this.deps.gateway.confirmJobLease?.(job, expiry);
523
+ leaseExpiresAt = expiry;
524
+ armLeaseBoundary();
525
+ await fenced(this.deps.p1Host.assertCurrent(job));
526
+ await readControls();
527
+ requireControlFence();
528
+ }
529
+ }
477
530
  if (!requestedTerminal) {
478
- await fenced(Promise.race([adapter.start({ securityBinding: { launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint }, generation: job.generation, approvedTaskPackage: job.taskPackage, bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId }), unconfirmedStop]));
531
+ await fenced(Promise.race([adapter.start({ ...(executionAttempt ? { executionAttempt, executionAttemptFingerprint: job.executionAttemptFingerprint } : {}), securityBinding: { launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint }, generation: job.generation, approvedTaskPackage: job.taskPackage, bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId }), unconfirmedStop]));
479
532
  }
480
533
  executionStage = 'commands';
481
534
  await readControls();
@@ -508,9 +561,12 @@ export class ExecutionCoordinator {
508
561
  await freezeOwnedModel();
509
562
  await readControls();
510
563
  executionStage = 'p1_verify';
511
- p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen,
564
+ p1Result = await publicationStep(() => this.deps.p1Host.verify({ ...(executionAttempt ? { executionAttempt } : {}), job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen,
512
565
  exportCandidate: isP2 ? async (handoff) => { await readControls(); await assertModelFrozen(); if (p2Handoff)
513
- throw Error('P2_DUPLICATE_HANDOFF'); p2Handoff = structuredClone(handoff); } : undefined }));
566
+ throw Error('P2_DUPLICATE_HANDOFF'); if (executionAttempt)
567
+ validateAttemptHandoff(handoff, executionAttempt); p2Handoff = structuredClone(handoff); } : undefined }));
568
+ if (executionAttempt)
569
+ validateAttemptEvidence(p1Result, executionAttempt);
514
570
  executionStage = 'p1_receipts';
515
571
  await readControls();
516
572
  await assertModelFrozen();
@@ -0,0 +1,98 @@
1
+ import { applyFixConsent, fixHostBusy } from "../commands/fix.js";
2
+ const covers = (report, grant) => !!report && report.repository_keys.includes(grant.repositoryKey) && report.verification_modes.includes(grant.verificationMode);
3
+ export class FixConsentRuntime {
4
+ deps;
5
+ /** Grants deferred while busy. Each later sync's own list replaces them, so a grant the Gateway dropped is never applied from memory. */
6
+ deferred = [];
7
+ due = false;
8
+ stopping = false;
9
+ logged = new Set();
10
+ queue = Promise.resolve();
11
+ constructor(deps) {
12
+ this.deps = deps;
13
+ }
14
+ /** True once a changed host waits for the restart; new claims are held until then. */
15
+ get restartDue() { return this.due; }
16
+ /** Applies the grants of one sync when idle. The restart waits for afterSync, so the rest of that sync (its session opens) is still handled. */
17
+ handleGrants(grants) {
18
+ return this.serial(async () => { this.deferred = grants.slice(0, 20); await this.apply(); });
19
+ }
20
+ /** After every sync: drops deferred grants the sync no longer lists, and restarts once a changed host is due and the runner is idle. */
21
+ afterSync(result) {
22
+ return this.serial(async () => {
23
+ if (!Array.isArray(result.fixConsentGrants) || result.fixConsentGrants.length === 0)
24
+ this.deferred = [];
25
+ await this.apply();
26
+ await this.restartWhenIdle();
27
+ });
28
+ }
29
+ serial(task) {
30
+ const next = this.queue.then(task, task);
31
+ this.queue = next.catch(() => undefined);
32
+ return next;
33
+ }
34
+ async busy() {
35
+ return this.deps.jobRunning() || await fixHostBusy(this.deps.stateRoot);
36
+ }
37
+ once(key, event) {
38
+ if (this.logged.has(key))
39
+ return;
40
+ this.logged.add(key);
41
+ this.deps.log(event);
42
+ }
43
+ async apply() {
44
+ if (this.stopping)
45
+ return;
46
+ const pending = this.deferred.filter(grant => !covers(this.deps.report(), grant));
47
+ if (pending.length > 0 && await this.busy()) {
48
+ this.deferred = pending;
49
+ for (const grant of pending)
50
+ this.once(`deferred:${grant.id}`, { event: "fix_consent_deferred", grant_id: grant.id, repository_key: grant.repositoryKey, reason: "job_active" });
51
+ return;
52
+ }
53
+ this.deferred = [];
54
+ const applied = [];
55
+ for (const grant of pending) {
56
+ let result;
57
+ try {
58
+ result = await applyFixConsent({ stateRoot: this.deps.stateRoot, runnerId: this.deps.runnerId,
59
+ repositoryKeys: [grant.repositoryKey], verificationModes: [grant.verificationMode], mode: "extend" });
60
+ }
61
+ catch {
62
+ // Git could not be pinned or the file could not be written; the error text may name local paths, so only the code is logged.
63
+ result = { ok: false, code: "FIX_CONSENT_APPLY_FAILED" };
64
+ }
65
+ if (!result.ok && result.code === "FIX_HOST_JOB_ACTIVE") {
66
+ this.deferred.push(grant);
67
+ continue;
68
+ }
69
+ if (!result.ok) {
70
+ this.reject(grant, result.code);
71
+ continue;
72
+ }
73
+ if (result.changed) {
74
+ this.due = true;
75
+ applied.push(grant);
76
+ continue;
77
+ }
78
+ // Already on disk, yet the host loaded at start does not report it: a restart would load the same file again.
79
+ if (!this.due)
80
+ this.reject(grant, "FIX_HOST_NOT_LOADED");
81
+ }
82
+ if (applied.length > 0) {
83
+ this.deps.log({ event: "fix_consent_applied", grant_ids: applied.map(grant => grant.id),
84
+ repository_keys: [...new Set(applied.map(grant => grant.repositoryKey))], verification_modes: [...new Set(applied.map(grant => grant.verificationMode))] });
85
+ }
86
+ }
87
+ async restartWhenIdle() {
88
+ if (this.stopping || !this.due || await fixHostBusy(this.deps.stateRoot) || this.deps.jobRunning())
89
+ return;
90
+ // No await between the last check and the stop: the daemon stops its job runner first, so no claim starts after it.
91
+ this.stopping = true;
92
+ this.deps.log({ event: "runner_restarting", reason: "fix_consent" });
93
+ this.deps.restart();
94
+ }
95
+ reject(grant, code) {
96
+ this.once(`rejected:${grant.id}:${code}`, { event: "fix_consent_rejected", grant_id: grant.id, repository_key: grant.repositoryKey, code });
97
+ }
98
+ }
@@ -36,7 +36,7 @@ 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, 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 };
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, executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_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
42
  const confirmJobLease = (j, expires) => { host?.heartbeat(j.id, j.generation, expires); leaseExpires = expires; claim.job.lease_expires_at = expires; };
@@ -65,7 +65,7 @@ export async function runManualCodexJob(input, deps) {
65
65
  gateway: {
66
66
  renewJob: (j, signal) => input.gateway.heartbeatLocalJob({ jobId: j.id, generation: j.generation, leaseToken: j.leaseToken }, signal), confirmJobLease,
67
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),
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, claimedJob: j }, signal),
69
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
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
71
  async complete(value) { await input.gateway.completeLocalJob(value); result = { jobId: value.jobId, status: value.status }; }