mtech-ai-local-runner 0.1.48 → 0.1.50
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 +40 -18
- package/dist/src/adapters/claude-code/adapter.js +1 -1
- package/dist/src/adapters/codex/config-builder.js +1 -1
- package/dist/src/adapters/codex/pre-model-gate.js +1 -1
- package/dist/src/adapters/codex/prompt-compiler.js +1 -1
- package/dist/src/asks/messenger.js +38 -11
- package/dist/src/asks/runtime.js +11 -3
- package/dist/src/daemon/heartbeat-scheduler.js +63 -0
- package/dist/src/daemon/job-runner.js +99 -0
- package/dist/src/daemon/runtime.js +120 -62
- package/dist/src/gateway-client.js +66 -0
- package/dist/src/safety/local-tool-policy.js +15 -4
- package/dist/src/safety/local-tool-server.js +2 -1
- package/dist/src/safety/local-tool-worker.js +25 -2
- package/dist/src/safety/pre-tool-use-hook.js +1 -1
- package/dist/src/transport/abortable-delay.js +12 -0
- package/dist/src/transport/pusher-protocol.js +66 -0
- package/dist/src/transport/reverb-socket.js +144 -0
- package/dist/src/transport/runner-transport.js +265 -0
- package/dist/src/transport/side-loops.js +50 -0
- package/dist/src/transport/sync-dispatch.js +91 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
|
-
##
|
|
1
|
+
## 0.1.49: one push transport instead of two long-polls
|
|
2
|
+
|
|
3
|
+
The daemon no longer long-polls the Gateway (Work Item 25d8ea74). It keeps one Pusher protocol 7 WebSocket to the
|
|
4
|
+
ELSA development Reverb server, which only rings, and calls `local_agent_runner_sync` once, with no wait: on start,
|
|
5
|
+
after each subscribe, on each signal, every 30 s while subscribed, and every 8 s while the socket is down or the
|
|
6
|
+
Gateway gives no push block. A job is claimed with `local_agent_jobs_wait` at `wait_seconds` 0, and only when the
|
|
7
|
+
sync says one is claimable. Session opens, ask deliveries and closures arrive on the sync result, and a closure is
|
|
8
|
+
acknowledged on the next sync. The full heartbeat goes out on a change or every 10 minutes. While the daemon takes no
|
|
9
|
+
jobs (reconciling at start, or suspended for recovery), it reports every adapter blocked with
|
|
10
|
+
`RUNNER_NOT_ACCEPTING_JOBS`, so the Gateway routes no job to it.
|
|
11
|
+
|
|
12
|
+
The socket sends `User-Agent: mtec-ai-local-runner/<version>`; the server's firewall refuses an upgrade without one.
|
|
13
|
+
The runner needs a Gateway with `local_agent_runner_sync` and `local_agent_runner_channel_auth`, so deploy the
|
|
14
|
+
Gateway before upgrading a runner. Push stays off until the Gateway's push switch names the employee; until then the
|
|
15
|
+
runner syncs every 8 s.
|
|
16
|
+
|
|
17
|
+
## 0.1.48: local_edit takes the complete file, not a patch
|
|
18
|
+
|
|
19
|
+
Read the existing file first, then pass its complete desired new contents in
|
|
20
|
+
`local_edit.content`, including unchanged text. The operation replaces the whole
|
|
21
|
+
file or creates it; it never applies a diff. Recognizable apply_patch directives,
|
|
22
|
+
unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
|
|
23
|
+
The isolated writer enforces the same guard. Literal patch blocks in a document
|
|
24
|
+
are also rejected by this conservative API; ordinary source string literals and
|
|
25
|
+
YAML separators remain accepted. No partial old/new edit tool is introduced.
|
|
26
|
+
|
|
27
|
+
Gateway and Runner must agree on the new tool inventory hash. Package runtime
|
|
28
|
+
receipts change, while the reviewed Codex CLI release manifest identity stays
|
|
29
|
+
unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
|
|
30
|
+
and exact runtime pins before switching a daemon. The candidate contains the
|
|
31
|
+
newer development ask/messenger code documented below and
|
|
32
|
+
the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
|
|
33
|
+
|
|
34
|
+
### Also in 0.1.48: AI-first ask delivery and messenger mode (disabled)
|
|
2
35
|
|
|
3
36
|
The runner can put an AI Gateway ask (a Work Decision, a named review or a work request), and the answer to it,
|
|
4
37
|
into the employee's own running Claude Code or Codex session before any Slack message is sent (Work Item e1c08b33,
|
|
@@ -70,23 +103,6 @@ its pilot list. Until then an existing runner works as before: setup never turns
|
|
|
70
103
|
installs no integration it was not asked for. The one change without the switch is that setup on a fresh Mac without an
|
|
71
104
|
admitted executor completes as a messenger instead of failing. Deploy the Gateway receivers before upgrading a runner.
|
|
72
105
|
|
|
73
|
-
## 0.1.48: local_edit takes the complete file, not a patch
|
|
74
|
-
|
|
75
|
-
Read the existing file first, then pass its complete desired new contents in
|
|
76
|
-
`local_edit.content`, including unchanged text. The operation replaces the whole
|
|
77
|
-
file or creates it; it never applies a diff. Recognizable apply_patch directives,
|
|
78
|
-
unified hunk lines and diff headers return `DENIED_PATCH_CONTENT` before a write.
|
|
79
|
-
The isolated writer enforces the same guard. Literal patch blocks in a document
|
|
80
|
-
are also rejected by this conservative API; ordinary source string literals and
|
|
81
|
-
YAML separators remain accepted. No partial old/new edit tool is introduced.
|
|
82
|
-
|
|
83
|
-
Gateway and Runner must agree on the new tool inventory hash. Package runtime
|
|
84
|
-
receipts change, while the reviewed Codex CLI release manifest identity stays
|
|
85
|
-
unchanged. Keep existing -2G allowed paths; deploy the matching Gateway registry
|
|
86
|
-
and exact runtime pins before switching a daemon. The candidate contains the
|
|
87
|
-
newer development ask/messenger code documented in the Unreleased section and
|
|
88
|
-
the 0.1.46/0.1.47 fixes; no integration is installed merely by preparing it.
|
|
89
|
-
|
|
90
106
|
## 0.1.47: Claude security host wiring remains gated
|
|
91
107
|
|
|
92
108
|
Claude now shares the job security session and checks a live opaque executable
|
|
@@ -766,3 +782,9 @@ the older `authenticated` field still means execution eligibility. A logged-in
|
|
|
766
782
|
Claude account can remain blocked by unverified security admission. Do not log out
|
|
767
783
|
or change credentials just because that legacy field is false. Unknown status is
|
|
768
784
|
not evidence of logout. These diagnostics do not bypass per-job admission.
|
|
785
|
+
|
|
786
|
+
### 0.1.50: small edits and publication-only Fix
|
|
787
|
+
|
|
788
|
+
Use `local_replace({path, old, new})` for a small approved edit after reading the file: `old` must be nonempty and match exactly once, including whitespace. Zero or overlapping/multiple matches are rejected with distinct errors and no write. Replacement text must not be a patch; the result retains the same scoped write, size, link and revocation protections as `local_edit`. `local_edit` remains a complete-file replacement API.
|
|
789
|
+
|
|
790
|
+
Both CLI adapters require the five-tool inventory. Gateway tools descriptors and runtime pins must match this release; CLI release manifests and the standalone binary stay fixed. In `gateway_publish_v1`, missing registered tests is expected: report not_run/unverified, apply only the smallest source-evidenced correction, disclose uncertainty and seal for a human-reviewed Gateway PR. Historical contracts and attempts are not edited. Reverb 0.1.49 is retained; installation, deployment and real model execution remain separate approvals.
|
|
@@ -12,7 +12,7 @@ import { formatHookCommand } from "../../safety/hook-command.js";
|
|
|
12
12
|
import { localEndpointReadObservations } from '../../safety/local-tool-server.js';
|
|
13
13
|
const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV", "CLAUDE_CONFIG_DIR"];
|
|
14
14
|
const ALLOWED_SUPERVISOR_COMMANDS = new Set(["continue", "answer_question", "request_revision", "request_checkpoint", "submit_for_review"]);
|
|
15
|
-
const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`);
|
|
15
|
+
const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_replace", "local_test"].map(name => `mcp__ai_local__${name}`);
|
|
16
16
|
function allowlistedEnv(env) {
|
|
17
17
|
const result = {};
|
|
18
18
|
for (const key of ALLOWED_ENV_KEYS) {
|
|
@@ -22,7 +22,7 @@ export function buildCodexInvocation(input) {
|
|
|
22
22
|
`permissions.local_job.filesystem={":minimal"="read",${deniedFiles}}`,
|
|
23
23
|
"permissions.local_job.network.enabled=false",
|
|
24
24
|
// Only this job server is preauthorized: every operation is checked by Runner policy.
|
|
25
|
-
`mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_test"],omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
|
|
25
|
+
`mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_replace","local_test"],omit_tools_from=${JSON.stringify(CODEX_JOB_MCP_OMIT_SURFACES)}}}`,
|
|
26
26
|
];
|
|
27
27
|
const env = { HOME: input.env.HOME, AI_GATEWAY_JOB_MCP_TOKEN: input.endpoint.token };
|
|
28
28
|
for (const key of ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CODEX_HOME"]) {
|
|
@@ -2,7 +2,7 @@ import { isAbsolute } from 'node:path';
|
|
|
2
2
|
import { requireSupportedCodexPolicy } from './supported-admission.js';
|
|
3
3
|
const REJECTED = 'CODEX_PRE_MODEL_REJECTED';
|
|
4
4
|
const usedSessions = new WeakSet();
|
|
5
|
-
const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_search', 'local_test'];
|
|
5
|
+
const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_replace', 'local_search', 'local_test'];
|
|
6
6
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7
7
|
/** Bounded, value-free diagnostics for ALL candidate prerequisites. This is not
|
|
8
8
|
* native release certification: only trusted retained observations may reach it,
|
|
@@ -23,7 +23,7 @@ function repositoryScope(taskPackage) {
|
|
|
23
23
|
"These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
|
|
24
24
|
}
|
|
25
25
|
export function compileCodexPrompt(taskPackage, label) {
|
|
26
|
-
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
|
|
26
|
+
return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
|
|
27
27
|
}
|
|
28
28
|
export const CODEX_REPORT_SCHEMA = {
|
|
29
29
|
type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
|
|
@@ -5,16 +5,22 @@ import { Daemon } from "../daemon/daemon.js";
|
|
|
5
5
|
import { ProcessLock } from "../daemon/process-lock.js";
|
|
6
6
|
import { runnerDisplayName } from "../daemon/runtime.js";
|
|
7
7
|
import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
|
|
8
|
-
import {
|
|
8
|
+
import { RunnerTransport } from "../transport/runner-transport.js";
|
|
9
|
+
import { ReverbSocket } from "../transport/reverb-socket.js";
|
|
10
|
+
import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-dispatch.js";
|
|
11
|
+
import { abortableDelay } from "../transport/abortable-delay.js";
|
|
12
|
+
import { sideLoops, tickingHeartbeat } from "../transport/side-loops.js";
|
|
9
13
|
import { packageVersion } from "../package-version.js";
|
|
10
14
|
import { AskRuntime, withAsks } from "./runtime.js";
|
|
11
15
|
/*
|
|
12
16
|
* Messenger mode (Work Item e1c08b33, spec section 8.1). Setup chose it because no executor is admitted on
|
|
13
|
-
* this Mac. The daemon runs the
|
|
14
|
-
* self-test and no executor host, so nothing built here can start an executor.
|
|
17
|
+
* this Mac. The daemon runs the push transport only (Work Item 25d8ea74): there is no jobs loop, no adapter probe,
|
|
18
|
+
* no PreToolUse self-test and no executor host, so nothing built here can start an executor.
|
|
15
19
|
*/
|
|
16
|
-
/** A messenger has no jobs loop, so
|
|
20
|
+
/** A messenger has no jobs loop, so it sends the heartbeat itself: at daemon start, then every 600 s. */
|
|
17
21
|
export const MESSENGER_HEARTBEAT_MS = 600_000;
|
|
22
|
+
/** How often the heartbeat decides whether a beat is due (Work Item 25d8ea74); it used to run on every wait. */
|
|
23
|
+
export const MESSENGER_HEARTBEAT_TICK_MS = 30_000;
|
|
18
24
|
const HEARTBEAT_RETRY_MS = 60_000;
|
|
19
25
|
/**
|
|
20
26
|
* The waits before resending a heartbeat that had to leave session_open out, one after another: 60, 120, 240 s, then the
|
|
@@ -108,21 +114,42 @@ export async function buildMessengerDaemon(input) {
|
|
|
108
114
|
}, { now: input.now, reportError: code => log({ event: "messenger_heartbeat_failed", code }),
|
|
109
115
|
advertised: async () => JSON.stringify(await asks.advertisement() ?? null) });
|
|
110
116
|
const opener = new SessionOpener({ home });
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const result = await gateway.waitForSessionOpens(25, signal);
|
|
115
|
-
await asks.handleWait(result);
|
|
116
|
-
return result;
|
|
117
|
-
},
|
|
117
|
+
const stopping = new AbortController();
|
|
118
|
+
const closureAcks = new PendingClosureAcks();
|
|
119
|
+
const dispatch = createSyncDispatcher({
|
|
118
120
|
open: request => opener.open(request),
|
|
119
121
|
report: async (openId, result, signal) => {
|
|
120
122
|
await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
|
|
121
123
|
failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
|
|
122
124
|
},
|
|
125
|
+
asks,
|
|
126
|
+
closureAcks,
|
|
123
127
|
delay: abortableDelay,
|
|
128
|
+
signal: stopping.signal,
|
|
124
129
|
reportError: diagnostic => log({ event: "session_open_poller_failed", ...diagnostic }),
|
|
125
130
|
});
|
|
131
|
+
// No executor: the messenger sends no adapter versions, and the Gateway never says a job is claimable.
|
|
132
|
+
const transport = new RunnerTransport({
|
|
133
|
+
sync: async (reason, signal) => {
|
|
134
|
+
const acknowledged = closureAcks.take();
|
|
135
|
+
try {
|
|
136
|
+
return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged }, signal);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
closureAcks.restore(acknowledged);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
|
|
144
|
+
dispatch,
|
|
145
|
+
openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
|
|
146
|
+
report: log,
|
|
147
|
+
});
|
|
148
|
+
const sessionOpens = sideLoops({
|
|
149
|
+
heartbeat: tickingHeartbeat(heartbeat, input.heartbeatTickMs ?? MESSENGER_HEARTBEAT_TICK_MS, stopping.signal),
|
|
150
|
+
transport,
|
|
151
|
+
onStop: () => stopping.abort(),
|
|
152
|
+
});
|
|
126
153
|
const daemon = new Daemon({
|
|
127
154
|
lock: new ProcessLock(join(input.stateRoot, "daemon.lock")),
|
|
128
155
|
// Job state belongs to a full runner: a messenger never reads, reconciles or suspends on it.
|
package/dist/src/asks/runtime.js
CHANGED
|
@@ -102,17 +102,24 @@ export class AskRuntime {
|
|
|
102
102
|
* the runner's person is a pilot actor and it has stored this runner's ask_inbox. A result without ask_deliveries
|
|
103
103
|
* therefore means asks are off, and every stored delivery ends here with no report: the Gateway's fallback owns them.
|
|
104
104
|
* A result with the key present but empty changes nothing.
|
|
105
|
+
*
|
|
106
|
+
* Resolves false when the result's closures could not be handled (the hub is down, or closing failed), so the push
|
|
107
|
+
* transport does not acknowledge them and the Gateway lists them again (Work Item 25d8ea74).
|
|
105
108
|
*/
|
|
106
109
|
async handleWait(result) {
|
|
107
110
|
if (!this.started)
|
|
108
|
-
return;
|
|
111
|
+
return false;
|
|
112
|
+
let closuresHandled = true;
|
|
109
113
|
try {
|
|
110
114
|
if (result.askDeliveries === undefined) {
|
|
111
115
|
await this.inbox.endAll();
|
|
112
|
-
return;
|
|
116
|
+
return true;
|
|
113
117
|
}
|
|
114
|
-
if (result.closedDeliveryIds !== undefined && result.closedDeliveryIds.length > 0)
|
|
118
|
+
if (result.closedDeliveryIds !== undefined && result.closedDeliveryIds.length > 0) {
|
|
119
|
+
closuresHandled = false;
|
|
115
120
|
await this.inbox.close(result.closedDeliveryIds);
|
|
121
|
+
closuresHandled = true;
|
|
122
|
+
}
|
|
116
123
|
if (result.askDeliveries.length > 0)
|
|
117
124
|
await this.inbox.receive(result.askDeliveries);
|
|
118
125
|
}
|
|
@@ -120,6 +127,7 @@ export class AskRuntime {
|
|
|
120
127
|
this.log({ event: "ask_delivery_failed", code: errorCode(error) });
|
|
121
128
|
}
|
|
122
129
|
this.flushSoon();
|
|
130
|
+
return closuresHandled;
|
|
123
131
|
}
|
|
124
132
|
/**
|
|
125
133
|
* heartbeat runner.session_open.ask_inbox, or undefined when the hub is down or nothing is installed. While a start is
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class HeartbeatScheduler {
|
|
2
|
+
deps;
|
|
3
|
+
lastKey;
|
|
4
|
+
lastSentAt = Number.NEGATIVE_INFINITY;
|
|
5
|
+
timer;
|
|
6
|
+
stopped = false;
|
|
7
|
+
chain = Promise.resolve();
|
|
8
|
+
release;
|
|
9
|
+
done = new Promise(resolve => { this.release = resolve; });
|
|
10
|
+
constructor(deps) {
|
|
11
|
+
this.deps = deps;
|
|
12
|
+
}
|
|
13
|
+
/** One heartbeat check now, after any check in progress; never throws. */
|
|
14
|
+
tick() {
|
|
15
|
+
this.chain = this.chain.then(() => this.check());
|
|
16
|
+
return this.chain;
|
|
17
|
+
}
|
|
18
|
+
async check() {
|
|
19
|
+
let snapshot;
|
|
20
|
+
try {
|
|
21
|
+
snapshot = await this.deps.snapshot();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
this.report("HEARTBEAT_SNAPSHOT_FAILED");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const key = JSON.stringify(snapshot);
|
|
28
|
+
const now = (this.deps.now ?? Date.now)();
|
|
29
|
+
if (key === this.lastKey && now - this.lastSentAt < (this.deps.maxAgeMs ?? 600_000))
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
await this.deps.send(snapshot);
|
|
33
|
+
this.lastKey = key;
|
|
34
|
+
this.lastSentAt = now;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
this.report("HEARTBEAT_SEND_FAILED");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Keeps ticking until stop(). The first tick comes one interval in: the caller sends the first heartbeat itself. */
|
|
41
|
+
async run() {
|
|
42
|
+
const loop = () => {
|
|
43
|
+
if (this.stopped)
|
|
44
|
+
return;
|
|
45
|
+
this.timer = setTimeout(() => { void this.tick().then(loop); }, this.deps.intervalMs ?? 60_000);
|
|
46
|
+
};
|
|
47
|
+
loop();
|
|
48
|
+
await this.done;
|
|
49
|
+
}
|
|
50
|
+
stop() {
|
|
51
|
+
if (this.stopped)
|
|
52
|
+
return;
|
|
53
|
+
this.stopped = true;
|
|
54
|
+
clearTimeout(this.timer);
|
|
55
|
+
this.release();
|
|
56
|
+
}
|
|
57
|
+
report(code) {
|
|
58
|
+
try {
|
|
59
|
+
this.deps.reportError?.(code);
|
|
60
|
+
}
|
|
61
|
+
catch { /* Logging cannot stop heartbeats. */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export class JobRunner {
|
|
2
|
+
deps;
|
|
3
|
+
armed = false;
|
|
4
|
+
busy = false;
|
|
5
|
+
pending = false;
|
|
6
|
+
stopped = false;
|
|
7
|
+
inFlight;
|
|
8
|
+
release;
|
|
9
|
+
done = new Promise(resolve => { this.release = resolve; });
|
|
10
|
+
constructor(deps) {
|
|
11
|
+
this.deps = deps;
|
|
12
|
+
}
|
|
13
|
+
/** True while a claim or a job is in progress. */
|
|
14
|
+
get running() {
|
|
15
|
+
return this.busy;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* True while this runner may claim: after run() started and before stop(). A runner that does not accept jobs
|
|
19
|
+
* (still reconciling, or suspended after an unknown outcome) must not look ready to the Gateway.
|
|
20
|
+
*/
|
|
21
|
+
get accepting() {
|
|
22
|
+
return this.armed && !this.stopped;
|
|
23
|
+
}
|
|
24
|
+
notify() {
|
|
25
|
+
if (this.stopped)
|
|
26
|
+
return;
|
|
27
|
+
if (!this.armed || this.busy) {
|
|
28
|
+
this.pending = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.start();
|
|
32
|
+
}
|
|
33
|
+
async run() {
|
|
34
|
+
// A pending promise alone does not keep Node alive; this referenced timer does until stop().
|
|
35
|
+
const keepAlive = setInterval(() => undefined, 60_000);
|
|
36
|
+
try {
|
|
37
|
+
this.armed = true;
|
|
38
|
+
if (!this.stopped) {
|
|
39
|
+
try {
|
|
40
|
+
this.deps.onArmed?.();
|
|
41
|
+
}
|
|
42
|
+
catch { /* Telling the Gateway is best effort; claims still start. */ }
|
|
43
|
+
}
|
|
44
|
+
if (this.pending && !this.stopped) {
|
|
45
|
+
this.pending = false;
|
|
46
|
+
this.start();
|
|
47
|
+
}
|
|
48
|
+
await this.done;
|
|
49
|
+
// Like the old poll loop, run() ends only after the claim or job in progress has finished.
|
|
50
|
+
await this.inFlight;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearInterval(keepAlive);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
stop() {
|
|
57
|
+
if (this.stopped)
|
|
58
|
+
return;
|
|
59
|
+
this.stopped = true;
|
|
60
|
+
this.release();
|
|
61
|
+
}
|
|
62
|
+
start() {
|
|
63
|
+
const cycle = this.cycle();
|
|
64
|
+
this.inFlight = cycle;
|
|
65
|
+
void cycle.finally(() => { if (this.inFlight === cycle)
|
|
66
|
+
this.inFlight = undefined; });
|
|
67
|
+
}
|
|
68
|
+
async cycle() {
|
|
69
|
+
this.busy = true;
|
|
70
|
+
let stage = "claim";
|
|
71
|
+
try {
|
|
72
|
+
const claim = await this.deps.claim();
|
|
73
|
+
if (claim.status === "claimed" && claim.job !== undefined && !this.stopped) {
|
|
74
|
+
stage = "execute";
|
|
75
|
+
await this.deps.execute(claim.job);
|
|
76
|
+
this.deps.requestSync("after_job");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
try {
|
|
81
|
+
this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
|
|
82
|
+
}
|
|
83
|
+
catch { /* Observational logging cannot bypass the stop below or discard recovery state. */ }
|
|
84
|
+
// Unknown execution or terminal outcomes must reconcile before any new claim.
|
|
85
|
+
if (stage === "execute") {
|
|
86
|
+
this.busy = false;
|
|
87
|
+
this.stop();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
this.busy = false;
|
|
93
|
+
}
|
|
94
|
+
if (this.pending && !this.stopped) {
|
|
95
|
+
this.pending = false;
|
|
96
|
+
this.start();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -17,11 +17,16 @@ import { WorkspaceManager } from "../workspace/workspace-manager.js";
|
|
|
17
17
|
import { GitClient } from "../workspace/git-client.js";
|
|
18
18
|
import { FileDaemonStateStore } from "../state-store.js";
|
|
19
19
|
import { ExecutionCoordinator } from "./execution-coordinator.js";
|
|
20
|
-
import {
|
|
20
|
+
import { JobRunner } from "./job-runner.js";
|
|
21
|
+
import { HeartbeatScheduler } from "./heartbeat-scheduler.js";
|
|
21
22
|
import { ProcessLock } from "./process-lock.js";
|
|
22
23
|
import { Daemon } from "./daemon.js";
|
|
23
24
|
import { SessionOpener, sessionOpenAdvertisement } from "./session-opener.js";
|
|
24
|
-
import {
|
|
25
|
+
import { RunnerTransport } from "../transport/runner-transport.js";
|
|
26
|
+
import { ReverbSocket } from "../transport/reverb-socket.js";
|
|
27
|
+
import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-dispatch.js";
|
|
28
|
+
import { abortableDelay } from "../transport/abortable-delay.js";
|
|
29
|
+
import { sideLoops } from "../transport/side-loops.js";
|
|
25
30
|
import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
|
|
26
31
|
import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
27
32
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
@@ -115,60 +120,15 @@ export async function buildDaemon(input) {
|
|
|
115
120
|
workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
|
|
116
121
|
state,
|
|
117
122
|
});
|
|
118
|
-
|
|
123
|
+
let transport;
|
|
124
|
+
/** The adapter probe in progress, if any: a claim waits for it, so versions never change under a claim. */
|
|
125
|
+
let probing = Promise.resolve();
|
|
126
|
+
const poller = new JobRunner({
|
|
119
127
|
reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
current = await plugin.probe();
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
current = { available: false, authenticated: false, capabilities: [] };
|
|
128
|
-
}
|
|
129
|
-
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
130
|
-
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
131
|
-
if (index >= 0)
|
|
132
|
-
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
133
|
-
if (ready)
|
|
134
|
-
versions.claude_code_cli = '1';
|
|
135
|
-
else
|
|
136
|
-
delete versions.claude_code_cli;
|
|
137
|
-
}
|
|
138
|
-
let current;
|
|
139
|
-
try {
|
|
140
|
-
current = await codex.probe();
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
current = { available: false, authenticated: false, capabilities: [] };
|
|
144
|
-
}
|
|
145
|
-
const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
|
|
146
|
-
const index = probes.findIndex(p => p.id === 'codex_cli');
|
|
147
|
-
if (index >= 0)
|
|
148
|
-
probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
|
|
149
|
-
if (ready)
|
|
150
|
-
versions.codex_cli = '1';
|
|
151
|
-
else
|
|
152
|
-
delete versions.codex_cli;
|
|
153
|
-
let sessionOpen;
|
|
154
|
-
// A detection error must not stop job claims: the heartbeat simply omits the field.
|
|
155
|
-
try {
|
|
156
|
-
sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
sessionOpen = undefined;
|
|
160
|
-
}
|
|
161
|
-
const askInbox = await asks.advertisement();
|
|
162
|
-
await gateway.heartbeatLocalRunner({
|
|
163
|
-
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
164
|
-
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
165
|
-
capabilities: localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode),
|
|
166
|
-
mode: "full",
|
|
167
|
-
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
168
|
-
});
|
|
169
|
-
},
|
|
170
|
-
wait: async () => {
|
|
171
|
-
const claim = await gateway.waitForLocalJob(versions, 25);
|
|
128
|
+
// Push transport (Work Item 25d8ea74): claimed only when a sync says a job is claimable, with no wait.
|
|
129
|
+
claim: async () => {
|
|
130
|
+
await probing;
|
|
131
|
+
const claim = await gateway.waitForLocalJob(versions, 0);
|
|
172
132
|
if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
|
|
173
133
|
return { status: "no_job" };
|
|
174
134
|
if (production && claim.task_package.runtime_adapter === 'codex_cli')
|
|
@@ -186,23 +146,107 @@ export async function buildDaemon(input) {
|
|
|
186
146
|
execute: async (job) => {
|
|
187
147
|
await coordinator.execute(job);
|
|
188
148
|
},
|
|
189
|
-
|
|
149
|
+
requestSync: reason => { transport?.requestSync(reason); },
|
|
150
|
+
// Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
|
|
151
|
+
onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
|
|
190
152
|
});
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
153
|
+
const heartbeats = new HeartbeatScheduler({
|
|
154
|
+
intervalMs: 30_000,
|
|
155
|
+
snapshot: async () => {
|
|
156
|
+
// Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
|
|
157
|
+
if (!poller.running) {
|
|
158
|
+
const probe = (async () => {
|
|
159
|
+
if (input.claudeSupportedProbe) {
|
|
160
|
+
let current;
|
|
161
|
+
try {
|
|
162
|
+
current = await plugin.probe();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
current = { available: false, authenticated: false, capabilities: [] };
|
|
166
|
+
}
|
|
167
|
+
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
168
|
+
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
169
|
+
if (index >= 0)
|
|
170
|
+
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
171
|
+
if (ready)
|
|
172
|
+
versions.claude_code_cli = '1';
|
|
173
|
+
else
|
|
174
|
+
delete versions.claude_code_cli;
|
|
175
|
+
}
|
|
176
|
+
let current;
|
|
177
|
+
try {
|
|
178
|
+
current = await codex.probe();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
current = { available: false, authenticated: false, capabilities: [] };
|
|
182
|
+
}
|
|
183
|
+
const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
|
|
184
|
+
const index = probes.findIndex(p => p.id === 'codex_cli');
|
|
185
|
+
if (index >= 0)
|
|
186
|
+
probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
|
|
187
|
+
if (ready)
|
|
188
|
+
versions.codex_cli = '1';
|
|
189
|
+
else
|
|
190
|
+
delete versions.codex_cli;
|
|
191
|
+
})();
|
|
192
|
+
probing = probe.catch(() => undefined);
|
|
193
|
+
await probe;
|
|
194
|
+
}
|
|
195
|
+
let sessionOpen;
|
|
196
|
+
// A detection error must not stop job claims: the heartbeat simply omits the field.
|
|
197
|
+
try {
|
|
198
|
+
sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
sessionOpen = undefined;
|
|
202
|
+
}
|
|
203
|
+
const askInbox = await asks.advertisement();
|
|
204
|
+
return {
|
|
205
|
+
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
206
|
+
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
207
|
+
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
|
|
208
|
+
mode: "full",
|
|
209
|
+
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
210
|
+
};
|
|
197
211
|
},
|
|
212
|
+
send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
|
|
213
|
+
reportError: code => { process.stderr.write(`${JSON.stringify({ event: "heartbeat_failed", code })}\n`); },
|
|
214
|
+
});
|
|
215
|
+
const stopping = new AbortController();
|
|
216
|
+
const closureAcks = new PendingClosureAcks();
|
|
217
|
+
const opener = new SessionOpener({ home: input.env.HOME ?? "" });
|
|
218
|
+
const dispatch = createSyncDispatcher({
|
|
198
219
|
open: request => opener.open(request),
|
|
199
220
|
report: async (openId, result, signal) => {
|
|
200
221
|
await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
|
|
201
222
|
failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
|
|
202
223
|
},
|
|
224
|
+
asks,
|
|
225
|
+
closureAcks,
|
|
226
|
+
jobs: poller,
|
|
203
227
|
delay: abortableDelay,
|
|
228
|
+
signal: stopping.signal,
|
|
204
229
|
reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
|
|
205
230
|
});
|
|
231
|
+
const pushTransport = new RunnerTransport({
|
|
232
|
+
sync: async (reason, signal) => {
|
|
233
|
+
const acknowledged = closureAcks.take();
|
|
234
|
+
// A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
|
|
235
|
+
try {
|
|
236
|
+
return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged }, signal);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
closureAcks.restore(acknowledged);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
|
|
244
|
+
dispatch,
|
|
245
|
+
openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
|
|
246
|
+
report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
|
|
247
|
+
});
|
|
248
|
+
transport = pushTransport;
|
|
249
|
+
const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
|
|
206
250
|
const daemon = new Daemon({
|
|
207
251
|
lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
|
|
208
252
|
reconcile: async (stored) => {
|
|
@@ -222,6 +266,20 @@ export async function buildDaemon(input) {
|
|
|
222
266
|
return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
|
|
223
267
|
}
|
|
224
268
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
|
269
|
+
/** The execution blocker a full runner reports for every adapter while it takes no jobs. */
|
|
270
|
+
export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
|
|
271
|
+
/*
|
|
272
|
+
* Work Item 25d8ea74: sync keeps presence fresh even while the daemon reconciles or is suspended for recovery, which
|
|
273
|
+
* the old job loop's heartbeat never did. So the heartbeat itself must say the runner takes no jobs, or the Gateway
|
|
274
|
+
* would route a job to a runner that never claims it. The Gateway requires at least one capability from a full
|
|
275
|
+
* runner, so each adapter is reported blocked, with this code, instead of being left out.
|
|
276
|
+
*/
|
|
277
|
+
export function acceptingJobs(capabilities, accepting) {
|
|
278
|
+
if (accepting)
|
|
279
|
+
return capabilities;
|
|
280
|
+
return capabilities.map(capability => ({ ...capability, authenticated: false, execution_status: "blocked",
|
|
281
|
+
execution_reason_codes: [...new Set([RUNNER_NOT_ACCEPTING_JOBS, ...capability.execution_reason_codes])].slice(0, 20) }));
|
|
282
|
+
}
|
|
225
283
|
/** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
|
|
226
284
|
export async function reconcilePreparationState(previous, deps) {
|
|
227
285
|
if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
|