mtech-ai-local-runner 0.1.7 → 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 +34 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/daemon/execution-coordinator.js +72 -17
- package/dist/src/daemon/job-poller.js +10 -1
- package/dist/src/daemon/runtime.js +22 -1
- package/dist/src/gateway-client.js +19 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,13 @@ 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
|
+
|
|
13
20
|
## 0.1.7
|
|
14
21
|
|
|
15
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.
|
|
@@ -42,3 +49,30 @@ Existing employees can upgrade their installed daemon with:
|
|
|
42
49
|
npx mtech-ai-local-runner@0.1.7 setup
|
|
43
50
|
npx mtech-ai-local-runner@0.1.7 doctor
|
|
44
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.
|
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.
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
});
|