mtech-ai-local-runner 0.1.74 → 0.1.76
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 +49 -2
- package/dist/src/cli.js +6 -3
- package/dist/src/daemon/runtime.js +13 -3
- package/dist/src/daemon/suspended-publication.js +52 -0
- package/dist/src/gateway-client.js +14 -0
- package/dist/src/platform/macos/stable-release.js +90 -0
- package/dist/src/publication/installed-host.js +4 -2
- package/dist/src/publication/server-ci-host.js +80 -25
- package/dist/src/verification/continuation-runner-contract.js +6 -3
- package/dist/src/verification/runner-observed-verifier.js +45 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## Unreleased: Gateway-managed verification budget (P3E)
|
|
2
|
+
|
|
3
|
+
D6 owner revision `fbc30fbe-cac9-45d1-9eff-bccec6ee6a54` selects a separate
|
|
4
|
+
Gateway-initiated P2 verification budget: at most 1,500 weighted minutes per UTC
|
|
5
|
+
calendar month and one active verification per workspace. This does not cap or
|
|
6
|
+
certify the entire Bitbucket bill or unrelated jobs.
|
|
7
|
+
|
|
8
|
+
New `gateway-verification-scope-v2` contracts freeze the managed accounting
|
|
9
|
+
policy, reviewed weight and D6 identity. Legacy scope-v1 contracts retain their
|
|
10
|
+
strict provider-billed usage condition and are never upgraded by a flag. The
|
|
11
|
+
Runner validates the explicit new scope through both reviewed and continuation
|
|
12
|
+
envelopes before execution. Existing provider validation limits (including its
|
|
13
|
+
reviewed cloud-1x pipeline restriction) are unchanged; this feature does not
|
|
14
|
+
approve another CI location or an unreviewed isolation profile.
|
|
15
|
+
|
|
16
|
+
The Gateway's `AI_OPS_GATEWAY_WORK_VERIFICATION_MANAGED_BUDGET_ENABLED` switch
|
|
17
|
+
is false by default. New launch/admission and stage/start writes need it; already
|
|
18
|
+
admitted CI may still be polled and settled when that switch is off. Ambiguous
|
|
19
|
+
POST results retain their reservation and never automatically retry a paid run.
|
|
20
|
+
Scoped monthly rows preserve the legacy ledger and share its workspace guard;
|
|
21
|
+
known settlement releases ownership without refunding conservative debits.
|
|
22
|
+
|
|
23
|
+
This is unreleased source support. No profile, reviewer or host is
|
|
24
|
+
registered by the change. A coordinated additive ledger migration and compatible
|
|
25
|
+
readers are required before activation. The earlier source-version0.1.73 review
|
|
26
|
+
archive predates this implementation and cannot authorize these new bytes; an
|
|
27
|
+
unused final version, committed source and fresh package/runtime manifest must
|
|
28
|
+
be reviewed together. No package is published or installed. See the
|
|
29
|
+
[P3E design](../../docs/superpowers/specs/2026-10-30-p3e-managed-ci-budget-design.md)
|
|
30
|
+
and [implementation plan](../../docs/superpowers/plans/2026-10-30-p3e-managed-ci-budget-implementation.md).
|
|
31
|
+
|
|
1
32
|
## 0.1.74: exact-parent reviewed correction attempts (P3D)
|
|
2
33
|
|
|
3
34
|
This local source supports newly approved executable V3 launches behind the
|
|
@@ -525,6 +556,13 @@ publication authority through a late response. Once publication may have taken
|
|
|
525
556
|
effect, retain `publication_pending` recovery instead of falsely completing,
|
|
526
557
|
failing or restarting the attempt. Canonical publication approval remains required.
|
|
527
558
|
|
|
559
|
+
Since 0.1.75 (Work Decision `b3d7eb22`) a failed Gateway stage, advance or fence read
|
|
560
|
+
after stage started is repeated with the same arguments for up to 15 minutes, 5 s
|
|
561
|
+
doubling to 130 s, while the heartbeat keeps the lease. A suspended runner names its
|
|
562
|
+
held publication job in `suspended_job` on sync. Once the Gateway answers that the job
|
|
563
|
+
is closed, it clears its state and restarts. The Gateway closes only that runner's own
|
|
564
|
+
job, only when its lease is long expired and no pull request can exist.
|
|
565
|
+
|
|
528
566
|
Component, shadow admission, installed-package and live-provider evidence are
|
|
529
567
|
separate. Historical 0.1.10 hook behavior below is not current Claude admission;
|
|
530
568
|
Claude parity requires the actual current isolation/admission gate to pass.
|
|
@@ -863,10 +901,19 @@ The relay binds only to loopback. Local capabilities are stored in private mode-
|
|
|
863
901
|
Existing employees can upgrade their installed daemon with:
|
|
864
902
|
|
|
865
903
|
```bash
|
|
866
|
-
npx mtech-ai-local-runner setup
|
|
867
|
-
npx mtech-ai-local-runner doctor
|
|
904
|
+
npx mtech-ai-local-runner@<version> setup
|
|
905
|
+
npx mtech-ai-local-runner@<version> doctor
|
|
868
906
|
```
|
|
869
907
|
|
|
908
|
+
Since 0.1.76, setup copies the install that ran it into
|
|
909
|
+
`<state root>/releases/<version>` and points the LaunchAgent at that copy, so
|
|
910
|
+
the daemon never runs from the npx cache. The copy is byte-identical: it keeps
|
|
911
|
+
the measured Fix receipt. A newer dependency from a fresh install could change
|
|
912
|
+
that receipt. An existing release directory with other bytes is refused, and the
|
|
913
|
+
LaunchAgent is left as it was. Before 0.1.76, setup pinned whatever CLI ran it.
|
|
914
|
+
Under npx that was the cache (seen with 0.1.75 on 2026-10-30). Run 0.1.75 setup
|
|
915
|
+
from an installed copy instead.
|
|
916
|
+
|
|
870
917
|
### Cancellation and preparation failures
|
|
871
918
|
|
|
872
919
|
Cancelling a Work Loop also cancels its non-terminal local jobs. Repeating the
|
package/dist/src/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { OAuthTokenProvider, LoginRequiredError } from "./auth/token-provider.js
|
|
|
24
24
|
import { createBrowserOpener } from "./auth/browser-opener.js";
|
|
25
25
|
import { FileLocalConfigStore, runnerMode } from "./registration/local-config.js";
|
|
26
26
|
import { LaunchdService } from "./platform/macos/launchd.js";
|
|
27
|
+
import { installStableRelease } from "./platform/macos/stable-release.js";
|
|
27
28
|
import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
|
|
28
29
|
import { createHash } from "node:crypto";
|
|
29
30
|
import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
|
|
@@ -258,10 +259,12 @@ export async function main(argv, env, io = {}) {
|
|
|
258
259
|
const features = requestedFeatures ?? ["asks"];
|
|
259
260
|
const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
|
|
260
261
|
const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
|
|
261
|
-
|
|
262
|
+
// The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
|
|
263
|
+
const daemonExecutable = await (io.installRelease ?? installStableRelease)({ entry: process.argv[1], stateRoot: config.stateRoot });
|
|
264
|
+
await launchd.install(daemonExecutable, config.stateRoot);
|
|
262
265
|
stdout(JSON.stringify(mode === "full"
|
|
263
|
-
? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}) }
|
|
264
|
-
: { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}),
|
|
266
|
+
? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), daemonExecutable }
|
|
267
|
+
: { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), daemonExecutable,
|
|
265
268
|
action: "No executor is admitted on this Mac, so the runner is set up as a messenger: it delivers AI Gateway asks and opens sessions, and runs no jobs. Resolve the adapter diagnostics, then rerun setup to run jobs." }));
|
|
266
269
|
return 0;
|
|
267
270
|
}
|
|
@@ -35,6 +35,7 @@ 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';
|
|
38
39
|
import { RoomActivityRuntime } from '../rooms/activity.js';
|
|
39
40
|
import { notifyRoomActivity } from '../rooms/notifier.js';
|
|
40
41
|
export async function buildDaemon(input) {
|
|
@@ -267,6 +268,9 @@ export async function buildDaemon(input) {
|
|
|
267
268
|
report: () => localFixHost?.report, jobRunning: () => poller.running,
|
|
268
269
|
restart: () => { daemon.stop(); },
|
|
269
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`); } });
|
|
270
274
|
const opener = new SessionOpener({ home: input.env.HOME ?? "" });
|
|
271
275
|
const dispatch = createSyncDispatcher({
|
|
272
276
|
open: request => opener.open(request),
|
|
@@ -289,9 +293,10 @@ export async function buildDaemon(input) {
|
|
|
289
293
|
const pushTransport = new RunnerTransport({
|
|
290
294
|
sync: async (reason, signal) => {
|
|
291
295
|
const acknowledged = closureAcks.take();
|
|
296
|
+
const suspendedJob = await suspendedPublication.named();
|
|
292
297
|
// A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
|
|
293
298
|
try {
|
|
294
|
-
return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion(), roomActivityV1: true }, signal);
|
|
299
|
+
return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion(), roomActivityV1: true, ...(suspendedJob ? { suspendedJob } : {}) }, signal);
|
|
295
300
|
}
|
|
296
301
|
catch (error) {
|
|
297
302
|
closureAcks.restore(acknowledged);
|
|
@@ -307,7 +312,12 @@ export async function buildDaemon(input) {
|
|
|
307
312
|
await dispatch(result);
|
|
308
313
|
}
|
|
309
314
|
finally {
|
|
310
|
-
|
|
315
|
+
try {
|
|
316
|
+
await fixConsent.afterSync(result);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await suspendedPublication.afterSync(result);
|
|
320
|
+
}
|
|
311
321
|
}
|
|
312
322
|
},
|
|
313
323
|
openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
|
|
@@ -328,7 +338,7 @@ export async function buildDaemon(input) {
|
|
|
328
338
|
throw new Error('Runner state requires authoritative reconciliation');
|
|
329
339
|
},
|
|
330
340
|
poller, sessionOpens: withGuide(guide, withAsks(asks, sessionOpens)),
|
|
331
|
-
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`); },
|
|
332
342
|
});
|
|
333
343
|
// Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
|
|
334
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
|
+
}
|
|
@@ -19,6 +19,17 @@ export class GatewayToolError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
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;
|
|
22
|
+
const SUSPENDED_JOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
/** A malformed answer is dropped, never a reason to refuse the sync: the runner then stays suspended. */
|
|
24
|
+
function parseSuspendedJob(value) {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
26
|
+
return undefined;
|
|
27
|
+
const { job_id: jobId, generation, status, closed } = value;
|
|
28
|
+
if (typeof jobId !== "string" || !SUSPENDED_JOB_ID.test(jobId) || !Number.isSafeInteger(generation) || generation < 1
|
|
29
|
+
|| typeof status !== "string" || !/^[a-z_]{1,32}$/.test(status) || typeof closed !== "boolean")
|
|
30
|
+
return undefined;
|
|
31
|
+
return { jobId, generation: generation, status, closed };
|
|
32
|
+
}
|
|
22
33
|
const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
|
|
23
34
|
const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
24
35
|
const FIX_CONSENT_GRANTS_MAX = 20;
|
|
@@ -54,6 +65,7 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
54
65
|
if (body === undefined || !Array.isArray(body.opens) || malformed("ask_deliveries") || malformed("closed_delivery_ids")) {
|
|
55
66
|
throw new GatewayToolError("Gateway returned an invalid runner sync result", "local_agent_runner_sync", "GATEWAY_INVALID_TOOL_RESULT");
|
|
56
67
|
}
|
|
68
|
+
const suspendedJob = parseSuspendedJob(body.suspended_job);
|
|
57
69
|
return {
|
|
58
70
|
opens: body.opens,
|
|
59
71
|
...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
|
|
@@ -64,6 +76,7 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
64
76
|
...(body.ask_alert === true ? { askAlert: true } : {}),
|
|
65
77
|
...(body.room_activity_enabled === true ? { roomActivityEnabled: true } : {}),
|
|
66
78
|
...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
|
|
79
|
+
...(suspendedJob ? { suspendedJob } : {}),
|
|
67
80
|
jobAvailable: body.job_available === true,
|
|
68
81
|
push: parsePush(body.push, runnerId),
|
|
69
82
|
syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
|
|
@@ -608,6 +621,7 @@ export class McpGatewayClient {
|
|
|
608
621
|
...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
|
|
609
622
|
...(input.guidancePolicyVersion ? { guidance_policy_version: input.guidancePolicyVersion } : {}),
|
|
610
623
|
...(input.roomActivityV1 ? { room_activity_v1: true } : {}),
|
|
624
|
+
...(input.suspendedJob ? { suspended_job: { job_id: input.suspendedJob.jobId, generation: input.suspendedJob.generation } } : {}),
|
|
611
625
|
}, signal);
|
|
612
626
|
return parseRunnerSync(result, this.runnerId);
|
|
613
627
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
4
|
+
/*
|
|
5
|
+
* The LaunchAgent runs the CLI that ran setup. Under npx that is the npx cache (2026-10-30, 0.1.75), which npm may
|
|
6
|
+
* prune or reuse, so the next daemon start would fail or run other bytes. Setup therefore copies the running install
|
|
7
|
+
* (the node_modules holding this package and its dependencies) into <state root>/releases/<version> and the
|
|
8
|
+
* LaunchAgent runs that copy. The copy is the same bytes that ran setup, so the measured Fix receipt does not change;
|
|
9
|
+
* a fresh npm install could resolve newer dependencies and change it.
|
|
10
|
+
*/
|
|
11
|
+
const PACKAGE = "mtech-ai-local-runner";
|
|
12
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
|
|
13
|
+
/** Every name, link target, file mode and content hash under a directory, in sorted order. */
|
|
14
|
+
async function treeDigest(root) {
|
|
15
|
+
const hash = createHash("sha256");
|
|
16
|
+
const walk = async (dir) => {
|
|
17
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
18
|
+
const path = join(dir, name), rel = relative(root, path), st = await lstat(path);
|
|
19
|
+
if (st.isSymbolicLink())
|
|
20
|
+
hash.update(`L ${rel} ${await readlink(path)}\n`);
|
|
21
|
+
else if (st.isDirectory()) {
|
|
22
|
+
hash.update(`D ${rel}\n`);
|
|
23
|
+
await walk(path);
|
|
24
|
+
}
|
|
25
|
+
else if (st.isFile())
|
|
26
|
+
hash.update(`F ${rel} ${(st.mode & 0o777).toString(8)} ${createHash("sha256").update(await readFile(path)).digest("hex")}\n`);
|
|
27
|
+
else
|
|
28
|
+
throw new Error("RUNNER_RELEASE_FILE_UNSUPPORTED");
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await walk(root);
|
|
32
|
+
return hash.digest("hex");
|
|
33
|
+
}
|
|
34
|
+
/** The installed package that holds the entry, or undefined when the entry is not inside an installed package. */
|
|
35
|
+
async function installedPackage(entry) {
|
|
36
|
+
let dir = dirname(entry);
|
|
37
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
38
|
+
try {
|
|
39
|
+
const manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
40
|
+
if (manifest.name === PACKAGE) {
|
|
41
|
+
if (typeof manifest.version !== "string" || !VERSION.test(manifest.version))
|
|
42
|
+
throw new Error("RUNNER_RELEASE_VERSION_INVALID");
|
|
43
|
+
return basename(dirname(dir)) === "node_modules" ? { root: dir, version: manifest.version } : undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof Error && error.message === "RUNNER_RELEASE_VERSION_INVALID")
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
const parent = dirname(dir);
|
|
51
|
+
if (parent === dir)
|
|
52
|
+
break;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** The path the LaunchAgent should run: the entry's copy under <state root>/releases/<version>. */
|
|
58
|
+
export async function installStableRelease(input) {
|
|
59
|
+
const entry = await realpath(input.entry);
|
|
60
|
+
const pkg = await installedPackage(entry);
|
|
61
|
+
if (!pkg)
|
|
62
|
+
return input.entry;
|
|
63
|
+
const releases = join(input.stateRoot, "releases");
|
|
64
|
+
await mkdir(releases, { recursive: true, mode: 0o700 });
|
|
65
|
+
if (entry.startsWith((await realpath(releases)) + sep))
|
|
66
|
+
return entry;
|
|
67
|
+
const modules = dirname(pkg.root), target = join(releases, pkg.version);
|
|
68
|
+
const targetEntry = join(target, "node_modules", PACKAGE, relative(pkg.root, entry));
|
|
69
|
+
const expected = await treeDigest(modules);
|
|
70
|
+
const existing = await lstat(target).catch(() => undefined);
|
|
71
|
+
if (existing) {
|
|
72
|
+
if (!existing.isDirectory() || await treeDigest(join(target, "node_modules")).catch(() => "") !== expected) {
|
|
73
|
+
throw new Error("RUNNER_RELEASE_DIRECTORY_DIFFERS");
|
|
74
|
+
}
|
|
75
|
+
return targetEntry;
|
|
76
|
+
}
|
|
77
|
+
const partial = `${target}.partial-${process.pid}`;
|
|
78
|
+
await rm(partial, { recursive: true, force: true });
|
|
79
|
+
try {
|
|
80
|
+
await mkdir(partial, { mode: 0o700 });
|
|
81
|
+
await cp(modules, join(partial, "node_modules"), { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
|
82
|
+
if (await treeDigest(join(partial, "node_modules")) !== expected)
|
|
83
|
+
throw new Error("RUNNER_RELEASE_COPY_MISMATCH");
|
|
84
|
+
await rename(partial, target);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await rm(partial, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
return targetEntry;
|
|
90
|
+
}
|
|
@@ -42,6 +42,8 @@ export function assertLocalFixConsent(value) {
|
|
|
42
42
|
if (!Array.isArray(modes) || !modes.length || new Set(modes).size !== modes.length || modes.some(m => m !== 'gateway_ci_v1' && m !== 'gateway_publish_v1'))
|
|
43
43
|
throw denied();
|
|
44
44
|
}
|
|
45
|
+
/** One fixed JSON line per repeated publication call; no payload, path or token. */
|
|
46
|
+
const reportRetry = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); };
|
|
45
47
|
/** Serves each job with the registration of the mode frozen in its own Fix scope. */
|
|
46
48
|
function multiModeHost(hosts) {
|
|
47
49
|
const verificationModes = hosts.map(h => { if (h.verificationMode === undefined)
|
|
@@ -94,7 +96,7 @@ export async function loadInstalledLocalFixHost(input) {
|
|
|
94
96
|
// The scope's own reviewed policy and this measured release make the registration the server CI host checks the job against.
|
|
95
97
|
const manifest = { verification_mode: mode, project: key, ci_profile: scope.ci_profile, ci_profile_fingerprint: protocolHash(scope.ci_profile), receipt: { ...measured.receipt } };
|
|
96
98
|
const registration = { ...manifest, review: { status: 'reviewed', fingerprint: protocolHash(manifest) } };
|
|
97
|
-
return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
|
|
99
|
+
return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, report: reportRetry, canonicalApprovalId: async (publicationId) => {
|
|
98
100
|
let approvals;
|
|
99
101
|
try {
|
|
100
102
|
approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
|
|
@@ -146,7 +148,7 @@ export async function loadInstalledLocalFixHost(input) {
|
|
|
146
148
|
const privateRoot = path.join(input.stateRoot, 'local-fix-publication'), s = await lstat(privateRoot);
|
|
147
149
|
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
|
|
148
150
|
throw denied();
|
|
149
|
-
const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
|
|
151
|
+
const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, report: reportRetry, canonicalApprovalId: async (publicationId) => {
|
|
150
152
|
let approvals;
|
|
151
153
|
try {
|
|
152
154
|
approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
|
|
@@ -14,6 +14,13 @@ import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
|
|
|
14
14
|
import { PublicationOutcomeUnknownError } from './publisher.js';
|
|
15
15
|
import { isLocalFixAdapter } from './local-fix-adapters.js';
|
|
16
16
|
const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
|
|
17
|
+
/** Work Decision b3d7eb22: a failed Gateway publication call is repeated for at most this long; the coordinator heartbeat keeps the lease. */
|
|
18
|
+
export const PUBLICATION_RETRY_WINDOW_MS = 900_000;
|
|
19
|
+
/** 5 s doubling to 130 s, longer than the Gateway's 120-s quiet window, so an absent working branch can be staged again (decision 1bddc563). */
|
|
20
|
+
export const publicationRetryDelay = (failures) => Math.min(5000 * 2 ** (failures - 1), 130_000);
|
|
21
|
+
/** Only the Gateway read of the fence failed; every local check passed, so the call may be repeated. */
|
|
22
|
+
class FenceUnreadable extends Error {
|
|
23
|
+
}
|
|
17
24
|
const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
|
|
18
25
|
/** Gateway decision 8e7fff53: under any directory scope, test infrastructure, boot and dependency files stay as the base has them. */
|
|
19
26
|
const testInfrastructure = (p) => /(^|\/)(phpunit\.xml(\.dist)?|bitbucket-pipelines\.yml|composer\.[^/]+|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml)$|^(scripts|app\/common)(\/|$)|(^|\/)(vendor|node_modules)(\/|$)/.test(p);
|
|
@@ -82,8 +89,22 @@ export function createServerCiLocalFixHost(options) {
|
|
|
82
89
|
throw denied();
|
|
83
90
|
let closed = false, settled = false, stageStarted = false, host, phase = 'validation_report', primary, disposing, finalization, seal;
|
|
84
91
|
const current = async () => { if (closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
|
|
85
|
-
throw denied(); await options.assertReleaseCurrent?.();
|
|
92
|
+
throw denied(); await options.assertReleaseCurrent?.(); let context; try {
|
|
93
|
+
context = (await options.gateway.localPublicationContext(fence)).context;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new FenceUnreadable();
|
|
97
|
+
} if (protocolHash(bindingProof(context)) !== protocolHash(binding) || closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
|
|
86
98
|
throw denied(); await seal?.assertUnchanged(); };
|
|
99
|
+
const readable = async () => { try {
|
|
100
|
+
await current();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof FenceUnreadable)
|
|
105
|
+
return false;
|
|
106
|
+
throw error;
|
|
107
|
+
} };
|
|
87
108
|
const dispose = () => disposing ??= (host?.dispose() ?? Promise.resolve());
|
|
88
109
|
return { close: async () => { closed = true; if (!finalization || settled) {
|
|
89
110
|
try {
|
|
@@ -198,21 +219,51 @@ export function createServerCiLocalFixHost(options) {
|
|
|
198
219
|
if (!security)
|
|
199
220
|
throw denied();
|
|
200
221
|
let result;
|
|
222
|
+
const sleep = options.sleep ?? delay, now = options.now ?? Date.now;
|
|
223
|
+
// Work Decision b3d7eb22: a string answer names a failed Gateway call, repeated with the same arguments while the
|
|
224
|
+
// fence holds. The streak ends at the first accepted answer; a delay that would outlast the window gives up as before.
|
|
225
|
+
const retrying = async (attempt) => {
|
|
226
|
+
let started;
|
|
227
|
+
for (let failures = 1;; failures++) {
|
|
228
|
+
const answer = await attempt();
|
|
229
|
+
if (typeof answer !== 'string')
|
|
230
|
+
return answer;
|
|
231
|
+
started ??= now();
|
|
232
|
+
const wait = publicationRetryDelay(failures);
|
|
233
|
+
if (now() - started + wait > PUBLICATION_RETRY_WINDOW_MS)
|
|
234
|
+
throw new PublicationOutcomeUnknownError();
|
|
235
|
+
try {
|
|
236
|
+
options.report?.({ event: 'local_fix_publication_retry', operation: answer, failures, delay_ms: wait });
|
|
237
|
+
}
|
|
238
|
+
catch { /* diagnostics only */ }
|
|
239
|
+
await sleep(wait);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
201
242
|
phase = 'stage';
|
|
202
243
|
stageStarted = true;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
244
|
+
let stages = 0;
|
|
245
|
+
const stageInput = { ...fence, operation: 'stage', files, ...(report ? { validationReport: report } : {}), securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } };
|
|
246
|
+
// The same frozen files again: the Gateway reconciles a present branch or stages an absent one again (1bddc563).
|
|
247
|
+
result = await retrying(async () => {
|
|
248
|
+
if (stages++ > 0 && !await readable())
|
|
249
|
+
return 'fence';
|
|
250
|
+
let answer;
|
|
251
|
+
try {
|
|
252
|
+
answer = (await options.gateway.executeServerCiPublication(stageInput)).result;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return 'stage';
|
|
256
|
+
}
|
|
257
|
+
const id = answer.publication_id;
|
|
258
|
+
if (typeof id !== 'string' || !id)
|
|
259
|
+
throw new PublicationOutcomeUnknownError();
|
|
260
|
+
if (record.publication_id && record.publication_id !== id)
|
|
261
|
+
throw new PublicationOutcomeUnknownError();
|
|
262
|
+
record.publication_id = id;
|
|
263
|
+
await journal.save(key, record);
|
|
264
|
+
return answer.status === 'publication_unknown' ? 'stage' : answer;
|
|
265
|
+
});
|
|
266
|
+
const publicationId = String(result.publication_id);
|
|
216
267
|
const pending = new Set(['staged', 'validating', 'authorized', 'committing', 'committed', 'pushing', 'pushed', 'opening_pr']);
|
|
217
268
|
for (;;) {
|
|
218
269
|
phase = 'result';
|
|
@@ -221,7 +272,7 @@ export function createServerCiLocalFixHost(options) {
|
|
|
221
272
|
if (result.status === 'ci_failed')
|
|
222
273
|
throw new ServerCiPublicationError(result.ci?.error_code ?? result.failure?.code);
|
|
223
274
|
if (result.status === 'completed') {
|
|
224
|
-
await
|
|
275
|
+
await retrying(async () => await readable() ? {} : 'fence');
|
|
225
276
|
const ci = result.ci;
|
|
226
277
|
if (result.verification_mode !== mode || (mode === 'gateway_ci_v1' ? ci?.status !== 'passed' || ci.commit_sha !== result.commit_sha : ci?.status !== 'not_run' || result.verification?.status !== 'unverified') || typeof result.commit_sha !== 'string' || !/^[a-f0-9]{40}$/.test(result.commit_sha) || result.working_branch !== binding.working_branch)
|
|
227
278
|
throw new PublicationOutcomeUnknownError();
|
|
@@ -230,16 +281,20 @@ export function createServerCiLocalFixHost(options) {
|
|
|
230
281
|
if (result.waiting || !pending.has(String(result.status)))
|
|
231
282
|
throw new PublicationOutcomeUnknownError();
|
|
232
283
|
phase = 'advance';
|
|
233
|
-
await
|
|
234
|
-
await
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
284
|
+
await sleep(5000);
|
|
285
|
+
result = await retrying(async () => {
|
|
286
|
+
if (!await readable())
|
|
287
|
+
return 'fence';
|
|
288
|
+
const approval = await options.canonicalApprovalId?.(publicationId);
|
|
289
|
+
if (!await readable())
|
|
290
|
+
return 'fence';
|
|
291
|
+
try {
|
|
292
|
+
return (await options.gateway.executeServerCiPublication({ ...fence, operation: 'advance', publicationId, canonicalApprovalId: approval })).result;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return 'advance';
|
|
296
|
+
}
|
|
297
|
+
});
|
|
243
298
|
}
|
|
244
299
|
});
|
|
245
300
|
})().catch(error => {
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
4
4
|
import { protocolHash, protocolJson } from '../publication/candidate.js';
|
|
5
|
+
import { assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
5
6
|
export const CONTINUATION_SCHEMA = 'local-runner-verification-v3';
|
|
6
7
|
export const CONTINUATION_POLICY = 'local-runner-reviewed-corrections-v1';
|
|
7
8
|
export const ATTEMPT_CONTEXT_SCHEMA = 'local-runner-attempt-context-v1';
|
|
@@ -57,10 +58,12 @@ export function resolveContinuationVerification(value) {
|
|
|
57
58
|
const inner = object(e.runner_binding), c = object(e.completion_binding);
|
|
58
59
|
if (inner.schema_version !== 'local-runner-verification-v1' || inner.mode !== 'runner_observed_v1' || inner.policy_key !== 'local-runner-verified-v1')
|
|
59
60
|
reject();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
try {
|
|
62
|
+
assertGatewayVerificationScope(inner.gateway_verification);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
63
65
|
reject();
|
|
66
|
+
}
|
|
64
67
|
exact(c, ['schema_version', 'frozen_loop_contract', 'completion_criteria_fingerprint', 'runner_criteria_fingerprint', 'review_subject_schema', 'review_requirement', 'execute_attempt_limit', 'max_correction_rounds', 'd3_reference']);
|
|
65
68
|
if (!integer(c.max_correction_rounds, 0, 2) || c.execute_attempt_limit !== Math.min(3, 1 + Number(c.max_correction_rounds)))
|
|
66
69
|
reject();
|
|
@@ -30,13 +30,57 @@ export function assertGatewayVerificationScope(value, repositoryKey) {
|
|
|
30
30
|
throw denied();
|
|
31
31
|
const g = value;
|
|
32
32
|
keys(g, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
|
|
33
|
-
if (
|
|
33
|
+
if (!['gateway-verification-scope-v1', 'gateway-verification-scope-v2'].includes(g.schema_version) || g.intent !== 'verification_only' || g.mode !== 'shadow'
|
|
34
34
|
|| typeof g.profile_id !== 'string' || !g.profile_id || typeof g.repository_key !== 'string' || !g.repository_key
|
|
35
35
|
|| repositoryKey !== undefined && g.repository_key !== repositoryKey || !g.profile_definition || typeof g.profile_definition !== 'object'
|
|
36
36
|
|| protocolHash(g.profile_definition) !== g.profile_fingerprint || g.profile_definition.reviewed !== true
|
|
37
37
|
|| g.profile_definition.ref_prefix !== 'ai-verify/' || g.profile_definition.max_concurrent_runs !== 1
|
|
38
38
|
|| !Array.isArray(g.profile_definition.repository_keys) || !g.profile_definition.repository_keys.includes(g.repository_key))
|
|
39
39
|
throw denied();
|
|
40
|
+
const p = g.profile_definition, managed = g.schema_version === 'gateway-verification-scope-v2';
|
|
41
|
+
const fields = ['reviewed', 'repository_keys', 'ref_prefix', 'ci', 'protected_paths', 'monthly_minutes_budget', 'max_pipeline_minutes', 'weighted_minutes_reservation', 'max_concurrent_runs', 'grant_ttl_seconds'];
|
|
42
|
+
// Legacy bytes remain legacy; accounting can only arrive in an explicit V2 scope.
|
|
43
|
+
keys(p, managed ? [...fields, 'accounting'] : fields);
|
|
44
|
+
if (!managed)
|
|
45
|
+
return;
|
|
46
|
+
const exact = (value, fields) => {
|
|
47
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
48
|
+
throw denied();
|
|
49
|
+
keys(value, fields);
|
|
50
|
+
if (fields.some(k => !Object.hasOwn(value, k)))
|
|
51
|
+
throw denied();
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
exact(p, [...fields, 'accounting']);
|
|
55
|
+
const a = exact(p.accounting, ['mode', 'budget_scope', 'period_kind', 'decision_revision_id', 'weight_multiplier']);
|
|
56
|
+
if (a.mode !== 'gateway_managed_reservations_v1' || a.budget_scope !== 'p2_gateway_verification' || a.period_kind !== 'utc_calendar_month'
|
|
57
|
+
|| a.decision_revision_id !== 'fbc30fbe-cac9-45d1-9eff-bccec6ee6a54' || !Number.isSafeInteger(a.weight_multiplier) || Number(a.weight_multiplier) < 1)
|
|
58
|
+
throw denied();
|
|
59
|
+
for (const key of ['monthly_minutes_budget', 'max_pipeline_minutes', 'weighted_minutes_reservation', 'grant_ttl_seconds'])
|
|
60
|
+
if (!Number.isSafeInteger(p[key]) || Number(p[key]) < 1)
|
|
61
|
+
throw denied();
|
|
62
|
+
if (Number(p.monthly_minutes_budget) > 1500 || Number(p.max_pipeline_minutes) > 60 || Number(p.grant_ttl_seconds) > 86400
|
|
63
|
+
|| Number(p.grant_ttl_seconds) < Number(p.max_pipeline_minutes) * 60 + 600
|
|
64
|
+
|| p.weighted_minutes_reservation !== Number(p.max_pipeline_minutes) * Number(a.weight_multiplier)
|
|
65
|
+
|| Number(p.weighted_minutes_reservation) > Number(p.monthly_minutes_budget))
|
|
66
|
+
throw denied();
|
|
67
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(g.profile_id) || !Array.isArray(p.protected_paths) || p.protected_paths.length > 100 || p.protected_paths.some(n => typeof n !== 'string' || !n || n.length > 400))
|
|
68
|
+
throw denied();
|
|
69
|
+
const ci = exact(p.ci, ['validation_only', 'secret_isolation', 'automatic_deployment', 'selector', 'required_steps', 'trusted_files']);
|
|
70
|
+
if (ci.validation_only !== true || ci.secret_isolation !== true || ci.automatic_deployment !== false || typeof ci.selector !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,100}$/.test(ci.selector)
|
|
71
|
+
|| !Array.isArray(ci.required_steps) || !ci.required_steps.length || ci.required_steps.length > 50 || new Set(ci.required_steps).size !== ci.required_steps.length
|
|
72
|
+
|| ci.required_steps.some(n => typeof n !== 'string' || !n || n.trim() !== n || n.length > 255)
|
|
73
|
+
|| !Array.isArray(ci.trusted_files) || !ci.trusted_files.length || ci.trusted_files.length > 100)
|
|
74
|
+
throw denied();
|
|
75
|
+
const paths = new Set();
|
|
76
|
+
for (const file of ci.trusted_files) {
|
|
77
|
+
const f = exact(file, ['path', 'sha256']);
|
|
78
|
+
if (typeof f.path !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_.-]+)*$/.test(f.path) || f.path.split('/').includes('..') || paths.has(f.path) || !hex(f.sha256, 64))
|
|
79
|
+
throw denied();
|
|
80
|
+
paths.add(f.path);
|
|
81
|
+
}
|
|
82
|
+
if (!paths.has('bitbucket-pipelines.yml'))
|
|
83
|
+
throw denied();
|
|
40
84
|
}
|
|
41
85
|
/** Canonical P1 domain. Never uses legacy terminal T/U bytes or diff hashing. */
|
|
42
86
|
export function buildP1ChangedPaths(input, bounds) {
|