mtech-ai-local-runner 0.1.73 → 0.1.75

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 (31) hide show
  1. package/README.md +35 -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 +31 -10
  11. package/dist/src/daemon/suspended-publication.js +52 -0
  12. package/dist/src/gateway-client.js +79 -5
  13. package/dist/src/protocol.js +33 -0
  14. package/dist/src/publication/installed-host.js +4 -2
  15. package/dist/src/publication/server-ci-host.js +85 -31
  16. package/dist/src/rooms/activity.js +84 -0
  17. package/dist/src/rooms/notifier.js +21 -0
  18. package/dist/src/rooms/store.js +56 -0
  19. package/dist/src/safety/claude-managed-scope.js +7 -19
  20. package/dist/src/safety/claude-release.js +0 -2
  21. package/dist/src/safety/claude-review-evidence.js +2 -2
  22. package/dist/src/safety/codex-exec-production-host.js +15 -2
  23. package/dist/src/safety/job-tool-policy.js +4 -3
  24. package/dist/src/verification/continuation-runner-contract.js +228 -0
  25. package/dist/src/verification/p1-execution-host.js +25 -6
  26. package/dist/src/verification/p1-host-composition.js +2 -0
  27. package/dist/src/verification/reviewed-runner-contract.js +7 -0
  28. package/dist/src/verification/runner-observed-verifier.js +29 -5
  29. package/dist/src/workspace/registered-submodules.js +204 -0
  30. package/dist/src/workspace/workspace-manager.js +128 -18
  31. 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
@@ -497,6 +525,13 @@ publication authority through a late response. Once publication may have taken
497
525
  effect, retain `publication_pending` recovery instead of falsely completing,
498
526
  failing or restarting the attempt. Canonical publication approval remains required.
499
527
 
528
+ Since 0.1.75 (Work Decision `b3d7eb22`) a failed Gateway stage, advance or fence read
529
+ after stage started is repeated with the same arguments for up to 15 minutes, 5 s
530
+ doubling to 130 s, while the heartbeat keeps the lease. A suspended runner names its
531
+ held publication job in `suspended_job` on sync. Once the Gateway answers that the job
532
+ is closed, it clears its state and restarts. The Gateway closes only that runner's own
533
+ job, only when its lease is long expired and no pull request can exist.
534
+
500
535
  Component, shadow admission, installed-package and live-provider evidence are
501
536
  separate. Historical 0.1.10 hook behavior below is not current Claude admission;
502
537
  Claude parity requires the actual current isolation/admission gate to pass.
@@ -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,9 @@ 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 { SuspendedPublication } from './suspended-publication.js';
39
+ import { RoomActivityRuntime } from '../rooms/activity.js';
40
+ import { notifyRoomActivity } from '../rooms/notifier.js';
38
41
  export async function buildDaemon(input) {
39
42
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
40
43
  if (claudeParts !== 0 && claudeParts !== 3)
@@ -63,8 +66,11 @@ export async function buildDaemon(input) {
63
66
  pendingCodexClaim.job.lease_expires_at = expiresAt;
64
67
  };
65
68
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
69
+ const roomActivity = new RoomActivityRuntime({ stateRoot: input.stateRoot,
70
+ read: (cursor, limit) => gateway.readRoomActivity(cursor, limit), notify: notifyRoomActivity });
66
71
  // AI-first ask delivery (Work Item e1c08b33) rides on the session-open loop below; it starts no executor.
67
72
  const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
73
+ claimRoomNotice: () => roomActivity.claimNotice(),
68
74
  report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
69
75
  log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
70
76
  // Guide hooks (Work Item d38d60f9) ride on the same sync and heartbeat; they start no executor.
@@ -121,7 +127,7 @@ export async function buildDaemon(input) {
121
127
  taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
122
128
  createBinding: async (job, signal) => gateway.createLocalJobBinding({
123
129
  jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
124
- launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
130
+ launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
125
131
  }, signal),
126
132
  publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
127
133
  syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
@@ -145,7 +151,7 @@ export async function buildDaemon(input) {
145
151
  }
146
152
  return registry.select(id, version, options);
147
153
  } },
148
- workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
154
+ workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient(), registeredSubmodules: p1Host?.registeredSubmodules }),
149
155
  state,
150
156
  });
151
157
  let transport;
@@ -167,6 +173,7 @@ export async function buildDaemon(input) {
167
173
  launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
168
174
  gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
169
175
  leaseExpiresAt: claim.job.lease_expires_at,
176
+ executionAttempt: claim.attempt_context, executionAttemptFingerprint: claim.attempt_context_fingerprint,
170
177
  };
