mtech-ai-local-runner 0.1.74 → 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 CHANGED
@@ -525,6 +525,13 @@ publication authority through a late response. Once publication may have taken
525
525
  effect, retain `publication_pending` recovery instead of falsely completing,
526
526
  failing or restarting the attempt. Canonical publication approval remains required.
527
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
+
528
535
  Component, shadow admission, installed-package and live-provider evidence are
529
536
  separate. Historical 0.1.10 hook behavior below is not current Claude admission;
530
537
  Claude parity requires the actual current isolation/admission gate to pass.
@@ -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
  }
@@ -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 => {
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.75",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {