mtech-ai-local-runner 0.1.73 → 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 (29) 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/daemon/execution-coordinator.js +65 -9
  9. package/dist/src/daemon/manual-codex.js +2 -2
  10. package/dist/src/daemon/runtime.js +20 -9
  11. package/dist/src/gateway-client.js +65 -5
  12. package/dist/src/protocol.js +33 -0
  13. package/dist/src/publication/server-ci-host.js +5 -6
  14. package/dist/src/rooms/activity.js +84 -0
  15. package/dist/src/rooms/notifier.js +21 -0
  16. package/dist/src/rooms/store.js +56 -0
  17. package/dist/src/safety/claude-managed-scope.js +7 -19
  18. package/dist/src/safety/claude-release.js +0 -2
  19. package/dist/src/safety/claude-review-evidence.js +2 -2
  20. package/dist/src/safety/codex-exec-production-host.js +15 -2
  21. package/dist/src/safety/job-tool-policy.js +4 -3
  22. package/dist/src/verification/continuation-runner-contract.js +228 -0
  23. package/dist/src/verification/p1-execution-host.js +25 -6
  24. package/dist/src/verification/p1-host-composition.js +2 -0
  25. package/dist/src/verification/reviewed-runner-contract.js +7 -0
  26. package/dist/src/verification/runner-observed-verifier.js +29 -5
  27. package/dist/src/workspace/registered-submodules.js +204 -0
  28. package/dist/src/workspace/workspace-manager.js +128 -18
  29. 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() {
@@ -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();
@@ -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 }; }
@@ -35,6 +35,8 @@ import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
35
35
  import { AskRuntime, withAsks } from '../asks/runtime.js';
36
36
  import { GuideRuntime, withGuide } from '../guide/runtime.js';
37
37
  import { FixConsentRuntime } from './fix-consent.js';
38
+ import { RoomActivityRuntime } from '../rooms/activity.js';
39
+ import { notifyRoomActivity } from '../rooms/notifier.js';
38
40
  export async function buildDaemon(input) {
39
41
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
40
42
  if (claudeParts !== 0 && claudeParts !== 3)
@@ -63,8 +65,11 @@ export async function buildDaemon(input) {
63
65
  pendingCodexClaim.job.lease_expires_at = expiresAt;
64
66
  };
65
67
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
68
+ const roomActivity = new RoomActivityRuntime({ stateRoot: input.stateRoot,
69
+ read: (cursor, limit) => gateway.readRoomActivity(cursor, limit), notify: notifyRoomActivity });
66
70
  // AI-first ask delivery (Work Item e1c08b33) rides on the session-open loop below; it starts no executor.
67
71
  const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
72
+ claimRoomNotice: () => roomActivity.claimNotice(),
68
73
  report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
69
74
  log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
70
75
  // Guide hooks (Work Item d38d60f9) ride on the same sync and heartbeat; they start no executor.
@@ -121,7 +126,7 @@ export async function buildDaemon(input) {
121
126
  taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
122
127
  createBinding: async (job, signal) => gateway.createLocalJobBinding({
123
128
  jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
124
- launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
129
+ launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
125
130
  }, signal),
126
131
  publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
127
132
  syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
@@ -145,7 +150,7 @@ export async function buildDaemon(input) {
145
150
  }
146
151
  return registry.select(id, version, options);
147
152
  } },
148
- workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
153
+ workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient(), registeredSubmodules: p1Host?.registeredSubmodules }),
149
154
  state,
150
155
  });
151
156
  let transport;
@@ -167,6 +172,7 @@ export async function buildDaemon(input) {
167
172
  launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
168
173
  gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
169
174
  leaseExpiresAt: claim.job.lease_expires_at,
175
+ executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_fingerprint,
170
176
  };
171
177
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
172
178
  return { status: "claimed", job };
@@ -285,7 +291,7 @@ export async function buildDaemon(input) {
285
291
  const acknowledged = closureAcks.take();
286
292
  // A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
287
293
  try {
288
- return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
294
+ return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion(), roomActivityV1: true }, signal);
289
295
  }
290
296
  catch (error) {
291
297
  closureAcks.restore(acknowledged);
@@ -293,12 +299,17 @@ export async function buildDaemon(input) {
293
299
  }
294
300
  },
295
301
  authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