171
178
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
172
179
  return { status: "claimed", job };
@@ -261,6 +268,9 @@ export async function buildDaemon(input) {
261
268
  report: () => localFixHost?.report, jobRunning: () => poller.running,
262
269
  restart: () => { daemon.stop(); },
263
270
  log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
271
+ // A suspended publication clears itself once the Gateway closed its job (Work Decision b3d7eb22), then restarts like a consent change.
272
+ const suspendedPublication = new SuspendedPublication({ state, restart: () => { daemon.stop(); },
273
+ log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
264
274
  const opener = new SessionOpener({ home: input.env.HOME ?? "" });
265
275
  const dispatch = createSyncDispatcher({
266
276
  open: request => opener.open(request),
@@ -283,9 +293,10 @@ export async function buildDaemon(input) {
283
293
  const pushTransport = new RunnerTransport({
284
294
  sync: async (reason, signal) => {
285
295
  const acknowledged = closureAcks.take();
296
+ const suspendedJob = await suspendedPublication.named();
286
297
  // A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
287
298
  try {
288
- return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
299
+ return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion(), roomActivityV1: true, ...(suspendedJob ? { suspendedJob } : {}) }, signal);
289
300
  }
290
301
  catch (error) {
291
302
  closureAcks.restore(acknowledged);
@@ -293,12 +304,22 @@ export async function buildDaemon(input) {
293
304
  }
294
305
  },
295
306
  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
- } },
307
+ dispatch: async (result) => {
308
+ void roomActivity.handleSync(result.roomActivityEnabled === true).catch(() => {
309
+ process.stderr.write(`${JSON.stringify({ event: 'room_activity_scan_failed' })}\n`);
310
+ });
311
+ try {
312
+ await dispatch(result);
313
+ }
314
+ finally {
315
+ try {
316
+ await fixConsent.afterSync(result);
317
+ }
318
+ finally {
319
+ await suspendedPublication.afterSync(result);
320
+ }
321
+ }
322
+ },
302
323
  openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
303
324
  report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
304
325
  });
@@ -317,7 +338,7 @@ export async function buildDaemon(input) {
317
338
  throw new Error('Runner state requires authoritative reconciliation');
318
339
  },
319
340
  poller, sessionOpens: withGuide(guide, withAsks(asks, sessionOpens)),
320
- reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
341
+ reportRecoveryRequired: reason => { suspendedPublication.markSuspended(); process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
321
342
  });
322
343
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
323
344
  // The Claude host always closes; a failing Claude close never masks a Codex close failure.
@@ -0,0 +1,52 @@
1
+ const PUBLICATION_PHASES = ["publication_pending", "publication_terminal_pending"];
2
+ /** The job fence of a stored publication phase, or undefined for any other state. */
3
+ function heldPublication(state) {
4
+ if (!state || typeof state !== "object" || Array.isArray(state))
5
+ return undefined;
6
+ const { phase, jobId, generation } = state;
7
+ if (typeof phase !== "string" || !PUBLICATION_PHASES.includes(phase))
8
+ return undefined;
9
+ if (typeof jobId !== "string" || !jobId.trim() || !Number.isSafeInteger(generation) || generation < 1)
10
+ return undefined;
11
+ return { jobId, generation: generation };
12
+ }
13
+ export class SuspendedPublication {
14
+ deps;
15
+ suspended = false;
16
+ cleared = false;
17
+ constructor(deps) {
18
+ this.deps = deps;
19
+ }
20
+ /** The daemon suspended for recovery; from now on its held publication is named. */
21
+ markSuspended() { this.suspended = true; }
22
+ /** The job to name on the next sync. A state that cannot be read names nothing. */
23
+ async named() {
24
+ if (!this.suspended || this.cleared)
25
+ return undefined;
26
+ try {
27
+ return heldPublication(await this.deps.state.load());
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /** After every sync: clears and restarts only when the answer closes the job the state still holds. */
34
+ async afterSync(result) {
35
+ const answer = result.suspendedJob;
36
+ if (!answer?.closed)
37
+ return;
38
+ const held = await this.named();
39
+ if (!held || held.jobId !== answer.jobId || held.generation !== answer.generation)
40
+ return;
41
+ try {
42
+ await this.deps.state.clear();
43
+ }
44
+ catch {
45
+ this.deps.log({ event: "runner_recovery_clear_failed", job_id: held.jobId, generation: held.generation });
46
+ return;
47
+ }
48
+ this.cleared = true;
49
+ this.deps.log({ event: "runner_recovery_cleared", job_id: held.jobId, generation: held.generation, gateway_status: answer.status });
50
+ this.deps.restart();
51
+ }
52
+ }