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 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
- await launchd.install(process.argv[1], config.stateRoot);
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
- await fixConsent.afterSync(result);
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?.(); if (protocolHash(bindingProof((await options.gateway.localPublicationContext(fence)).context)) !== protocolHash(binding) || closed || !lifecycle.isCurrent() || !lifecycle.isFrozen())
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
- try {
204
- result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'stage', files, ...(report ? { validationReport: report } : {}), securityReceipt: { ...security }, hostReceipt: { ...registration.receipt } })).result;
205
- }
206
- catch {
207
- throw new PublicationOutcomeUnknownError();
208
- }
209
- const publicationId = result.publication_id;
210
- if (typeof publicationId !== 'string' || !publicationId)
211
- throw new PublicationOutcomeUnknownError();
212
- if (record.publication_id && record.publication_id !== publicationId)
213
- throw new PublicationOutcomeUnknownError();
214
- record.publication_id = publicationId;
215
- await journal.save(key, record);
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 current();
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 (options.sleep ?? delay)(5000);
234
- await current();
235
- const approval = await options.canonicalApprovalId?.(publicationId);
236
- await current();
237
- try {
238
- result = (await options.gateway.executeServerCiPublication({ ...fence, operation: 'advance', publicationId, canonicalApprovalId: approval })).result;
239
- }
240
- catch {
241
- throw new PublicationOutcomeUnknownError();
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
- const gateway = object(inner.gateway_verification), definition = object(gateway.profile_definition);
61
- exact(gateway, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
62
- if (gateway.schema_version !== 'gateway-verification-scope-v1' || gateway.intent !== 'verification_only' || gateway.mode !== 'shadow' || !same(gateway.profile_fingerprint, protocolHash(definition)) || definition.reviewed !== true || definition.ref_prefix !== 'ai-verify/' || definition.max_concurrent_runs !== 1 || typeof gateway.profile_id !== 'string' || !gateway.profile_id || typeof gateway.repository_key !== 'string' || !Array.isArray(definition.repository_keys) || !definition.repository_keys.includes(gateway.repository_key))
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 (g.schema_version !== 'gateway-verification-scope-v1' || g.intent !== 'verification_only' || g.mode !== 'shadow'
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {