mtech-ai-local-runner 0.1.6 → 0.1.8

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
@@ -10,6 +10,17 @@ The package stores safe runner configuration locally and keeps OAuth refresh cre
10
10
 
11
11
  Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
12
12
 
13
+ ## 0.1.8 (release candidate)
14
+
15
+ - Report safe preparation stage/tool/error categories and settle known failures instead of silently reclaiming the same job forever.
16
+ - Bound preparation to 15 minutes, retain terminal receipts across connection failures, and halt on unknown binding outcomes until reconciled.
17
+ - Pair with Gateway cancellation propagation and three-delivery preparation bounds. Actual approved unbounded employee-local Claude execution remains unbounded.
18
+ - Prepared locally; npm publication and daemon upgrade are separate actions. Keep a stopped runner stopped until its cancelled job is confirmed terminal on Gateway.
19
+
20
+ ## 0.1.7
21
+
22
+ - Accept one fenced final JSON report after an explanatory preamble while preserving status, evidence, length and pending-work validation. Reject ambiguous multiple code blocks.
23
+
13
24
  ## 0.1.6
14
25
 
15
26
  - Require a final JSON report with actual findings, nonempty evidence and no pending work before reporting completion. Incomplete or missing reports fail without a successful investigation artifact.
@@ -35,6 +46,33 @@ The relay binds only to loopback. Local capabilities are stored in private mode-
35
46
  Existing employees can upgrade their installed daemon with:
36
47
 
37
48
  ```bash
38
- npx mtech-ai-local-runner@0.1.6 setup
39
- npx mtech-ai-local-runner@0.1.6 doctor
49
+ npx mtech-ai-local-runner@0.1.7 setup
50
+ npx mtech-ai-local-runner@0.1.7 doctor
40
51
  ```
52
+
53
+ ### Cancellation and preparation failures
54
+
55
+ Cancelling a Work Loop also cancels its non-terminal local jobs. Repeating the
56
+ cancellation repairs jobs left by an earlier Gateway version; terminal results
57
+ are preserved. Claim/reclaim and lease-gated launch checks reject cancelled
58
+ Loops. An active adapter stops when the next lease heartbeat fails; cancellation
59
+ is not an immediate process-kill acknowledgement.
60
+
61
+ Before binding, the Gateway permits at most three queue deliveries and a
62
+ 15-minute preparation window from the first claim. The daemon also bounds its
63
+ workspace/probe/shadow/binding preparation to 15 minutes. A known preparation
64
+ failure is reported as `blocked` immediately, with a safe stage, tool name when
65
+ available, and error category. Tokens, grants, arguments and raw error responses
66
+ are excluded from these diagnostics. Unacknowledged terminal receipts remain in
67
+ private state for reconciliation before polling continues.
68
+
69
+ A binding request whose outcome is unknown is retained as `binding_uncertain`;
70
+ the daemon stops polling and requires authoritative reconciliation of the
71
+ recorded job/generation and deterministic binding reference before restart.
72
+ It neither repeats the binding request nor assumes the server did not create it.
73
+ Do not delete that state to force another execution.
74
+
75
+ These preparation controls end at binding and do not impose time, turn, token
76
+ or cost termination limits on an approved unbounded employee-local Claude run.
77
+ Existing generation fencing, scope restrictions, cancellation and audit remain
78
+ in force.
@@ -32,7 +32,19 @@ export function completionEvent(result, costUsd) {
32
32
  report = JSON.parse(result);
33
33
  }
34
34
  catch {
35
- return failed;
35
+ // Claude may wrap the final report in one Markdown fence after a short
36
+ // explanation. Accept framing only; retain every completion/evidence check.
37
+ if ((result.match(/```/g) ?? []).length !== 2)
38
+ return failed;
39
+ const framed = result.trim().match(/^(?:[\s\S]*?\n)?```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/);
40
+ if (!framed)
41
+ return failed;
42
+ try {
43
+ report = JSON.parse(framed[1]);
44
+ }
45
+ catch {
46
+ return failed;
47
+ }
36
48
  }
37
49
  if (!report || typeof report !== "object" || Array.isArray(report))
38
50
  return failed;
package/dist/src/cli.js CHANGED
@@ -182,7 +182,7 @@ export async function main(argv, env, io = {}) {
182
182
  const authenticated = await runFile(binary, ["auth", "status"], { encoding: "utf8" }).then(() => true).catch(() => false);
183
183
  const registered = await gateway.registerLocalRunner({
184
184
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
185
- displayName: runnerDisplayName(), packageVersion: "0.1.6",
185
+ displayName: runnerDisplayName(), packageVersion: "0.1.8",
186
186
  capabilities: [{ adapter: "claude_code_cli", version: "1", capabilities: ["repository_read", "repository_edit", "shell_test", "git_diff"], authenticated }],
187
187
  });
188
188
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1 });
@@ -1,3 +1,4 @@
1
+ import { GatewayToolError } from "../gateway-client.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
3
4
  function canonical(value) {
@@ -28,30 +29,84 @@ function text(value, label) {
28
29
  throw new Error(`task package is missing ${label}`);
29
30
  return value;
30
31
  }
32
+ class PreparationDeadlineError extends Error {
33
+ }
31
34
  export class ExecutionCoordinator {
32
35
  deps;
33
36
  constructor(deps) {
34
37
  this.deps = deps;
35
38
  }
36
39
  async execute(job) {
37
- const pkg = job.taskPackage;
38
- const repository = pkg.repository;
39
- const adapterId = text(pkg.runtime_adapter, "runtime adapter");
40
- const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
41
- const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
42
- const workspace = await this.deps.workspaces.create({
43
- jobId: job.id,
44
- repositoryKey: text(repository?.key, "repository key"),
45
- providerUrl: text(repository?.provider_url, "repository provider URL"),
46
- baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
47
- });
48
- const adapter = await this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id });
49
- await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
50
- const gatewayShadow = await this.deps.gateway.shadow(job);
51
- if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
52
- throw new Error("contract or task-package fingerprint mismatch before binding");
40
+ let stage = "package";
41
+ let workspace;
42
+ let adapter;
43
+ let binding;
44
+ const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
45
+ const step = async (operation) => {
46
+ const remaining = deadline - Date.now();
47
+ if (remaining <= 0)
48
+ throw new PreparationDeadlineError();
49
+ let timer;
50
+ try {
51
+ return await Promise.race([operation(), new Promise((_, reject) => {
52
+ timer = setTimeout(() => reject(new PreparationDeadlineError()), remaining);
53
+ })]);
54
+ }
55
+ finally {
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ }
59
+ };
60
+ try {
61
+ const pkg = job.taskPackage;
62
+ const repository = pkg.repository;
63
+ const adapterId = text(pkg.runtime_adapter, "runtime adapter");
64
+ const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
65
+ const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
66
+ stage = "workspace";
67
+ workspace = await step(() => this.deps.workspaces.create({
68
+ jobId: job.id,
69
+ repositoryKey: text(repository?.key, "repository key"),
70
+ providerUrl: text(repository?.provider_url, "repository provider URL"),
71
+ baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
72
+ }));
73
+ stage = "adapter";
74
+ adapter = await step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }));
75
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
76
+ stage = "shadow";
77
+ const gatewayShadow = await step(() => this.deps.gateway.shadow(job));
78
+ stage = "fingerprint";
79
+ if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
80
+ throw new Error("contract or task-package fingerprint mismatch before binding");
81
+ }
82
+ stage = "binding";
83
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" });
84
+ binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }));
85
+ }
86
+ catch (error) {
87
+ const diagnostic = { stage, code: "PREPARATION_FAILED" };
88
+ if (error instanceof PreparationDeadlineError)
89
+ diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
90
+ if (error instanceof GatewayToolError) {
91
+ diagnostic.tool = error.toolName;
92
+ diagnostic.code = error.code;
93
+ }
94
+ this.deps.reportPreparationFailure?.(diagnostic);
95
+ if (stage === "binding" && !(error instanceof GatewayToolError && error.code === "GATEWAY_TOOL_REJECTED")) {
96
+ // The server may have committed the binding. Never fabricate a blocked result
97
+ // or repeat the mutation before the deterministic binding reference is reconciled.
98
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
99
+ phase: "binding_uncertain", preparationFailure: diagnostic });
100
+ throw new Error("Binding outcome requires reconciliation");
101
+ }
102
+ // Retain this exact safe terminal payload until Gateway acknowledges it.
103
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
104
+ phase: "preparation_failed", preparationFailure: diagnostic });
105
+ await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
106
+ status: "blocked", result: { preparation_failure: { ...diagnostic } } });
107
+ await this.deps.state.clear();
108
+ return;
53
109
  }
54
- const binding = await this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path });
55
110
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest });
56
111
  let heartbeatFailure = null;
57
112
  let requestedTerminal;
@@ -9,14 +9,23 @@ export class JobPoller {
9
9
  async tick() {
10
10
  if (this.stopped)
11
11
  return;
12
+ let stage = "heartbeat";
12
13
  try {
13
14
  await this.deps.heartbeat?.();
15
+ stage = "wait";
14
16
  const claim = await this.deps.wait();
15
- this.failures = 0;
17
+ stage = "execute";
16
18
  if (claim.status === "claimed" && claim.job)
17
19
  await this.deps.execute(claim.job);
20
+ this.failures = 0;
18
21
  }
19
22
  catch {
23
+ this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
24
+ // Unknown execution/terminal outcomes must reconcile before any new claim.
25
+ if (stage === "execute") {
26
+ this.stop();
27
+ return;
28
+ }
20
29
  this.failures += 1;
21
30
  const base = Math.min(30_000, 1000 * (2 ** Math.min(5, this.failures - 1)));
22
31
  const jitter = Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
@@ -48,6 +48,7 @@ export async function buildDaemon(input) {
48
48
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
49
49
  const state = new FileDaemonStateStore(input.stateRoot);
50
50
  const coordinator = new ExecutionCoordinator({
51
+ reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
51
52
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
52
53
  gateway: {
53
54
  shadow: (job) => gateway.localJobShadow({ workLoopId: job.workLoopId,
@@ -70,9 +71,10 @@ export async function buildDaemon(input) {
70
71
  state,
71
72
  });
72
73
  const poller = new JobPoller({
74
+ reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
73
75
  heartbeat: () => gateway.heartbeatLocalRunner({
74
76
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
75
- displayName: runnerDisplayName(), packageVersion: "0.1.6",
77
+ displayName: runnerDisplayName(), packageVersion: "0.1.8",
76
78
  capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
77
79
  capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
78
80
  }).then(() => undefined),
@@ -103,6 +105,10 @@ export async function buildDaemon(input) {
103
105
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
104
106
  reconcile: async (stored) => {
105
107
  const previous = stored;
108
+ if (await reconcilePreparationState(previous, {
109
+ complete: (result) => gateway.completeLocalJob(result), clear: () => state.clear(),
110
+ }))
111
+ return;
106
112
  if (previous.bindingId && previous.jobId && previous.generation && previous.leaseToken) {
107
113
  try {
108
114
  const receipt = await gateway.readBindingEvents(previous.bindingId, previous.eventCursor ?? 0);
@@ -131,3 +137,18 @@ export async function buildDaemon(input) {
131
137
  } } };
132
138
  }
133
139
  export function runnerDisplayName() { return `${hostname()} local AI`; }
140
+ /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
141
+ export async function reconcilePreparationState(previous, deps) {
142
+ if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
143
+ throw new Error("Binding outcome requires reconciliation before runner polling");
144
+ }
145
+ if (!previous.preparationFailure)
146
+ return false;
147
+ if (!previous.jobId || !previous.generation || !previous.leaseToken) {
148
+ throw new Error("Preparation receipt is missing its job fence");
149
+ }
150
+ await deps.complete({ jobId: previous.jobId, generation: previous.generation,
151
+ leaseToken: previous.leaseToken, status: "blocked", result: { preparation_failure: previous.preparationFailure } });
152
+ await deps.clear();
153
+ return true;
154
+ }
@@ -2,6 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import { v5 as uuidv5 } from "uuid";
3
3
  import { bindingApprovalIdFromEnv, redact } from "./config.js";
4
4
  import { isTerminalEventType } from "./protocol.js";
5
+ /** Safe diagnostic metadata: never includes arguments, response bodies or credentials. */
6
+ export class GatewayToolError extends Error {
7
+ toolName;
8
+ code;
9
+ constructor(message, toolName, code) {
10
+ super(message);
11
+ this.toolName = toolName;
12
+ this.code = code;
13
+ }
14
+ }
5
15
  function nested(value, path) {
6
16
  return path.split(".").reduce((current, key) => {
7
17
  if (!current || typeof current !== "object")
@@ -113,6 +123,7 @@ export class McpGatewayClient {
113
123
  throw new Error("No token provider or static token configured");
114
124
  }
115
125
  async callTool(name, args) {
126
+ let failureCode = "GATEWAY_TRANSPORT_ERROR";
116
127
  try {
117
128
  const token = await this.resolveToken();
118
129
  const response = await this.fetchFn(this.mcpUrl, {
@@ -122,14 +133,17 @@ export class McpGatewayClient {
122
133
  });
123
134
  const body = (await response.json());
124
135
  if (!response.ok || body.error) {
136
+ failureCode = !response.ok ? `GATEWAY_HTTP_${response.status}` : "GATEWAY_RPC_ERROR";
125
137
  throw new Error(`Gateway MCP ${name} failed with status ${response.status}: ${JSON.stringify(body.error ?? body)}`);
126
138
  }
127
139
  const text = nested(body, "result.content.0.text");
128
- if (nested(body, "result.isError") || typeof text !== "string") {
140
+ if (typeof text !== "string") {
141
+ failureCode = "GATEWAY_INVALID_TOOL_RESULT";
129
142
  throw new Error(`Gateway MCP ${name} returned an invalid tool result`);
130
143
  }
131
144
  const envelope = JSON.parse(text);
132
- if (envelope.status !== "completed") {
145
+ if (envelope.status !== "completed" || nested(body, "result.isError")) {
146
+ failureCode = envelope.status === "failed" ? "GATEWAY_TOOL_REJECTED" : "GATEWAY_INVALID_TOOL_RESULT";
133
147
  throw new Error(`Gateway MCP ${name} did not complete: ${JSON.stringify(envelope)}`);
134
148
  }
135
149
  return nested(envelope, "result.data");
@@ -148,7 +162,7 @@ export class McpGatewayClient {
148
162
  }
149
163
  const leaseToken = this.context?.leaseToken ?? this.initialLeaseToken ?? "";
150
164
  sanitized = redact(sanitized, leaseToken);
151
- throw new Error(redact(sanitized, this.bindingApprovalId ?? ""));
165
+ throw new GatewayToolError(redact(sanitized, this.bindingApprovalId ?? ""), name, failureCode);
152
166
  }
153
167
  }
154
168
  async resolveCompatibility(input) {
@@ -421,6 +435,8 @@ export class McpGatewayClient {
421
435
  const shown = await this.callTool("work_loop_show", { work_loop_id: input.workLoopId });
422
436
  if (shown.loop.environment !== "development")
423
437
  throw new Error("local agent runtime is development-only");
438
+ if (shown.loop.status === "cancelled")
439
+ throw new GatewayToolError("Work Loop is cancelled", "work_loop_show", "WORK_LOOP_CANCELLED");
424
440
  const result = await this.callTool("work_execution_contract_show", {
425
441
  contract_id: requiredString(shown.loop.work_execution_contract_id, "execution contract ID"),
426
442
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {