deepdebug-local-agent 1.9.0 → 1.10.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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * manager.js - `job.assign` / `job.cancel` receiver and job supervisor of the Local Agent (F3-10). Registered by install.js only with
3
+ * LOCAL_AGENT_JOBS=true, v2 on, DLP enforce and a runner.
4
+ *
5
+ * `job.assign` (the frame is already schema-validated and its Ed25519 signature checked by the session). The answer is the ack:
6
+ * accepted the job runs (or waits, state `queued`) and its progress/outcome come back as job.event / job.result.
7
+ * A job id already known (running, queued or recently finished) is acked `accepted` and NOT started again.
8
+ * rejected protocol_unsupported no runner, DLP not enforce (read per request), or the runner does not do that `kind`
9
+ * rejected unauthorized the envelope's workspace is not one this agent serves (for this tenant)
10
+ * rejected schema_violation `options` malformed (the session's schema check comes first; this is the second line)
11
+ * rejected protocol_unsupported also: `options` present and the runner did not declare `supportsOptions` (options.js: never ignore dryRun silently)
12
+ * rejected rate_limited concurrency + queue full, memory reservation or RSS above the limit (retryable: the CP may try later or elsewhere)
13
+ * The requested budget is CLAMPED to the local ceilings (LOCAL_AGENT_JOBS_MAX_TOKENS / _SECONDS); whatever the job needs beyond ends it with
14
+ * `budget_exceeded` / `timeout`. Failures that happen after the ack (no LLM key, a runner error) are job.event `failed` + job.result `failed`.
15
+ *
16
+ * `job.cancel`: aborts the job's signal, calls runner.cancel(jobId), and after `cancelGraceMs` abandons a runner that did not stop; the job ends
17
+ * `cancelled` (job.event + job.result). A queued job is cancelled at once. An unknown or finished id is a no-op acked `accepted` (no oracle, idempotent:
18
+ * a repeated cancel, a cancel of a finished job and a cancel of an orphan the CP gave up are all the same). An id never seen is remembered (bounded), so a
19
+ * job.assign that arrives afterwards for it is acked `accepted` and NOT run. A job id that is not a UUID is acked `rejected schema_violation`: no lookup, no effect.
20
+ *
21
+ * What goes on the wire: only the whitelisted metadata of events.js. What is logged or audited: short hashes of ids, kinds, counters, codes. Never an
22
+ * input, a prompt, an answer, a path or an error text. The runner's `answer` stays local (`onResult`).
23
+ */
24
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
25
+ import { createAudit, short } from "../content-handlers/audit.js";
26
+ import { createJobEvents, resultPayload, JOB_ERROR_CODES } from "./events.js";
27
+ import { LlmClientError } from "../llm/client.js";
28
+ import { normalizeJobOptions } from "./options.js";
29
+
30
+ const LLM_TO_JOB = Object.freeze({ budget_exceeded: "budget_exceeded", timeout: "timeout", cancelled: "cancelled", dlp_blocked: "policy_denied", dlp_denied: "policy_denied", dlp_not_enforced: "policy_denied", model_not_allowed: "policy_denied" });
31
+ const utf8 = new TextEncoder();
32
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
33
+ const EARLY_CANCELS = 256; // job ids cancelled before they were known (bounded)
34
+
35
+ /** A job error code from whatever a runner threw (codes only; the message is never read). */
36
+ export function jobErrorCodeOf(err) {
37
+ if (err && JOB_ERROR_CODES.includes(err.jobErrorCode) && err.jobErrorCode !== "none") return err.jobErrorCode;
38
+ if (err && err.name === "LlmClientError") return LLM_TO_JOB[err.code] || "llm_error";
39
+ return "unknown";
40
+ }
41
+
42
+ /**
43
+ * Runner interface (F3-08c plugs the packaged agent loop in here):
44
+ * { name: string, kinds?: string[] (default: all of fix|implement|quality), needsLlm?: boolean (default true),
45
+ * start(job, ctx): Promise<{ outcome: "pr_opened"|"no_change"|"failed"|"cancelled", errorCode?, prUrl?, branch?, commitSha?, filesChangedCount?,
46
+ * diffSha256?, tests?, answer? }>,
47
+ * cancel(jobId): void|Promise<void> }
48
+ * `job.options` (contract 2.1, capability `job.options`): always an object (options.js), frozen, every field present: createBranch, createPullRequest, dryRun,
49
+ * maxIterations (clamped to LOCAL_AGENT_JOBS_MAX_ITERATIONS), `effects` {createBranch, push, openPullRequest} (what the runner may do outside; dryRun
50
+ * -> none) and, when named, ticketKey / branchName. A runner declares `supportsOptions: true` to be given options: a job that carries any is
51
+ * refused `protocol_unsupported` by a runner that does not (dryRun and createPullRequest:false are NEVER ignored silently).
52
+ * `ctx.readFile` after the job was cancelled, timed out or finished answers `{ok:false, status:"cancelled"}` without touching the disk.
53
+ * `answer` is client content: it never leaves through this module (it goes to `onResult` only).
54
+ */
55
+ export function validateRunner(r) {
56
+ return !!r && typeof r === "object" && typeof r.name === "string" && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(r.name) && typeof r.start === "function" && typeof r.cancel === "function"
57
+ && (r.kinds === undefined || (Array.isArray(r.kinds) && r.kinds.every((k) => ["fix", "implement", "quality"].includes(k))))
58
+ && (r.supportsOptions === undefined || typeof r.supportsOptions === "boolean");
59
+ }
60
+
61
+ export function createJobManager({ session, tenantId, resolveWorkspace, config, env = process.env, llm = null, readFile = null, runner = null, onResult = null, audit = null,
62
+ log = console, now = Date.now, memoryUsage = () => process.memoryUsage().rss, events = null, sleep }) {
63
+ const tenant = String(tenantId).toLowerCase();
64
+ const auditor = audit || createAudit({ file: config.auditFile, now });
65
+ const ev = events || createJobEvents({ session, config, audit: auditor, now, ...(sleep ? { sleep } : {}) });
66
+ const warn = (m) => (log.warn || log.log).call(log, m);
67
+ const stats = { received: 0, accepted: 0, duplicate: 0, queued: 0, rejected: {}, finished: { succeeded: 0, failed: 0, cancelled: 0 }, cancels: 0, cancelInvalid: 0, cancelledBeforeAssign: 0,
68
+ optionsInvalid: 0, optionsUnsupported: 0, abandoned: 0 };
69
+ const active = new Map(); // jobId -> record (queued + running)
70
+ const recent = new Set(); // jobIds that finished (bounded)
71
+ const queue = [];
72
+ const cancelledEarly = new Set(); // job.cancel for an id not known (yet): a job.assign that arrives later for it is not run (orphan of a lost/late assign)
73
+ let running = 0;
74
+ let reserved = 0;
75
+ let closed = false;
76
+ let theRunner = runner && validateRunner(runner) ? runner : null;
77
+ const note = (event, extra) => { try { auditor.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
78
+ const reject = (rec, code) => { stats.rejected[code] = (stats.rejected[code] || 0) + 1; note("job_rejected", { code, ...rec }); return { reject: code }; };
79
+
80
+ const snapshot = (rec, over = {}) => ({ jobId: rec.jobId, state: rec.state, phase: rec.phase, iteration: rec.iteration, filesRead: rec.filesRead, filesChanged: rec.filesChanged,
81
+ tokensIn: rec.tokensIn, tokensOut: rec.tokensOut, costUsd: rec.costUsd, durationMs: Math.max(0, now() - rec.acceptedAt), ...over });
82
+ const tokensUsed = (rec) => rec.tokensIn + rec.tokensOut;
83
+ const publish = (rec) => { if (rec.state === "running" || rec.state === "queued") ev.progress(rec, snapshot(rec)); };
84
+
85
+ function release(rec) {
86
+ reserved = Math.max(0, reserved - rec.reservedBytes);
87
+ rec.reservedBytes = 0;
88
+ for (const t of [rec.deadlineTimer, rec.graceTimer]) if (t) clearTimeout(t);
89
+ rec.deadlineTimer = rec.graceTimer = null;
90
+ active.delete(rec.jobId);
91
+ recent.add(rec.jobId);
92
+ while (recent.size > config.recentJobs) recent.delete(recent.values().next().value);
93
+ rec.job = null; // the inline inputs (transit content) are not kept a moment longer than the job
94
+ }
95
+
96
+ function startNext() {
97
+ while (!closed && running < config.maxConcurrent && queue.length) { const rec = queue.shift(); rec.holdsSlot = true; running++; start(rec); }
98
+ }
99
+
100
+ function abort(rec, reason) {
101
+ if (rec.abortReason) return;
102
+ rec.abortReason = reason;
103
+ try { rec.ac.abort(); } catch { /* already aborted */ }
104
+ try { Promise.resolve(theRunner.cancel(rec.jobId)).catch(() => {}); } catch { /* a runner's cancel never breaks the supervisor */ }
105
+ if (!rec.graceTimer && rec.state === "running") {
106
+ rec.graceTimer = setTimeout(() => { if (!rec.finished) { stats.abandoned++; note("job_runner_abandoned", { jobShort: rec.short, reason }); finish(rec, null, null); } }, config.cancelGraceMs);
107
+ if (rec.graceTimer.unref) rec.graceTimer.unref();
108
+ }
109
+ }
110
+
111
+ /** The one place a job ends. `outcome`: what the runner returned (null if it threw or was abandoned); `err`: what it threw. */
112
+ async function finish(rec, outcome, err) {
113
+ if (rec.finished) return;
114
+ rec.finished = true;
115
+ try { rec.ac.abort(); } catch { /* already aborted */ } // anything the runner left in flight (a read, an LLM call) is released with the job
116
+ const holdsSlot = rec.holdsSlot;
117
+ let state;
118
+ let errorCode;
119
+ let result;
120
+ if (rec.abortReason === "cancelled") { state = "cancelled"; errorCode = "cancelled"; result = "cancelled"; }
121
+ else if (rec.abortReason === "timeout") { state = "failed"; errorCode = "timeout"; result = "failed"; }
122
+ else if (rec.abortReason === "budget_exceeded") { state = "failed"; errorCode = "budget_exceeded"; result = "failed"; }
123
+ else if (err) { state = "failed"; errorCode = jobErrorCodeOf(err); result = "failed"; }
124
+ else if (outcome && outcome.outcome === "failed") { state = "failed"; errorCode = JOB_ERROR_CODES.includes(outcome.errorCode) && outcome.errorCode !== "none" ? outcome.errorCode : "unknown"; result = "failed"; }
125
+ else if (outcome && outcome.outcome === "cancelled") { state = "cancelled"; errorCode = "cancelled"; result = "cancelled"; }
126
+ else if (outcome && (outcome.outcome === "pr_opened" || outcome.outcome === "no_change")) { state = "succeeded"; errorCode = "none"; result = outcome.outcome; }
127
+ else { state = "failed"; errorCode = "unknown"; result = "failed"; }
128
+ rec.state = state;
129
+ if (state === "succeeded") rec.phase = "done";
130
+ if (holdsSlot) running--;
131
+ const snap = snapshot(rec, { state, errorCode });
132
+ stats.finished[state]++;
133
+ note("job_finished", { jobShort: rec.short, kind: rec.kind, state, errorCode, tokensIn: rec.tokensIn, tokensOut: rec.tokensOut, ms: snap.durationMs, answerBytes: outcome && typeof outcome.answer === "string" ? utf8.encode(outcome.answer).length : 0 });
134
+ if (onResult && outcome) { try { onResult({ jobId: rec.jobId, kind: rec.kind, state, outcome: result, errorCode, answer: typeof outcome.answer === "string" ? outcome.answer : undefined }); } catch { /* a consumer's bug never breaks the supervisor */ } }
135
+ const resultFrame = resultPayload(rec.jobId, result, state === "succeeded" || result === "pr_opened" ? outcome || {} : {});
136
+ const workspaceId = rec.workspaceId;
137
+ release(rec);
138
+ startNext(); // the slot is free as soon as the job is over, not when its last frame is acked
139
+ await ev.terminal({ ev: rec.ev, workspaceId, short: rec.short }, snap, resultFrame);
140
+ }
141
+
142
+ /** The slot (`running`, `holdsSlot`) is taken by the caller at admission time, so a burst of job.assign can never exceed maxConcurrent. */
143
+ function start(rec) {
144
+ rec.state = "running";
145
+ rec.startedAt = now();
146
+ rec.phase = "init";
147
+ note("job_started", { jobShort: rec.short, kind: rec.kind });
148
+ publish(rec);
149
+ const secondsMs = rec.budget.seconds * 1000;
150
+ rec.deadlineTimer = setTimeout(() => abort(rec, "timeout"), secondsMs);
151
+ if (rec.deadlineTimer.unref) rec.deadlineTimer.unref();
152
+ // F3-09/F3-10: a runner that thinks needs the key the job names; a mismatch is a failure of the job, not of the channel
153
+ if (theRunner.needsLlm !== false) {
154
+ if (!llm || !llm.client) { finish(rec, null, Object.assign(new Error("no llm"), { jobErrorCode: "llm_error" })); return; }
155
+ if (!llm.holder.held || !llm.holder.matches(rec.job.llm.keyRef)) { finish(rec, null, Object.assign(new Error("no key"), { jobErrorCode: "llm_error" })); return; }
156
+ }
157
+ const ctx = Object.freeze({
158
+ signal: rec.ac.signal,
159
+ workspace: Object.freeze({ id: rec.workspaceId }),
160
+ llm: Object.freeze({
161
+ async chat(opts = {}) {
162
+ if (rec.abortReason || rec.finished) throw new LlmClientError("cancelled");
163
+ const r = await llm.client.chat({ ...opts, model: opts.model ?? rec.job.llm.alias, signal: rec.ac.signal, job: { jobId: rec.jobId, agent: theRunner.name }, tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)) });
164
+ rec.tokensIn += r.usage.promptTokens;
165
+ rec.tokensOut += r.usage.completionTokens;
166
+ if (r.costUsd !== null && r.costUsd !== undefined) rec.costUsd = (rec.costUsd || 0) + r.costUsd;
167
+ publish(rec);
168
+ if (tokensUsed(rec) > rec.budget.tokens) abort(rec, "budget_exceeded"); // the next call is refused at equality (client: budget_exceeded); only an overshoot of the provider aborts
169
+ return r;
170
+ },
171
+ }),
172
+ async readFile(ref, opts = {}) {
173
+ // an abandoned, cancelled or timed-out job reads nothing more (no descriptor opened, no buffer built), and what a read that was already
174
+ // in flight returns is dropped, not counted and not handed to the runner
175
+ if (rec.abortReason || rec.finished) return { ok: false, status: "cancelled" };
176
+ if (!readFile) return { ok: false, status: "forbidden" };
177
+ const r = await readFile({ workspaceId: rec.workspaceId, ref, maxBytes: opts.maxBytes, signal: rec.ac.signal });
178
+ if (rec.abortReason || rec.finished) return { ok: false, status: "cancelled" };
179
+ if (r && r.ok) { rec.filesRead++; publish(rec); }
180
+ return r;
181
+ },
182
+ progress(p = {}) {
183
+ if (rec.finished) return;
184
+ if (p.phase) rec.phase = p.phase;
185
+ for (const k of ["iteration", "filesRead", "filesChanged"]) if (Number.isSafeInteger(p[k]) && p[k] >= 0) rec[k] = p[k];
186
+ publish(rec);
187
+ },
188
+ budget: () => ({ tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)), msLeft: Math.max(0, rec.startedAt + secondsMs - now()) }),
189
+ });
190
+ Promise.resolve().then(() => theRunner.start(rec.job, ctx)).then(
191
+ (outcome) => finish(rec, outcome && typeof outcome === "object" ? outcome : null, null),
192
+ (err) => finish(rec, null, err || new Error("runner")),
193
+ ).catch(() => {});
194
+ }
195
+
196
+ return {
197
+ stats, audit: auditor, events: ev,
198
+ get runner() { return theRunner; },
199
+ get activeCount() { return active.size; },
200
+ get running() { return running; },
201
+ get reservedBytes() { return reserved; },
202
+ /** F3-08c: the packaged agent loop replaces the runner (jobs already running keep the one they started with only as far as their own cancel). */
203
+ setRunner(r) { if (!validateRunner(r)) throw new Error("not a runner"); theRunner = r; },
204
+
205
+ handleAssign(ctx) {
206
+ stats.received++;
207
+ const p = ctx.params;
208
+ const base = { jobShort: short(p?.jobId), kind: p?.kind };
209
+ if (closed || !theRunner) return reject(base, "protocol_unsupported");
210
+ // F3-07b rule, per request: a job reads client files and feeds them to the LLM; never without the DLP in enforce
211
+ if (dlpConfigFromEnv(env).mode !== "enforce") return reject(base, "protocol_unsupported");
212
+ if (theRunner.kinds && !theRunner.kinds.includes(p.kind)) return reject(base, "protocol_unsupported");
213
+ if (active.has(p.jobId) || recent.has(p.jobId)) { stats.duplicate++; note("job_duplicate", base); return { accept: true }; }
214
+ if (cancelledEarly.has(p.jobId)) { stats.cancelledBeforeAssign++; note("job_assign_after_cancel", base); return { accept: true }; } // the CP already gave this job up: never started
215
+ const ws = typeof ctx.workspaceId === "string" && ctx.workspaceId ? resolveWorkspace(ctx.workspaceId) : null;
216
+ if (!ws || !ws.root) return reject(base, "unauthorized");
217
+ // contract 2.1 `options`: only a runner that declared it handles them; a malformed object (the session's schema check should have caught it) runs nothing
218
+ const opts = normalizeJobOptions(p.options, config);
219
+ if (!opts.ok) { stats.optionsInvalid++; return reject(base, "schema_violation"); }
220
+ if (p.options !== undefined && theRunner.supportsOptions !== true) { stats.optionsUnsupported++; return reject({ ...base, options: opts.present }, "protocol_unsupported"); }
221
+ let inlineBytes = 0;
222
+ for (const i of p.inputs || []) if (typeof i.inline === "string") inlineBytes += utf8.encode(i.inline).length;
223
+ const need = inlineBytes + config.memPerJobBytes;
224
+ if (reserved + need > config.maxMemoryBytes) return reject(base, "rate_limited");
225
+ if (config.maxRssBytes > 0 && memoryUsage() > config.maxRssBytes) return reject(base, "rate_limited");
226
+ const slotFree = running < config.maxConcurrent;
227
+ if (!slotFree && queue.length >= config.maxQueued) return reject(base, "rate_limited");
228
+
229
+ const budget = { tokens: Math.min(p.budget.tokens, config.maxTokens), seconds: Math.min(p.budget.seconds, config.maxSeconds) };
230
+ const rec = {
231
+ jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
232
+ tokensIn: 0, tokensOut: 0, costUsd: null, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
233
+ reservedBytes: need, deadlineTimer: null, graceTimer: null, budget,
234
+ job: Object.freeze({ jobId: p.jobId, kind: p.kind, workspaceId: ctx.workspaceId, incidentRef: p.incidentRef, repoRef: p.repoRef, policy: p.policy, budget: Object.freeze({ ...budget }), llm: p.llm, inputs: p.inputs || [], options: opts.options }),
235
+ };
236
+ reserved += need;
237
+ active.set(rec.jobId, rec);
238
+ stats.accepted++;
239
+ note("job_accepted", { ...base, workspaceShort: short(ctx.workspaceId), budgetClamped: budget.tokens !== p.budget.tokens || budget.seconds !== p.budget.seconds, queued: !slotFree, inlineBytes,
240
+ options: opts.present, dryRun: opts.options.dryRun });
241
+ if (slotFree) { rec.holdsSlot = true; running++; setImmediate(() => { if (!closed && !rec.finished && rec.state === "queued") start(rec); }); } // starts after the ack; the slot is ours from now on
242
+ else { stats.queued++; queue.push(rec); publish(rec); }
243
+ return { accept: true };
244
+ },
245
+
246
+ handleCancel(ctx) {
247
+ const p = ctx.params;
248
+ // a job id that is not a UUID is not a job of this contract: a clear refusal, no lookup, no state touched, nothing echoed (not even in the audit)
249
+ if (!p || typeof p.jobId !== "string" || !UUID.test(p.jobId)) { stats.cancelInvalid++; note("job_cancel_invalid", {}); return reject({}, "schema_violation"); }
250
+ const rec = active.get(p.jobId);
251
+ stats.cancels++;
252
+ note("job_cancel", { jobShort: short(p.jobId), known: !!rec, reason: p.reason });
253
+ if (!rec) {
254
+ // unknown or already finished: idempotent, never tells which. An id never seen is remembered (bounded) so an assign that arrives late is not run.
255
+ if (!recent.has(p.jobId) && !closed) { cancelledEarly.add(p.jobId); while (cancelledEarly.size > EARLY_CANCELS) cancelledEarly.delete(cancelledEarly.values().next().value); }
256
+ return { accept: true };
257
+ }
258
+ if (rec.state === "queued") {
259
+ const i = queue.indexOf(rec);
260
+ if (i >= 0) queue.splice(i, 1);
261
+ rec.abortReason = "cancelled";
262
+ finish(rec, null, null);
263
+ } else abort(rec, "cancelled");
264
+ return { accept: true };
265
+ },
266
+
267
+ /** Link close / process shutdown: every job is aborted, nothing more is sent. */
268
+ cancelAll() {
269
+ closed = true;
270
+ queue.length = 0;
271
+ for (const rec of [...active.values()]) { rec.finished = true; rec.abortReason ||= "cancelled"; try { rec.ac.abort(); } catch { /* done */ } try { Promise.resolve(theRunner?.cancel(rec.jobId)).catch(() => {}); } catch { /* done */ } release(rec); }
272
+ running = 0;
273
+ ev.close();
274
+ },
275
+ };
276
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * options.js - `job.assign.payload.options` (contract 2.1, capability `job.options`, F3-10): how to run a job.
3
+ *
4
+ * The session has already validated the frame against the closed schema; this module validates AGAIN (the manager is also called without a session in
5
+ * tests and by future code) and turns the optional object into the one shape every runner reads as `job.options`: frozen, every field present.
6
+ *
7
+ * absent / not given the agent's own defaults: createBranch true, createPullRequest true, dryRun false, maxIterations = the local ceiling
8
+ * maxIterations CLAMPED to LOCAL_AGENT_JOBS_MAX_ITERATIONS: an option never widens what the agent allows itself
9
+ * effects what a runner may DO to the outside world, derived conservatively (a missing effect is never a surprise, an extra one is):
10
+ * dryRun -> no branch, no push, no pull request; createBranch false -> work on repoRef.branch, never push to it, no pull request;
11
+ * createPullRequest false -> no pull request
12
+ *
13
+ * A runner that does not declare `supportsOptions: true` is never given options (the manager refuses the job, see manager.js): dryRun and
14
+ * createPullRequest:false must never be ignored silently. Nothing here is content (an id, a ref name, flags, a counter); still nothing of it is
15
+ * written to a log: the audit records only which keys were present.
16
+ */
17
+ export const OPTION_KEYS = Object.freeze(["ticketKey", "branchName", "createBranch", "createPullRequest", "dryRun", "maxIterations"]);
18
+ export const JOB_OPTIONS_CAPABILITY = "job.options";
19
+
20
+ const SHORT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
21
+ // the same expression as the contract (job.assign.schema.json options.branchName): no "..", no "//", no component starting with ".", no trailing "." or "/", no ".lock" suffix
22
+ const BRANCH = /^(?!.*\.\.)(?!.*\/\/)(?!.*\/\.)(?!.*[./]$)(?!.*\.lock(\/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$/;
23
+
24
+ /**
25
+ * @param {unknown} raw the payload's `options` (undefined when absent)
26
+ * @param {{maxIterations:number}} config the local ceilings
27
+ * @returns {{ok:true, options:object, present:string[]} | {ok:false}} `ok:false` = malformed (the caller answers `schema_violation`, nothing runs)
28
+ */
29
+ export function normalizeJobOptions(raw, config) {
30
+ const ceiling = Number.isSafeInteger(config?.maxIterations) && config.maxIterations >= 1 ? config.maxIterations : 25;
31
+ if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) return { ok: false };
32
+ const o = raw || {};
33
+ const keys = Object.keys(o);
34
+ if (keys.length === 0 && raw !== undefined) return { ok: false }; // minProperties 1
35
+ if (keys.some((k) => !OPTION_KEYS.includes(k))) return { ok: false }; // closed
36
+ for (const k of ["createBranch", "createPullRequest", "dryRun"]) if (o[k] !== undefined && typeof o[k] !== "boolean") return { ok: false };
37
+ if (o.ticketKey !== undefined && !(typeof o.ticketKey === "string" && SHORT_ID.test(o.ticketKey))) return { ok: false };
38
+ if (o.branchName !== undefined && !(typeof o.branchName === "string" && BRANCH.test(o.branchName))) return { ok: false };
39
+ if (o.maxIterations !== undefined && !(Number.isSafeInteger(o.maxIterations) && o.maxIterations >= 1 && o.maxIterations <= 100)) return { ok: false };
40
+
41
+ const createBranch = o.createBranch !== false;
42
+ const createPullRequest = o.createPullRequest !== false;
43
+ const dryRun = o.dryRun === true;
44
+ const maxIterations = Math.min(o.maxIterations ?? ceiling, ceiling);
45
+ const effects = Object.freeze({
46
+ createBranch: !dryRun && createBranch,
47
+ push: !dryRun && createBranch,
48
+ openPullRequest: !dryRun && createBranch && createPullRequest,
49
+ });
50
+ const options = Object.freeze({
51
+ ...(o.ticketKey !== undefined ? { ticketKey: o.ticketKey } : {}),
52
+ ...(o.branchName !== undefined ? { branchName: o.branchName } : {}),
53
+ createBranch, createPullRequest, dryRun, maxIterations, effects,
54
+ });
55
+ return { ok: true, options, present: keys.sort() };
56
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * smoke-runner.js - the REFERENCE runner of F3-10: proves the whole path (job.assign -> runner -> LLM client -> job.event/job.result) with the smallest
3
+ * possible job. It reads ONE allowed file of the job's workspace (the first `inputs[].ref`, an opaque id this agent minted, resolved by the content
4
+ * handlers' registry and path guard: deny list, symlinks, root), asks the AI gateway one FIXED question about it and returns the answer to the LOCAL
5
+ * `onResult` hook. It changes nothing (outcome `no_change`), opens no PR, touches no Git. It is not an agent loop: that is F3-08c.
6
+ * Selected with LOCAL_AGENT_JOBS_RUNNER=smoke; meant for QA and for tests, not for production traffic.
7
+ */
8
+ export const SMOKE_QUESTION = "In at most three sentences, say what the file below is for.";
9
+ export const SMOKE_SYSTEM = "You are a careful code reader. Answer briefly and only about the file you are given.";
10
+
11
+ /**
12
+ * Options (contract 2.1, `job.options`): this runner is the REFERENCE of how a runner reads them. It supports them trivially and visibly: it never
13
+ * creates a branch, pushes or opens a pull request, so `dryRun`, `createBranch` and `createPullRequest` cannot make it do more; `maxIterations` caps its
14
+ * (single) iteration, and `effects` (options.js) is what a real runner must check before every outside effect. dryRun still asks the LLM: a dry run
15
+ * runs the whole loop and only withholds the effects.
16
+ */
17
+ export function createSmokeRunner({ maxFileBytes = 64 * 1024, maxOutputTokens = 256 } = {}) {
18
+ return {
19
+ name: "smoke",
20
+ kinds: ["fix", "implement", "quality"],
21
+ needsLlm: true,
22
+ supportsOptions: true,
23
+ async start(job, ctx) {
24
+ const iteration = Math.min(1, job.options?.maxIterations ?? 1); // one question = one iteration, never above the job's cap (>= 1 by contract)
25
+ ctx.progress({ phase: "context" });
26
+ const ref = (job.inputs || []).find((i) => typeof i.ref === "string");
27
+ if (!ref) return { outcome: "failed", errorCode: "unknown" };
28
+ const file = await ctx.readFile(ref.ref, { maxBytes: maxFileBytes });
29
+ if (!file || !file.ok) return { outcome: "failed", errorCode: file && file.status === "forbidden" ? "policy_denied" : "unknown" };
30
+ ctx.progress({ phase: "plan", iteration });
31
+ const r = await ctx.llm.chat({
32
+ messages: [{ role: "system", content: SMOKE_SYSTEM }, { role: "user", content: `${SMOKE_QUESTION}\n\n<file>\n${file.text}\n</file>` }],
33
+ maxTokens: maxOutputTokens, temperature: 0,
34
+ });
35
+ return { outcome: "no_change", answer: r.text };
36
+ },
37
+ cancel() { /* the job's signal aborts the LLM call; nothing else is held */ },
38
+ };
39
+ }