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.
- package/README.md +35 -0
- package/dist/src/adapters/claude-code/prompt-compiler.js +3 -1
- package/dist/src/adapters/codex/exec-invocation.js +24 -0
- package/dist/src/adapters/codex/exec-launch-host.js +5 -0
- package/dist/src/adapters/codex/exec-runtime-options.js +1 -1
- package/dist/src/adapters/codex/prompt-compiler.js +3 -1
- package/dist/src/asks/runtime.js +22 -1
- package/dist/src/daemon/execution-coordinator.js +65 -9
- package/dist/src/daemon/manual-codex.js +2 -2
- package/dist/src/daemon/runtime.js +31 -10
- package/dist/src/daemon/suspended-publication.js +52 -0
- package/dist/src/gateway-client.js +79 -5
- package/dist/src/protocol.js +33 -0
- package/dist/src/publication/installed-host.js +4 -2
- package/dist/src/publication/server-ci-host.js +85 -31
- package/dist/src/rooms/activity.js +84 -0
- package/dist/src/rooms/notifier.js +21 -0
- package/dist/src/rooms/store.js +56 -0
- package/dist/src/safety/claude-managed-scope.js +7 -19
- package/dist/src/safety/claude-release.js +0 -2
- package/dist/src/safety/claude-review-evidence.js +2 -2
- package/dist/src/safety/codex-exec-production-host.js +15 -2
- package/dist/src/safety/job-tool-policy.js +4 -3
- package/dist/src/verification/continuation-runner-contract.js +228 -0
- package/dist/src/verification/p1-execution-host.js +25 -6
- package/dist/src/verification/p1-host-composition.js +2 -0
- package/dist/src/verification/reviewed-runner-contract.js +7 -0
- package/dist/src/verification/runner-observed-verifier.js +29 -5
- package/dist/src/workspace/registered-submodules.js +204 -0
- package/dist/src/workspace/workspace-manager.js +128 -18
- 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
|
-
|
|
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
|
-
|
|
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"],
|
package/dist/src/asks/runtime.js
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
70
|
-
|
|
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 =
|
|
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');
|
|
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) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|