deepdebug-local-agent 1.9.0 → 1.11.0
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/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +62 -0
- package/docs/la-protocol-v2.md +7 -5
- package/env.example +12 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
- package/src/la-protocol-v2/contract/README.md +75 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +43 -0
- package/src/la-protocol-v2/jobs/manager.js +281 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +432 -0
- package/src/la-protocol-v2/llm/config.js +63 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- package/src/la-protocol-v2/llm-key/store.js +10 -0
- package/src/la-protocol-v2/runtime.js +69 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supervisor.js - keeps agent2 (the packaged agent loop, profile local-agent) available for the runner (F3-08c, Local Agent half).
|
|
3
|
+
*
|
|
4
|
+
* spawn (LOCAL_AGENT_AGENT2_CMD) this agent starts the process, with a minimal environment and a NEW random API token at every start; waits for
|
|
5
|
+
* `/local/v1/health`; probes it while it runs; restarts it (at the next job) after a crash, at most `maxRestarts`
|
|
6
|
+
* times inside `restartWindowMs`, then reports `unavailable` instead of looping; kills it above `maxRssBytes`;
|
|
7
|
+
* stops it with the agent (SIGTERM, then SIGKILL after `stopGraceMs`).
|
|
8
|
+
* attach (LOCAL_AGENT_AGENT2_URL) agent2 already runs (sidecar); only health is checked. The token is the one it was started with.
|
|
9
|
+
*
|
|
10
|
+
* The environment of the child is an ALLOWLIST (PATH, HOME, JAVA_HOME, locale, proxies, the git/ssh variables the machine's own credentials need) plus
|
|
11
|
+
* what this module sets and what the operator named in LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH (never a name that looks like a secret). Nothing else of this
|
|
12
|
+
* agent's environment reaches agent2: not the LLM key (it lives in the holder, in memory), not LOCAL_AGENT_TOKEN's siblings, not the signing keys.
|
|
13
|
+
* stdout/stderr of the child are drained and DROPPED: they may hold customer content; agent2's own logging is configured in agent2.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn as nodeSpawn, execFile } from "node:child_process";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { createAgent2Api } from "./api-client.js";
|
|
22
|
+
import { isSecretEnvName } from "../../../utils/child-env.js";
|
|
23
|
+
|
|
24
|
+
const ALLOW = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TMP", "TEMP", "JAVA_HOME",
|
|
25
|
+
"SSH_AUTH_SOCK", "GIT_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_TERMINAL_PROMPT",
|
|
26
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "SYSTEMROOT", "PATHEXT", "COMSPEC"]);
|
|
27
|
+
|
|
28
|
+
export class Agent2UnavailableError extends Error {
|
|
29
|
+
constructor(code) { super(code); this.name = "Agent2UnavailableError"; this.code = code; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });
|
|
33
|
+
const defaultRss = (pid) => new Promise((resolve) => {
|
|
34
|
+
execFile("ps", ["-o", "rss=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
|
|
35
|
+
const kb = err ? 0 : Number.parseInt(String(out).trim(), 10);
|
|
36
|
+
resolve(Number.isFinite(kb) && kb > 0 ? kb * 1024 : 0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The full command line of a process, or null when it cannot be told (no `ps`, no such process). */
|
|
41
|
+
const defaultCmdline = (pid) => new Promise((resolve) => {
|
|
42
|
+
execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
|
|
43
|
+
const t = err ? "" : String(out).trim();
|
|
44
|
+
resolve(t || null);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const isAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
|
|
49
|
+
|
|
50
|
+
// Children still running when THIS process exits normally (SIGTERM/SIGINT handlers, process.exit): ended synchronously, best effort. A SIGKILL of this agent skips
|
|
51
|
+
// 'exit' handlers: that case is the pidfile's (the next start ends the orphan).
|
|
52
|
+
const liveChildren = new Set();
|
|
53
|
+
let exitHooked = false;
|
|
54
|
+
function hookExit() {
|
|
55
|
+
if (exitHooked) return;
|
|
56
|
+
exitHooked = true;
|
|
57
|
+
process.once("exit", () => { for (const c of liveChildren) { try { c.kill("SIGKILL"); } catch { /* gone */ } } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The environment agent2 starts with (pure, so a test can pin it). */
|
|
61
|
+
export function agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken }) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const k of ALLOW) if (env[k] !== undefined) out[k] = env[k];
|
|
64
|
+
for (const k of config.passthrough || []) if (env[k] !== undefined && !isSecretEnvName(k)) out[k] = env[k];
|
|
65
|
+
Object.assign(out, {
|
|
66
|
+
AGENT2_LOCAL_MODE: "true",
|
|
67
|
+
AGENT2_LOCAL_BIND: "127.0.0.1",
|
|
68
|
+
AGENT2_LOCAL_PORT: String(config.apiPort),
|
|
69
|
+
AGENT2_LOCAL_API_TOKEN: token,
|
|
70
|
+
AGENT2_LOCAL_TENANT_ID: tenantId,
|
|
71
|
+
AGENT2_LLM_BASE_URL: `${bridgeBase}/v1`, // the loopback bridge: DLP + the LLM client of this agent, never the AI gateway directly
|
|
72
|
+
// agent2 reaches this agent's /workspace API THROUGH the bridge, which signs for it (internal marker) and adds the API's X-Agent-Token itself: the token agent2 holds
|
|
73
|
+
// here is the bridge's callback token, NOT LOCAL_AGENT_TOKEN, and it opens nothing but the bridge
|
|
74
|
+
AGENT2_LA_WORKSPACE_URL: bridgeBase,
|
|
75
|
+
AGENT2_LA_WORKSPACE_TOKEN: callbackToken,
|
|
76
|
+
AGENT2_LA_CALLBACK_URL: bridgeBase, // POST {url}/agent2/cp-request: the PolicyGate door (-> policy.check over the v2 channel)
|
|
77
|
+
AGENT2_LA_CALLBACK_TOKEN: callbackToken,
|
|
78
|
+
DEEPDEBUG_POLICYGATE_TIMEOUT_MS: String(config.policyCheckTimeoutMs + 3_000), // agent2 must wait longer than the channel does (default 3 s would cut a decision off)
|
|
79
|
+
});
|
|
80
|
+
if (config.maxHeapMb > 0) out.JAVA_TOOL_OPTIONS = `-Xmx${config.maxHeapMb}m`;
|
|
81
|
+
if (config.approvalWaitSeconds > 0) out.AGENT2_LOCAL_APPROVAL_WAIT_SECONDS = String(config.approvalWaitSeconds);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAgent2Supervisor({ config, tenantId, bridge, env = process.env, log = console, spawnImpl = nodeSpawn, fetchImpl, now = Date.now, sleep = defaultSleep, rssOf = defaultRss, cmdlineOf = defaultCmdline }) {
|
|
86
|
+
const emitter = new EventEmitter();
|
|
87
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
88
|
+
const info = (m) => (log.info || log.log).call(log, m);
|
|
89
|
+
const stats = { starts: 0, exits: 0, healthFailed: 0, killedForRss: 0, startFailures: 0, refusedRestartLimit: 0, orphansEnded: 0, orphanChecksSkipped: 0 };
|
|
90
|
+
const cmdSha = config.mode === "spawn" ? crypto.createHash("sha256").update(JSON.stringify(config.cmd)).digest("hex") : "";
|
|
91
|
+
const pidFile = config.mode === "spawn" ? (config.pidFile || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".deepdebug", "agent2-runner.pid")) : null;
|
|
92
|
+
const startTimes = [];
|
|
93
|
+
let state = "idle"; // idle | starting | ready | down | stopped
|
|
94
|
+
let child = null;
|
|
95
|
+
let target = config.mode === "attach" ? { baseUrl: config.url, token: config.token } : null;
|
|
96
|
+
let starting = null;
|
|
97
|
+
let healthTimer = null;
|
|
98
|
+
let healthFails = 0;
|
|
99
|
+
let lastExitAt = 0;
|
|
100
|
+
let stopping = false;
|
|
101
|
+
|
|
102
|
+
// ---- the pidfile: a child that outlived this agent (SIGKILL, crash) would keep the API port and an old token; the next start ends it
|
|
103
|
+
function writePidFile(pid) {
|
|
104
|
+
if (!pidFile || !pid) return;
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true, mode: 0o700 });
|
|
107
|
+
fs.writeFileSync(pidFile, JSON.stringify({ pid, cmdSha, startedAt: now() }), { mode: 0o600 });
|
|
108
|
+
} catch { warn("[la-agent2] could not write the pidfile of agent2; a restart of this agent will not be able to end an orphan"); }
|
|
109
|
+
}
|
|
110
|
+
function removePidFile(onlyPid) {
|
|
111
|
+
if (!pidFile) return;
|
|
112
|
+
try {
|
|
113
|
+
if (onlyPid) { const cur = JSON.parse(fs.readFileSync(pidFile, "utf8")); if (cur && cur.pid !== onlyPid) return; }
|
|
114
|
+
fs.rmSync(pidFile, { force: true });
|
|
115
|
+
} catch { /* nothing to remove */ }
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* An agent2 left by a previous run of this agent: ended (SIGTERM, then SIGKILL after the grace) only when the pid is alive AND its command line is the command
|
|
119
|
+
* this agent starts (a reused pid of another program is never touched). Where the command line cannot be read (no `ps`) nothing is killed.
|
|
120
|
+
*/
|
|
121
|
+
async function reapOrphan() {
|
|
122
|
+
if (!pidFile) return;
|
|
123
|
+
let rec;
|
|
124
|
+
try { rec = JSON.parse(fs.readFileSync(pidFile, "utf8")); } catch { return; }
|
|
125
|
+
const pid = rec && Number.isSafeInteger(rec.pid) && rec.pid > 1 ? rec.pid : 0;
|
|
126
|
+
if (!pid || pid === process.pid || !isAlive(pid)) { removePidFile(); return; }
|
|
127
|
+
const line = await cmdlineOf(pid);
|
|
128
|
+
if (line === null) { stats.orphanChecksSkipped++; warn("[la-agent2] the pidfile names a live process but its command line cannot be read; it is left alone"); return; }
|
|
129
|
+
if (rec.cmdSha !== cmdSha || !config.cmd.slice(0, 8).every((a) => line.includes(a))) { removePidFile(); return; } // not ours (any more): the pid was reused
|
|
130
|
+
warn("[la-agent2] an agent2 from a previous run is still alive; ending it");
|
|
131
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
|
|
132
|
+
const deadline = now() + config.stopGraceMs;
|
|
133
|
+
while (isAlive(pid) && now() < deadline) await sleep(50);
|
|
134
|
+
if (isAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } await sleep(100); }
|
|
135
|
+
if (isAlive(pid)) throw new Agent2UnavailableError("orphan_alive");
|
|
136
|
+
stats.orphansEnded++;
|
|
137
|
+
removePidFile();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const api = createAgent2Api({ getTarget: () => { if (!target) throw new Agent2UnavailableError("agent2_down"); return target; }, requestTimeoutMs: config.requestTimeoutMs, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
141
|
+
|
|
142
|
+
function markDown(why) {
|
|
143
|
+
if (state === "down" || state === "stopped") return;
|
|
144
|
+
state = "down";
|
|
145
|
+
if (config.mode === "spawn") target = null;
|
|
146
|
+
emitter.emit("down", { why });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startHealthTimer() {
|
|
150
|
+
clearInterval(healthTimer);
|
|
151
|
+
healthFails = 0;
|
|
152
|
+
healthTimer = setInterval(async () => {
|
|
153
|
+
if (state !== "ready") return;
|
|
154
|
+
if (await api.health()) healthFails = 0;
|
|
155
|
+
else if (++healthFails >= config.healthFailures) {
|
|
156
|
+
stats.healthFailed++;
|
|
157
|
+
warn("[la-agent2] agent2 stopped answering its health probe");
|
|
158
|
+
if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
|
|
159
|
+
markDown("health");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (config.mode === "spawn" && config.maxRssBytes > 0 && child && child.pid) {
|
|
163
|
+
const rss = await rssOf(child.pid);
|
|
164
|
+
if (rss > config.maxRssBytes) { stats.killedForRss++; warn("[la-agent2] agent2 is above LOCAL_AGENT_AGENT2_MAX_RSS_BYTES; stopping it (a new one starts at the next job)"); try { child.kill("SIGKILL"); } catch { /* gone */ } markDown("rss"); }
|
|
165
|
+
}
|
|
166
|
+
}, config.healthIntervalMs);
|
|
167
|
+
if (healthTimer.unref) healthTimer.unref();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function waitHealthy(proc) {
|
|
171
|
+
const deadline = now() + config.startTimeoutMs;
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (stopping) throw new Agent2UnavailableError("stopped");
|
|
174
|
+
if (proc && proc.exited) throw new Agent2UnavailableError("exited_during_start");
|
|
175
|
+
if (await api.health()) return;
|
|
176
|
+
if (now() >= deadline) throw new Agent2UnavailableError("start_timeout");
|
|
177
|
+
await sleep(Math.min(250, Math.max(10, config.startTimeoutMs / 20)));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function startAttach() {
|
|
182
|
+
state = "starting";
|
|
183
|
+
try { await waitHealthy(null); } catch (e) { state = "down"; stats.startFailures++; throw e; }
|
|
184
|
+
state = "ready";
|
|
185
|
+
startHealthTimer();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function startSpawn() {
|
|
189
|
+
const t = now();
|
|
190
|
+
while (startTimes.length && t - startTimes[0] > config.restartWindowMs) startTimes.shift();
|
|
191
|
+
if (startTimes.length >= config.maxRestarts + 1) { stats.refusedRestartLimit++; throw new Agent2UnavailableError("restart_limit"); }
|
|
192
|
+
if (startTimes.length > 0) { const wait = Math.max(0, lastExitAt + config.restartBackoffMs * startTimes.length - t); if (wait > 0) await sleep(wait); }
|
|
193
|
+
startTimes.push(now());
|
|
194
|
+
state = "starting";
|
|
195
|
+
stats.starts++;
|
|
196
|
+
try { await reapOrphan(); } catch (e) { state = "down"; stats.startFailures++; throw e; }
|
|
197
|
+
const token = crypto.randomBytes(32).toString("base64url"); // a NEW secret per start: nothing from a previous process works
|
|
198
|
+
const callbackToken = crypto.randomBytes(32).toString("base64url");
|
|
199
|
+
const { baseUrl: bridgeBase } = await bridge.listen();
|
|
200
|
+
bridge.setCallbackToken(callbackToken);
|
|
201
|
+
const childEnv = agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken });
|
|
202
|
+
const proc = { exited: false };
|
|
203
|
+
let c;
|
|
204
|
+
try {
|
|
205
|
+
c = spawnImpl(config.cmd[0], config.cmd.slice(1), { env: childEnv, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
|
|
206
|
+
} catch { state = "down"; stats.startFailures++; throw new Agent2UnavailableError("spawn_failed"); }
|
|
207
|
+
child = c;
|
|
208
|
+
liveChildren.add(c); hookExit();
|
|
209
|
+
writePidFile(c.pid);
|
|
210
|
+
for (const s of [c.stdout, c.stderr]) { if (s && typeof s.on === "function") { s.on("data", () => {}); s.on("error", () => {}); } } // drained and dropped
|
|
211
|
+
c.on("error", () => { proc.exited = true; liveChildren.delete(c); });
|
|
212
|
+
c.on("exit", (code, signal) => {
|
|
213
|
+
proc.exited = true;
|
|
214
|
+
stats.exits++;
|
|
215
|
+
lastExitAt = now();
|
|
216
|
+
liveChildren.delete(c);
|
|
217
|
+
removePidFile(c.pid);
|
|
218
|
+
if (child === c) child = null;
|
|
219
|
+
if (!stopping) warn(`[la-agent2] agent2 exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
|
|
220
|
+
markDown("exit");
|
|
221
|
+
});
|
|
222
|
+
info(`[la-agent2] started agent2 (pid ${c.pid ?? "?"})`);
|
|
223
|
+
target = { baseUrl: `http://127.0.0.1:${config.apiPort}`, token };
|
|
224
|
+
try { await waitHealthy(proc); } catch (e) {
|
|
225
|
+
stats.startFailures++;
|
|
226
|
+
try { c.kill("SIGKILL"); } catch { /* gone */ }
|
|
227
|
+
state = "down"; target = null;
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
if (proc.exited) { state = "down"; target = null; throw new Agent2UnavailableError("exited_during_start"); }
|
|
231
|
+
state = "ready";
|
|
232
|
+
startHealthTimer();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
api, stats,
|
|
237
|
+
on: (ev, fn) => emitter.on(ev, fn),
|
|
238
|
+
off: (ev, fn) => emitter.off(ev, fn),
|
|
239
|
+
get state() { return state; },
|
|
240
|
+
get pid() { return child ? child.pid : null; },
|
|
241
|
+
/** Resolves when agent2 answers its health probe (starting it first in spawn mode). Throws Agent2UnavailableError(code). */
|
|
242
|
+
async ensureReady() {
|
|
243
|
+
if (stopping) throw new Agent2UnavailableError("stopped");
|
|
244
|
+
if (state === "ready") {
|
|
245
|
+
if (await api.health()) return;
|
|
246
|
+
if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
|
|
247
|
+
markDown("health");
|
|
248
|
+
}
|
|
249
|
+
if (!starting) starting = (config.mode === "attach" ? startAttach() : startSpawn()).finally(() => { starting = null; });
|
|
250
|
+
await starting;
|
|
251
|
+
},
|
|
252
|
+
/** Stops the child (SIGTERM, then SIGKILL after the grace) and every timer. */
|
|
253
|
+
async stop() {
|
|
254
|
+
stopping = true;
|
|
255
|
+
state = "stopped";
|
|
256
|
+
clearInterval(healthTimer);
|
|
257
|
+
const c = child;
|
|
258
|
+
child = null;
|
|
259
|
+
if (!c) return;
|
|
260
|
+
await new Promise((resolve) => {
|
|
261
|
+
let done = false;
|
|
262
|
+
const fin = () => { if (!done) { done = true; clearTimeout(t); resolve(); } };
|
|
263
|
+
const t = setTimeout(() => { try { c.kill("SIGKILL"); } catch { /* gone */ } fin(); }, config.stopGraceMs); // not unref'd: whoever awaits stop() waits at most stopGraceMs
|
|
264
|
+
c.once("exit", fin);
|
|
265
|
+
try { if (!c.kill("SIGTERM")) fin(); } catch { fin(); }
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - LOCAL_AGENT_JOBS flag and limits of the job.assign receiver (F3-10).
|
|
3
|
+
*
|
|
4
|
+
* false (default) `job.assign` and `job.cancel` keep being acked `rejected protocol_unsupported`, the capability `job.assign` is NOT announced:
|
|
5
|
+
* nothing of this folder is loaded.
|
|
6
|
+
* true the agent receives `job.assign`, validates it, acks it and hands it to a RUNNER (runners.js); `job.cancel` stops it; progress,
|
|
7
|
+
* cost and the result go back as `job.event` / `job.result` (metadata only). Needs LOCAL_AGENT_PROTOCOL_V2=true and
|
|
8
|
+
* LOCAL_AGENT_DLP_MODE=enforce (a job reads client files), and a runner: LOCAL_AGENT_JOBS_RUNNER=smoke (the reference runner,
|
|
9
|
+
* which also needs the LLM client) or one passed to buildV2Link by the packaging of the agent loop (F3-08c). Without a runner the
|
|
10
|
+
* receiver is NOT installed and the capability is NOT announced.
|
|
11
|
+
*
|
|
12
|
+
* Unset/empty/unrecognised = false. Every number is clamped: a bad value falls back to the default, never to "unlimited".
|
|
13
|
+
*/
|
|
14
|
+
export const JOBS_FLAG = "LOCAL_AGENT_JOBS";
|
|
15
|
+
|
|
16
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
17
|
+
const FALSE = new Set(["", "false", "0", "no", "off"]);
|
|
18
|
+
const KIB = 1024;
|
|
19
|
+
const MIB = 1024 * KIB;
|
|
20
|
+
|
|
21
|
+
function int(raw, def, min, max) {
|
|
22
|
+
const n = Number.parseInt(String(raw ?? ""), 10);
|
|
23
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : def;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const KNOWN_RUNNERS = Object.freeze(["smoke"]);
|
|
27
|
+
|
|
28
|
+
export function resolveJobsConfig(env = process.env, warn = console.warn) {
|
|
29
|
+
const raw = String(env[JOBS_FLAG] ?? "").trim().toLowerCase();
|
|
30
|
+
if (!TRUE.has(raw)) {
|
|
31
|
+
if (!FALSE.has(raw)) warn(`[la-jobs] unrecognised ${JOBS_FLAG} value; job.assign stays off`);
|
|
32
|
+
return { enabled: false };
|
|
33
|
+
}
|
|
34
|
+
let runner = String(env.LOCAL_AGENT_JOBS_RUNNER ?? "").trim().toLowerCase() || null;
|
|
35
|
+
if (runner && !KNOWN_RUNNERS.includes(runner)) { warn("[la-jobs] unknown LOCAL_AGENT_JOBS_RUNNER; no runner selected"); runner = null; }
|
|
36
|
+
const maxRss = Number.parseInt(String(env.LOCAL_AGENT_JOBS_MAX_RSS_BYTES ?? ""), 10);
|
|
37
|
+
return {
|
|
38
|
+
enabled: true,
|
|
39
|
+
runner,
|
|
40
|
+
maxConcurrent: int(env.LOCAL_AGENT_JOBS_MAX_CONCURRENT, 1, 1, 8), // jobs running at once
|
|
41
|
+
maxQueued: int(env.LOCAL_AGENT_JOBS_MAX_QUEUED, 4, 0, 64), // accepted and waiting for a slot (state `queued`); beyond it: rate_limited
|
|
42
|
+
// a job asks for a budget; the agent never grants more than this (clamped, not refused: the job then ends budget_exceeded / timeout)
|
|
43
|
+
maxTokens: int(env.LOCAL_AGENT_JOBS_MAX_TOKENS, 2_000_000, 1, 10_000_000),
|
|
44
|
+
maxSeconds: int(env.LOCAL_AGENT_JOBS_MAX_SECONDS, 3_600, 1, 86_400),
|
|
45
|
+
// job.assign `options.maxIterations` caps the agent loop BELOW this ceiling, never above it (options.js); also the value when the job names none
|
|
46
|
+
maxIterations: int(env.LOCAL_AGENT_JOBS_MAX_ITERATIONS, 25, 1, 100),
|
|
47
|
+
// memory: each admitted job reserves its inline bytes + memPerJobBytes against maxMemoryBytes; with maxRssBytes > 0 a new job is also
|
|
48
|
+
// refused while the process RSS is above it (a runner is code in this process: the reservation is accounting, the RSS check is the brake)
|
|
49
|
+
memPerJobBytes: int(env.LOCAL_AGENT_JOBS_MEM_PER_JOB_BYTES, 32 * MIB, MIB, 4 * 1024 * MIB),
|
|
50
|
+
maxMemoryBytes: int(env.LOCAL_AGENT_JOBS_MAX_MEMORY_BYTES, 256 * MIB, 16 * MIB, 64 * 1024 * MIB),
|
|
51
|
+
maxRssBytes: Number.isFinite(maxRss) && maxRss >= 128 * MIB ? maxRss : 0,
|
|
52
|
+
eventMinIntervalMs: int(env.LOCAL_AGENT_JOBS_EVENT_MIN_MS, 1_000, 50, 60_000), // progress job.event coalescing (the last one wins); terminal ones are never dropped
|
|
53
|
+
terminalRetries: int(env.LOCAL_AGENT_JOBS_TERMINAL_RETRIES, 20, 0, 200),
|
|
54
|
+
terminalRetryMs: int(env.LOCAL_AGENT_JOBS_TERMINAL_RETRY_MS, 500, 10, 10_000),
|
|
55
|
+
cancelGraceMs: int(env.LOCAL_AGENT_JOBS_CANCEL_GRACE_MS, 10_000, 100, 120_000), // after job.cancel a runner that has not stopped is abandoned
|
|
56
|
+
recentJobs: 256, // finished job ids remembered, so a repeated job.assign is idempotent
|
|
57
|
+
smokeMaxFileBytes: int(env.LOCAL_AGENT_JOBS_SMOKE_MAX_FILE_BYTES, 64 * KIB, KIB, MIB),
|
|
58
|
+
auditFile: String(env.LOCAL_AGENT_JOBS_AUDIT_FILE ?? "").trim() || null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events.js - LA -> CP `job.event` and `job.result` of one job (F3-10). METADATA ONLY: the payloads are built from a fixed whitelist of
|
|
3
|
+
* counters, enums and ids (the contract has no free-text field; the session re-validates every frame against the closed schemas before a byte
|
|
4
|
+
* is written). Nothing of the work itself (prompts, answers, paths, diffs) can be put here.
|
|
5
|
+
*
|
|
6
|
+
* progress coalesced: at most one frame per `eventMinIntervalMs` per job, the LAST state wins (a fast runner cannot flood the channel; a
|
|
7
|
+
* full send queue (`rate_limited`) or a detached session drops that frame and counts it: progress is advisory).
|
|
8
|
+
* terminal `job.event` (succeeded|failed|cancelled) then `job.result`: never dropped on backpressure; retried every `terminalRetryMs`
|
|
9
|
+
* up to `terminalRetries` times while the queue is full or the session is between connections, then given up (audited).
|
|
10
|
+
*/
|
|
11
|
+
import { ProtocolError } from "../errors.js";
|
|
12
|
+
|
|
13
|
+
const STATES = new Set(["queued", "running", "waiting_approval", "succeeded", "failed", "cancelled"]);
|
|
14
|
+
const PHASES = new Set(["init", "context", "plan", "edit", "validate", "pr", "done"]);
|
|
15
|
+
export const JOB_ERROR_CODES = Object.freeze(["none", "llm_error", "policy_denied", "build_failed", "tests_failed", "timeout", "cancelled", "budget_exceeded", "infra", "unknown"]);
|
|
16
|
+
const OUTCOMES = new Set(["pr_opened", "no_change", "failed", "cancelled"]);
|
|
17
|
+
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
18
|
+
|
|
19
|
+
/** The `job.event` payload of a job snapshot (unknown or invalid fields are left out, never repaired). */
|
|
20
|
+
export function eventPayload(snap) {
|
|
21
|
+
const p = { jobId: snap.jobId, state: STATES.has(snap.state) ? snap.state : "running" };
|
|
22
|
+
if (PHASES.has(snap.phase)) p.phase = snap.phase;
|
|
23
|
+
if (Number.isInteger(snap.iteration) && snap.iteration >= 0 && snap.iteration <= 1000) p.iteration = snap.iteration;
|
|
24
|
+
for (const k of ["filesRead", "filesChanged", "tokensIn", "tokensOut", "durationMs"]) if (isCount(snap[k])) p[k] = snap[k];
|
|
25
|
+
if (typeof snap.costUsd === "number" && Number.isFinite(snap.costUsd) && snap.costUsd >= 0 && snap.costUsd <= 100000) p.costUsd = Math.round(snap.costUsd * 1e6) / 1e6;
|
|
26
|
+
if (JOB_ERROR_CODES.includes(snap.errorCode)) p.errorCode = snap.errorCode;
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `job.result` payload: the outcome and the fields a runner may report (the schema validates their shapes; an invalid one drops the whole extra set). */
|
|
31
|
+
export function resultPayload(jobId, outcome, extra = {}) {
|
|
32
|
+
const p = { jobId, outcome: OUTCOMES.has(outcome) ? outcome : "failed" };
|
|
33
|
+
for (const k of ["prUrl", "branch", "commitSha", "diffSha256"]) if (typeof extra[k] === "string") p[k] = extra[k];
|
|
34
|
+
if (isCount(extra.filesChangedCount)) p.filesChangedCount = extra.filesChangedCount;
|
|
35
|
+
const t = extra.tests;
|
|
36
|
+
if (t && isCount(t.pass) && isCount(t.fail) && isCount(t.skip)) p.tests = { pass: t.pass, fail: t.fail, skip: t.skip };
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const RETRYABLE = new Set(["rate_limited", "not_ready"]);
|
|
41
|
+
|
|
42
|
+
export function createJobEvents({ session, config, audit = null, now = Date.now, sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); }) }) {
|
|
43
|
+
const stats = { progressSent: 0, progressDropped: 0, terminalSent: 0, terminalGivenUp: 0 };
|
|
44
|
+
const timers = new Set();
|
|
45
|
+
let closed = false;
|
|
46
|
+
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* never breaks the data path */ } };
|
|
47
|
+
|
|
48
|
+
async function enqueueOnce(type, payload, workspaceId) {
|
|
49
|
+
const h = await session.enqueue(type, payload, { workspaceId });
|
|
50
|
+
h.acked.catch(() => {}); // the session retransmits; a lost ack is not this module's business
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function sendReliable(type, payload, workspaceId, jobShort) {
|
|
54
|
+
for (let i = 0; ; i++) {
|
|
55
|
+
if (closed) return { ok: false, code: "agent_offline" };
|
|
56
|
+
try { await enqueueOnce(type, payload, workspaceId); return { ok: true }; } catch (e) {
|
|
57
|
+
const code = e instanceof ProtocolError ? e.code : "internal";
|
|
58
|
+
if (code === "schema_violation") return { ok: false, code }; // the caller may fall back to a smaller payload
|
|
59
|
+
if (!RETRYABLE.has(code) || i >= config.terminalRetries) { stats.terminalGivenUp++; note("job_event_given_up", { jobShort, type, code, tries: i + 1 }); return { ok: false, code }; }
|
|
60
|
+
await sleep(config.terminalRetryMs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
stats,
|
|
67
|
+
/** Advisory: coalesced, may be dropped. `job.ev` is this module's private per-job state. */
|
|
68
|
+
progress(job, snap) {
|
|
69
|
+
if (closed) return;
|
|
70
|
+
const ev = job.ev || (job.ev = { last: 0, timer: null, pending: null });
|
|
71
|
+
ev.pending = eventPayload(snap);
|
|
72
|
+
if (ev.timer) return;
|
|
73
|
+
const flush = () => {
|
|
74
|
+
ev.timer = null;
|
|
75
|
+
const payload = ev.pending;
|
|
76
|
+
ev.pending = null;
|
|
77
|
+
if (!payload || closed) return;
|
|
78
|
+
ev.last = now();
|
|
79
|
+
enqueueOnce("job.event", payload, job.workspaceId).then(() => { stats.progressSent++; }, () => { stats.progressDropped++; });
|
|
80
|
+
};
|
|
81
|
+
const wait = Math.max(0, ev.last + config.eventMinIntervalMs - now());
|
|
82
|
+
if (wait === 0) { flush(); return; }
|
|
83
|
+
ev.timer = setTimeout(flush, wait);
|
|
84
|
+
if (ev.timer.unref) ev.timer.unref();
|
|
85
|
+
timers.add(ev.timer);
|
|
86
|
+
},
|
|
87
|
+
/**
|
|
88
|
+
* The end: the pending progress frame is discarded (the terminal event carries the totals). The RESULT goes out BEFORE the terminal
|
|
89
|
+
* event: the control plane closes the job on the first terminal frame it sees and drops anything that comes after it, so a result
|
|
90
|
+
* sent after the terminal event (prUrl, branch, files changed) would be lost. Both are always sent; true only if both were accepted.
|
|
91
|
+
*/
|
|
92
|
+
async terminal(job, snap, result) {
|
|
93
|
+
const ev = job.ev;
|
|
94
|
+
if (ev) { if (ev.timer) { clearTimeout(ev.timer); timers.delete(ev.timer); ev.timer = null; } ev.pending = null; }
|
|
95
|
+
const jobShort = job.short;
|
|
96
|
+
let r = await sendReliable("job.result", result, job.workspaceId, jobShort);
|
|
97
|
+
// a runner-supplied field the schema refuses (a malformed PR url) must not lose the result: send the outcome alone
|
|
98
|
+
if (!r.ok && r.code === "schema_violation") r = await sendReliable("job.result", { jobId: result.jobId, outcome: result.outcome }, job.workspaceId, jobShort);
|
|
99
|
+
const okResult = r.ok;
|
|
100
|
+
const okEvent = (await sendReliable("job.event", eventPayload(snap), job.workspaceId, jobShort)).ok;
|
|
101
|
+
if (okEvent && okResult) stats.terminalSent++;
|
|
102
|
+
return okEvent && okResult;
|
|
103
|
+
},
|
|
104
|
+
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install.js - puts the job.assign receiver on a v2 link (F3-10). Called by runtime.js only when LOCAL_AGENT_JOBS=true, v2 is on, DLP is enforce and a
|
|
3
|
+
* runner exists. Registers `job.assign` and `job.cancel`; `link.jobs` is the manager (setRunner() is how F3-08c plugs the packaged agent loop in).
|
|
4
|
+
*
|
|
5
|
+
* Git credentials: the control plane NEVER sends a token (the job.assign schema has no field for one, and it is closed). A runner that opens a PR uses
|
|
6
|
+
* what this machine already has: the user's git credential helper, ssh-agent or the environment of the agent's own process. See docs/la-jobs-and-llm.md.
|
|
7
|
+
*/
|
|
8
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
9
|
+
import { createJobManager } from "./manager.js";
|
|
10
|
+
import { createSmokeRunner } from "./smoke-runner.js";
|
|
11
|
+
|
|
12
|
+
export const JOBS_CAPABILITIES = Object.freeze(["job.assign"]);
|
|
13
|
+
|
|
14
|
+
/** A workspace "served here": open in the manager (of this tenant, or of none yet) or the default root. -> { root } | null */
|
|
15
|
+
export function makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId }) {
|
|
16
|
+
const tenant = String(tenantId).toLowerCase();
|
|
17
|
+
return (workspaceId) => {
|
|
18
|
+
if (typeof workspaceId !== "string" || !workspaceId) return null;
|
|
19
|
+
const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
|
|
20
|
+
if (listed) {
|
|
21
|
+
if (listed.tenantId && String(listed.tenantId).toLowerCase() !== tenant) return null;
|
|
22
|
+
return listed.root ? { root: listed.root } : null;
|
|
23
|
+
}
|
|
24
|
+
if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, runner = null, onResult = null, now = Date.now, memoryUsage, sleep }) {
|
|
30
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("job.assign needs LOCAL_AGENT_DLP_MODE=enforce");
|
|
31
|
+
const tenant = String(tenantId).toLowerCase();
|
|
32
|
+
const resolveWorkspace = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
33
|
+
const theRunner = runner || (config.runner === "smoke" ? createSmokeRunner({ maxFileBytes: config.smokeMaxFileBytes }) : null);
|
|
34
|
+
if (!theRunner) throw new Error("job.assign needs a runner");
|
|
35
|
+
const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, config, env, llm: link.llm || null,
|
|
36
|
+
readFile: link.content ? link.content.readAllowedFile : null, runner: theRunner, onResult, log, now, ...(memoryUsage ? { memoryUsage } : {}), ...(sleep ? { sleep } : {}) });
|
|
37
|
+
link.session.setHandler("job.assign", (ctx) => manager.handleAssign(ctx));
|
|
38
|
+
link.session.setHandler("job.cancel", (ctx) => manager.handleCancel(ctx));
|
|
39
|
+
const closeLink = link.close.bind(link);
|
|
40
|
+
link.close = () => { manager.cancelAll(); closeLink(); };
|
|
41
|
+
link.jobs = manager;
|
|
42
|
+
return manager;
|
|
43
|
+
}
|