296
- dispatch: async (result) => { try {
297
- await dispatch(result);
298
- }
299
- finally {
300
- await fixConsent.afterSync(result);
301
- } },
302
+ dispatch: async (result) => {
303
+ void roomActivity.handleSync(result.roomActivityEnabled === true).catch(() => {
304
+ process.stderr.write(`${JSON.stringify({ event: 'room_activity_scan_failed' })}\n`);
305
+ });
306
+ try {
307
+ await dispatch(result);
308
+ }
309
+ finally {
310
+ await fixConsent.afterSync(result);
311
+ }
312
+ },
302
313
  openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
303
314
  report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
304
315
  });
@@ -1,3 +1,4 @@
1
+ import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt, CONTINUATION_SCHEMA, CONTINUATION_POLICY } from './verification/continuation-runner-contract.js';
1
2
  import { publicationError } from './publication/failure.js';
2
3
  import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
3
4
  import { hasP1 } from './verification/p1-execution-host.js';
@@ -17,6 +18,7 @@ export class GatewayToolError extends Error {
17
18
  this.code = code;
18
19
  }
19
20
  }
21
+ const ROOM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
22
  const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
21
23
  const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
22
24
  const FIX_CONSENT_GRANTS_MAX = 20;
@@ -60,6 +62,7 @@ export function parseRunnerSync(result, runnerId) {
60
62
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
61
63
  ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
62
64
  ...(body.ask_alert === true ? { askAlert: true } : {}),
65
+ ...(body.room_activity_enabled === true ? { roomActivityEnabled: true } : {}),
63
66
  ...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
64
67
  jobAvailable: body.job_available === true,
65
68
  push: parsePush(body.push, runnerId),
@@ -116,7 +119,7 @@ export function shadowTaskPackage(contract) {
116
119
  const verification = hasP1(payload) ? payload.verification : undefined;
117
120
  if (hasP1(payload)) {
118
121
  const resolved = resolveRunnerVerification(verification);
119
- if (resolved.version === 2 && (nested(payload, 'policy.policy_key') !== REVIEWED_POLICY || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
122
+ if (resolved.version !== 1 && (nested(payload, 'policy.policy_key') !== (resolved.version === 3 ? CONTINUATION_POLICY : REVIEWED_POLICY) || nested(payload, 'policy.loop_type') !== REVIEWED_LOOP_TYPE))
120
123
  throw Error('P1_CONTRACT_REJECTED');
121
124
  }
122
125
  const repositories = (payload.repositories ?? []).map((repository) => ({
@@ -315,10 +318,14 @@ export class McpGatewayClient {
315
318
  if (!this.checkoutPath) {
316
319
  throw new Error("local checkout path is required before creating an execution binding");
317
320
  }
318
- const useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext;
321
+ const qualified = input.expectedJobId !== undefined || input.expectedAttemptNumber !== undefined;
322
+ if (qualified && (typeof input.expectedJobId !== 'string' || !input.expectedJobId.trim() || !Number.isSafeInteger(input.expectedAttemptNumber) || input.expectedAttemptNumber < 1 || input.expectedAttemptNumber > 3))
323
+ throw new GatewayToolError('Qualified next identity is invalid', 'work_loop_next', 'CORRECTION_LINEAGE_MISMATCH');
324
+ const nextArguments = { work_loop_id: input.workLoopId, ...(qualified ? { expected_job_id: input.expectedJobId, expected_attempt_number: input.expectedAttemptNumber } : {}) };
325
+ const useProvidedLease = Boolean(this.initialLeaseToken) && !input.claimNext && !qualified;
319
326
  const claim = useProvidedLease
320
327
  ? await this.callTool("work_loop_show", { work_loop_id: input.workLoopId })
321
- : await this.callTool("work_loop_next", { work_loop_id: input.workLoopId });
328
+ : await this.callTool("work_loop_next", nextArguments);
322
329
  const leaseToken = useProvidedLease
323
330
  ? this.initialLeaseToken
324
331
  : requiredString(claim.lease_token, "lease token");
@@ -505,12 +512,25 @@ export class McpGatewayClient {
505
512
  return { leaseExpiresAt: context.leaseExpiresAt };
506
513
  }
507
514
  async waitForLocalJob(adapterVersions, waitSeconds = 25, jobId) {
508
- return this.callTool("local_agent_jobs_wait", {
515
+ const claim = await this.callTool("local_agent_jobs_wait", {
509
516
  runner_id: this.runnerId,
510
517
  adapter_versions: adapterVersions,
511
518
  ...(jobId ? { job_id: jobId } : {}),
512
519
  wait_seconds: Math.max(0, Math.min(30, waitSeconds)),
513
520
  });
521
+ if (['continuation', 'execution_attempt', 'execution_attempt_fingerprint'].some(key => Object.hasOwn(claim, key)))
522
+ throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
523
+ if (claim.status === 'claimed' && claim.task_package && claim.job) {
524
+ try {
525
+ assertClaimedExecutionAttempt({ id: claim.job.id, generation: claim.job.generation, workLoopId: claim.job.work_loop_id, launchFingerprint: claim.job.launch_fingerprint, launchGrant: claim.launch_grant ?? '', leaseToken: claim.lease_token ?? '', taskPackage: claim.task_package, executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_fingerprint });
526
+ }
527
+ catch {
528
+ throw new GatewayToolError('Claimed attempt identity is invalid', 'local_agent_jobs_wait', 'CORRECTION_EXECUTION_UNAVAILABLE');
529
+ }
530
+ }
531
+ else if (claim.attempt_context !== undefined || claim.attempt_context_fingerprint !== undefined)
532
+ throw new GatewayToolError('Unexpected claimed attempt identity', 'local_agent_jobs_wait', 'GATEWAY_INVALID_TOOL_RESULT');
533
+ return claim;
514
534
  }
515
535
  async registerLocalRunner(input) {
516
536
  return this.callTool("local_agent_runner_register", {
@@ -587,9 +607,40 @@ export class McpGatewayClient {
587
607
  reason: input.reason,
588
608
  ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
589
609
  ...(input.guidancePolicyVersion ? { guidance_policy_version: input.guidancePolicyVersion } : {}),
610
+ ...(input.roomActivityV1 ? { room_activity_v1: true } : {}),
590
611
  }, signal);
591
612
  return parseRunnerSync(result, this.runnerId);
592
613
  }
614
+ async readRoomActivity(afterRoomId = '', limit = 50, signal) {
615
+ if ((afterRoomId !== '' && !ROOM_ID.test(afterRoomId)) || !Number.isInteger(limit) || limit < 1 || limit > 100) {
616
+ throw new GatewayToolError('Invalid Room activity query', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
617
+ }
618
+ const result = await this.callTool('work_room_read', { operation: 'activity', after_room_id: afterRoomId, limit, share_only: true }, signal);
619
+ const body = result && typeof result === 'object' && !Array.isArray(result) ? result : undefined;
620
+ if (!body || !Array.isArray(body.rooms) || body.rooms.length > limit
621
+ || !(body.next_cursor === null || (typeof body.next_cursor === 'string' && ROOM_ID.test(body.next_cursor)))) {
622
+ throw new GatewayToolError('Invalid Room activity page', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
623
+ }
624
+ const rooms = body.rooms.map(row => {
625
+ const item = row && typeof row === 'object' && !Array.isArray(row) ? row : {};
626
+ if (typeof item.room_id !== 'string' || !ROOM_ID.test(item.room_id)
627
+ || !Number.isSafeInteger(item.unread_count) || item.unread_count < 0
628
+ || !Number.isSafeInteger(item.latest_message_sequence) || item.latest_message_sequence < 0) {
629
+ throw new GatewayToolError('Invalid Room activity entry', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
630
+ }
631
+ return { roomId: item.room_id, unreadCount: item.unread_count, latestMessageSequence: item.latest_message_sequence };
632
+ });
633
+ let previous = afterRoomId;
634
+ for (const room of rooms) {
635
+ if (room.roomId <= previous)
636
+ throw new GatewayToolError('Room activity order is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
637
+ previous = room.roomId;
638
+ }
639
+ if (body.next_cursor !== null && (rooms.length === 0 || body.next_cursor !== previous)) {
640
+ throw new GatewayToolError('Room activity cursor is invalid', 'work_room_read', 'GATEWAY_INVALID_TOOL_RESULT');
641
+ }
642
+ return { rooms, nextCursor: body.next_cursor };
643
+ }
593
644
  async authorizeRunnerChannel(socketId, signal) {
594
645
  const result = await this.callTool("local_agent_runner_channel_auth", {
595
646
  runner_id: this.runnerId,
@@ -640,6 +691,15 @@ export class McpGatewayClient {
640
691
  const bindingId = requiredString(result.binding.id, "binding ID");
641
692
  const iterationId = requiredString(result.binding.iteration_id, "iteration ID");
642
693
  signal?.throwIfAborted();
694
+ if (input.claimedJob) {
695
+ if (input.claimedJob.id !== input.jobId || input.claimedJob.generation !== input.generation || input.claimedJob.workLoopId !== input.workLoopId || input.claimedJob.leaseToken !== input.jobLeaseToken || input.claimedJob.launchGrant !== input.launchGrant || result.work_loop_id !== input.workLoopId)
696
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
697
+ assertBoundExecutionAttempt(input.claimedJob, result.bundle, iterationId);
698
+ if (input.claimedJob.executionAttempt && typeof result.binding_replayed !== 'boolean')
699
+ throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
700
+ }
701
+ else if (Object.hasOwn(result.bundle, 'attempt_context') || Object.hasOwn(result.bundle, 'attempt_context_fingerprint') || nested(result.bundle, 'contract.verification.schema_version') === CONTINUATION_SCHEMA)
702
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
643
703
  this.context = {
644
704
  jobFence: { job_id: input.jobId, generation: input.generation, lease_token: input.jobLeaseToken },
645
705
  workLoopId: requiredString(result.work_loop_id, "Work Loop ID"),
@@ -653,7 +713,7 @@ export class McpGatewayClient {
653
713
  runtimeAdapterVersion: requiredString(result.binding.runtime_adapter_version, "runtime adapter version"),
654
714
  };
655
715
  return {
656
- bindingId,
716
+ bindingId, iterationId, ...(input.claimedJob?.executionAttempt ? { bindingReplayed: result.binding_replayed } : {}),
657
717
  taskPackage: result.bundle,
658
718
  resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
659
719
  };
@@ -1,3 +1,5 @@
1
+ import { validateAttemptContext, attemptContextFingerprint } from './verification/continuation-runner-contract.js';
2
+ import { protocolHash } from './publication/candidate.js';
1
3
  export const PROTOCOL_VERSION = "agent-execution-v1";
2
4
  export const ROLES = ["planner", "supervisor", "executor", "reviewer"];
3
5
  export const EVENT_TYPES = [
@@ -73,3 +75,34 @@ export class TerminalGuard {
73
75
  return this.terminalSeen !== null;
74
76
  }
75
77
  }
78
+ /** Wire-domain/identity check only. Scope, measured command results and grant authority
79
+ * are verified by their owners; a syntactically valid payload is not completion proof. */
80
+ export function validateAttemptEvidence(value, expected) {
81
+ const c = validateAttemptContext(expected), fp = attemptContextFingerprint(c);
82
+ const reject = () => { throw Error('CORRECTION_LINEAGE_MISMATCH'); };
83
+ if (!value || typeof value !== 'object' || Array.isArray(value))
84
+ reject();
85
+ const p = value, m = p.changed_paths, receipts = p.receipts;
86
+ const hex = (v, n) => typeof v === 'string' && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
87
+ if (!hex(p.candidate_commit_sha, 40) || !hex(p.snapshot_fingerprint, 64) || !m || m.schema_version !== 'runner-changed-paths-v2' || m.digest_domain !== 'p1-changed-paths-v2' || m.attempt_context_fingerprint !== fp || m.base_commit_sha !== c.attempt_base_commit_sha || m.candidate_commit_sha !== p.candidate_commit_sha || !Array.isArray(m.entries) || !m.entries.length || !Array.isArray(receipts) || !receipts.length)
88
+ reject();
89
+ const { manifest_sha256, ...unsigned } = m;
90
+ if (manifest_sha256 !== protocolHash(unsigned))
91
+ reject();
92
+ for (const value of receipts) {
93
+ if (!value || typeof value !== 'object' || Array.isArray(value))
94
+ reject();
95
+ const r = value;
96
+ if (r.schema_version !== 'runner-command-receipt-v2' || r.attempt_context_fingerprint !== fp || r.job_id !== c.job_id || r.generation !== c.generation || r.contract_fingerprint !== c.contract_fingerprint || r.candidate_commit_sha !== p.candidate_commit_sha || r.snapshot_fingerprint !== p.snapshot_fingerprint)
97
+ reject();
98
+ }
99
+ }
100
+ /** Versioned host handoff check, distinct from ordinary terminal event transport. */
101
+ export function validateAttemptHandoff(value, expected) {
102
+ if (!value || typeof value !== 'object' || Array.isArray(value))
103
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
104
+ const h = value, fp = attemptContextFingerprint(expected);
105
+ if (h.schema_version !== 'gateway-candidate-handoff-v2' || h.attempt_context_fingerprint !== fp || attemptContextFingerprint(h.attempt_context) !== fp)
106
+ throw Error('CORRECTION_LINEAGE_MISMATCH');
107
+ validateAttemptEvidence(h, expected);
108
+ }