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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/docs/la-jobs-and-llm.md +56 -0
- package/docs/la-protocol-v2.md +3 -3
- package/env.example +6 -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 +4 -4
- package/src/la-protocol-v2/contract/README.md +25 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -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 +37 -0
- package/src/la-protocol-v2/jobs/manager.js +276 -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 +311 -0
- package/src/la-protocol-v2/llm/config.js +61 -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 +35 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/la-protocol-v2/contract.test.js +4 -4
- package/test/la-protocol-v2/helpers/fake-llm.js +64 -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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// F3-10 (L3): job.assign `options` (contract 2.1, capability job.options), the review's LOW findings on the LA side and the orphan-cancel rules.
|
|
2
|
+
// Normalizer in isolation; the supervisor with a fake session and scripted runners (options reach the runner, a runner without supportsOptions is
|
|
3
|
+
// refused, dryRun/createPullRequest are never ignored silently, maxIterations is clamped); cancel with a bad id, idempotent cancel of an unknown/finished/
|
|
4
|
+
// cancelling job, a cancel before the assign; readFile of an abandoned job; admission under a synchronous burst (atomic limits).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { createJobManager } from "../../src/la-protocol-v2/jobs/manager.js";
|
|
8
|
+
import { resolveJobsConfig } from "../../src/la-protocol-v2/jobs/config.js";
|
|
9
|
+
import { normalizeJobOptions, OPTION_KEYS } from "../../src/la-protocol-v2/jobs/options.js";
|
|
10
|
+
import { ProtocolError } from "../../src/la-protocol-v2/errors.js";
|
|
11
|
+
|
|
12
|
+
const noop = () => {};
|
|
13
|
+
const uuid = () => crypto.randomUUID();
|
|
14
|
+
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
const until = async (cond, ms = 3000) => { const t0 = Date.now(); while (!cond()) { if (Date.now() - t0 > ms) throw new Error("until: timed out"); await tick(5); } };
|
|
16
|
+
const CFG = { maxIterations: 25 };
|
|
17
|
+
|
|
18
|
+
function fakeSession() {
|
|
19
|
+
const frames = [];
|
|
20
|
+
return { frames, enqueue: async (type, payload, opts) => { frames.push({ type, payload, workspaceId: opts?.workspaceId }); return { acked: Promise.resolve() }; },
|
|
21
|
+
of: (jobId, type) => frames.filter((f) => f.payload.jobId === jobId && (!type || f.type === type)) };
|
|
22
|
+
}
|
|
23
|
+
const params = (over = {}) => ({ jobId: uuid(), kind: "implement", repoRef: { provider: "github", org: "acme", repo: "app", branch: "main" },
|
|
24
|
+
policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" }, budget: { tokens: 50_000, seconds: 60 }, llm: { alias: "vh-claude-sonnet", keyRef: "ab".repeat(32) }, inputs: [], ...over });
|
|
25
|
+
const ctxOf = (p, workspaceId = "ws-1") => ({ type: "job.assign", params: p, workspaceId });
|
|
26
|
+
|
|
27
|
+
function gatedRunner(over = {}) {
|
|
28
|
+
const calls = { started: [], cancelled: [], jobs: new Map(), ctxs: new Map() };
|
|
29
|
+
const gates = new Map();
|
|
30
|
+
return {
|
|
31
|
+
calls, gates, name: "gated", needsLlm: false, supportsOptions: true,
|
|
32
|
+
start(job, ctx) {
|
|
33
|
+
calls.started.push(job.jobId); calls.jobs.set(job.jobId, job); calls.ctxs.set(job.jobId, ctx);
|
|
34
|
+
return new Promise((resolve, reject) => { gates.set(job.jobId, { resolve, reject }); ctx.signal.addEventListener("abort", () => { if (!over.ignoreAbort) reject(new Error("aborted")); }, { once: true }); });
|
|
35
|
+
},
|
|
36
|
+
cancel(jobId) { calls.cancelled.push(jobId); },
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function mgr({ runner = gatedRunner(), env = {}, readFile = null, audit } = {}) {
|
|
41
|
+
const session = fakeSession();
|
|
42
|
+
const config = resolveJobsConfig({ LOCAL_AGENT_JOBS: "true", LOCAL_AGENT_JOBS_EVENT_MIN_MS: "50", LOCAL_AGENT_JOBS_CANCEL_GRACE_MS: "100", LOCAL_AGENT_JOBS_TERMINAL_RETRY_MS: "10", ...env }, noop);
|
|
43
|
+
const m = createJobManager({ session, tenantId: "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f", resolveWorkspace: (id) => (id === "ws-1" ? { root: "/w/ws-1" } : null), config,
|
|
44
|
+
env: { LOCAL_AGENT_DLP_MODE: "enforce" }, runner, readFile, log: { warn: noop }, ...(audit ? { audit } : {}) });
|
|
45
|
+
return { m, session, runner, config };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ------------------------------------------------------------------------------------------------------ the normalizer
|
|
49
|
+
|
|
50
|
+
test("options: absent = the agent's defaults (branch + PR on, no dry run, the local iteration ceiling); frozen; effects follow the flags conservatively", () => {
|
|
51
|
+
const r = normalizeJobOptions(undefined, CFG);
|
|
52
|
+
assert.equal(r.ok, true);
|
|
53
|
+
assert.deepEqual(r.options, { createBranch: true, createPullRequest: true, dryRun: false, maxIterations: 25, effects: { createBranch: true, push: true, openPullRequest: true } });
|
|
54
|
+
assert.ok(Object.isFrozen(r.options) && Object.isFrozen(r.options.effects));
|
|
55
|
+
assert.deepEqual(r.present, []);
|
|
56
|
+
const all = normalizeJobOptions({ ticketKey: "KAN-28", branchName: "vh/KAN-28-fix", createBranch: true, createPullRequest: false, dryRun: false, maxIterations: 10 }, CFG);
|
|
57
|
+
assert.deepEqual(all.options, { ticketKey: "KAN-28", branchName: "vh/KAN-28-fix", createBranch: true, createPullRequest: false, dryRun: false, maxIterations: 10, effects: { createBranch: true, push: true, openPullRequest: false } });
|
|
58
|
+
assert.deepEqual(all.present, [...OPTION_KEYS].sort());
|
|
59
|
+
// dryRun: no effect at all, whatever else is asked
|
|
60
|
+
assert.deepEqual(normalizeJobOptions({ dryRun: true, createBranch: true, createPullRequest: true }, CFG).options.effects, { createBranch: false, push: false, openPullRequest: false });
|
|
61
|
+
// createBranch:false: work on repoRef.branch, never push to it, no PR
|
|
62
|
+
assert.deepEqual(normalizeJobOptions({ createBranch: false, createPullRequest: true }, CFG).options.effects, { createBranch: false, push: false, openPullRequest: false });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("options: maxIterations is clamped to the local ceiling (never widened) and only lowered by the job", () => {
|
|
66
|
+
assert.equal(normalizeJobOptions({ maxIterations: 100 }, { maxIterations: 8 }).options.maxIterations, 8);
|
|
67
|
+
assert.equal(normalizeJobOptions({ maxIterations: 3 }, { maxIterations: 8 }).options.maxIterations, 3);
|
|
68
|
+
assert.equal(normalizeJobOptions({ dryRun: true }, { maxIterations: 8 }).options.maxIterations, 8);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("options: malformed objects are refused (closed, minProperties 1, types, ranges, branch/ticket patterns)", () => {
|
|
72
|
+
const bad = [null, [], "x", 5, {}, { extra: 1 }, { token: "ghp_x" }, { dryRun: "true" }, { dryRun: 1 }, { createBranch: null }, { createPullRequest: "no" },
|
|
73
|
+
{ maxIterations: 0 }, { maxIterations: 101 }, { maxIterations: 1.5 }, { maxIterations: "5" }, { maxIterations: Infinity },
|
|
74
|
+
{ ticketKey: "" }, { ticketKey: "-x" }, { ticketKey: "a".repeat(65) }, { ticketKey: "KAN 28" }, { ticketKey: 28 },
|
|
75
|
+
{ branchName: "" }, { branchName: "a..b" }, { branchName: "a//b" }, { branchName: "a/.b" }, { branchName: "a." }, { branchName: "a/" }, { branchName: "x.lock" }, { branchName: "a/x.lock/b" },
|
|
76
|
+
{ branchName: "-rf" }, { branchName: ".hidden" }, { branchName: "a b" }, { branchName: "a;rm" }, { branchName: "a$(x)" }, { branchName: "a\nb" }, { branchName: "a".repeat(256) }, { branchName: "/abs" }, { branchName: 5 }];
|
|
77
|
+
for (const b of bad) assert.deepEqual(normalizeJobOptions(b, CFG), { ok: false }, JSON.stringify(b));
|
|
78
|
+
for (const good of [{ branchName: "vh/KAN-28_fix.1" }, { branchName: "a" }, { branchName: "a".repeat(255) }, { ticketKey: "KAN-28" }, { ticketKey: "a" }, { maxIterations: 1 }, { maxIterations: 100 }]) {
|
|
79
|
+
assert.equal(normalizeJobOptions(good, CFG).ok, true, JSON.stringify(good));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ------------------------------------------------------------------------------------------------------ options reach the runner
|
|
84
|
+
|
|
85
|
+
test("job.assign with options: the runner gets job.options (normalized, frozen, clamped); without options it still gets the defaults; the audit lists keys, not values", async () => {
|
|
86
|
+
const entries = [];
|
|
87
|
+
const audit = { record: (e) => { entries.push(e); return e; }, tail: () => entries };
|
|
88
|
+
const { m, runner, session } = mgr({ env: { LOCAL_AGENT_JOBS_MAX_CONCURRENT: "2", LOCAL_AGENT_JOBS_MAX_ITERATIONS: "7" }, audit });
|
|
89
|
+
const a = params({ options: { ticketKey: "KAN-28", branchName: "vh/secret-branch-name", dryRun: true, maxIterations: 50 } });
|
|
90
|
+
const b = params();
|
|
91
|
+
assert.deepEqual(m.handleAssign(ctxOf(a)), { accept: true });
|
|
92
|
+
assert.deepEqual(m.handleAssign(ctxOf(b)), { accept: true });
|
|
93
|
+
await until(() => runner.calls.started.length === 2);
|
|
94
|
+
const oa = runner.calls.jobs.get(a.jobId).options;
|
|
95
|
+
assert.deepEqual(oa, { ticketKey: "KAN-28", branchName: "vh/secret-branch-name", createBranch: true, createPullRequest: true, dryRun: true, maxIterations: 7, effects: { createBranch: false, push: false, openPullRequest: false } });
|
|
96
|
+
assert.ok(Object.isFrozen(oa) && Object.isFrozen(runner.calls.jobs.get(a.jobId)));
|
|
97
|
+
assert.deepEqual(runner.calls.jobs.get(b.jobId).options, { createBranch: true, createPullRequest: true, dryRun: false, maxIterations: 7, effects: { createBranch: true, push: true, openPullRequest: true } });
|
|
98
|
+
const acc = entries.find((e) => e.event === "job_accepted" && e.options?.length);
|
|
99
|
+
assert.deepEqual(acc.options, ["branchName", "dryRun", "maxIterations", "ticketKey"]);
|
|
100
|
+
const dump = JSON.stringify([entries, session.frames]);
|
|
101
|
+
assert.ok(!dump.includes("vh/secret-branch-name") && !dump.includes("KAN-28"), "values of options are not in the audit or on the wire");
|
|
102
|
+
m.cancelAll();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a runner that did not declare supportsOptions is refused any options (protocol_unsupported): dryRun / createPullRequest:false are never ignored silently", async () => {
|
|
106
|
+
const r = gatedRunner({ supportsOptions: undefined });
|
|
107
|
+
const { m } = mgr({ runner: r });
|
|
108
|
+
for (const options of [{ dryRun: true }, { createPullRequest: false }, { maxIterations: 3 }, { ticketKey: "KAN-1" }]) {
|
|
109
|
+
assert.deepEqual(m.handleAssign(ctxOf(params({ options }))), { reject: "protocol_unsupported" }, JSON.stringify(options));
|
|
110
|
+
}
|
|
111
|
+
assert.equal(r.calls.started.length, 0);
|
|
112
|
+
assert.equal(m.stats.optionsUnsupported, 4);
|
|
113
|
+
assert.equal(m.activeCount, 0); assert.equal(m.reservedBytes, 0);
|
|
114
|
+
// without options the same runner works as before
|
|
115
|
+
const p = params();
|
|
116
|
+
assert.deepEqual(m.handleAssign(ctxOf(p)), { accept: true });
|
|
117
|
+
await until(() => r.calls.started.length === 1);
|
|
118
|
+
assert.deepEqual(r.calls.jobs.get(p.jobId).options.effects, { createBranch: true, push: true, openPullRequest: true });
|
|
119
|
+
m.cancelAll();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("malformed options that got past the session -> rejected schema_violation, nothing reserved, nothing started", () => {
|
|
123
|
+
const { m, runner } = mgr();
|
|
124
|
+
for (const options of [{}, { dryRun: "yes" }, { extra: true }, { branchName: "../x" }, null]) assert.deepEqual(m.handleAssign(ctxOf(params({ options }))), { reject: "schema_violation" }, JSON.stringify(options));
|
|
125
|
+
assert.equal(m.stats.optionsInvalid, 5);
|
|
126
|
+
assert.equal(m.activeCount, 0); assert.equal(m.reservedBytes, 0); assert.equal(runner.calls.started.length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a duplicate job id with different options is still idempotent: acked, not started again, the first options stand", async () => {
|
|
130
|
+
const { m, runner } = mgr();
|
|
131
|
+
const p = params({ options: { dryRun: true } });
|
|
132
|
+
m.handleAssign(ctxOf(p));
|
|
133
|
+
assert.deepEqual(m.handleAssign(ctxOf({ ...p, options: { dryRun: false } })), { accept: true });
|
|
134
|
+
await until(() => runner.calls.started.length === 1);
|
|
135
|
+
await tick(20);
|
|
136
|
+
assert.equal(runner.calls.started.length, 1);
|
|
137
|
+
assert.equal(runner.calls.jobs.get(p.jobId).options.dryRun, true);
|
|
138
|
+
m.cancelAll();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ------------------------------------------------------------------------------------------------------ cancel
|
|
142
|
+
|
|
143
|
+
test("cancel with an id that is not a UUID: rejected schema_violation, no lookup, no state change, no frame, nothing echoed in the audit", async () => {
|
|
144
|
+
const entries = [];
|
|
145
|
+
const audit = { record: (e) => { entries.push(e); return e; }, tail: () => entries };
|
|
146
|
+
const { m, session, runner } = mgr({ audit });
|
|
147
|
+
const live = params();
|
|
148
|
+
m.handleAssign(ctxOf(live));
|
|
149
|
+
await until(() => runner.calls.started.length === 1);
|
|
150
|
+
const ECHO = "../../etc/passwd-ECHO";
|
|
151
|
+
for (const bad of [ECHO, "", "not-a-uuid", live.jobId.toUpperCase(), `${live.jobId} `, 42, null, undefined, {}, [live.jobId]]) {
|
|
152
|
+
assert.deepEqual(m.handleCancel({ params: { jobId: bad, reason: "user" } }), { reject: "schema_violation" }, String(bad));
|
|
153
|
+
}
|
|
154
|
+
assert.deepEqual(m.handleCancel({ params: null }), { reject: "schema_violation" });
|
|
155
|
+
assert.equal(m.stats.cancelInvalid, 11);
|
|
156
|
+
assert.equal(m.stats.cancels, 0, "no lookup counted");
|
|
157
|
+
assert.equal(m.running, 1); assert.equal(runner.calls.cancelled.length, 0);
|
|
158
|
+
await tick(20);
|
|
159
|
+
assert.equal(session.of(live.jobId, "job.event").at(-1).payload.state, "running", "the live job is untouched");
|
|
160
|
+
assert.ok(!JSON.stringify(entries).includes("ECHO"));
|
|
161
|
+
// and the real cancel still works
|
|
162
|
+
assert.deepEqual(m.handleCancel({ params: { jobId: live.jobId, reason: "user" } }), { accept: true });
|
|
163
|
+
await until(() => session.of(live.jobId, "job.result").length === 1);
|
|
164
|
+
m.cancelAll();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("cancel is idempotent: twice on a running job, while it is being abandoned, after it finished, for an unknown id: always accepted, one result, one runner.cancel", async () => {
|
|
168
|
+
const stubborn = mgr({ runner: gatedRunner({ ignoreAbort: true }) });
|
|
169
|
+
const p = params();
|
|
170
|
+
stubborn.m.handleAssign(ctxOf(p));
|
|
171
|
+
await until(() => stubborn.runner.calls.started.length === 1);
|
|
172
|
+
for (let i = 0; i < 3; i++) assert.deepEqual(stubborn.m.handleCancel({ params: { jobId: p.jobId, reason: "timeout" } }), { accept: true });
|
|
173
|
+
assert.deepEqual(stubborn.runner.calls.cancelled, [p.jobId], "the runner is told once");
|
|
174
|
+
await until(() => stubborn.session.of(p.jobId, "job.result").length === 1, 2000);
|
|
175
|
+
const n = stubborn.session.frames.length;
|
|
176
|
+
for (let i = 0; i < 3; i++) assert.deepEqual(stubborn.m.handleCancel({ params: { jobId: p.jobId, reason: "timeout" } }), { accept: true });
|
|
177
|
+
assert.deepEqual(stubborn.m.handleCancel({ params: { jobId: uuid(), reason: "timeout" } }), { accept: true });
|
|
178
|
+
await tick(30);
|
|
179
|
+
assert.equal(stubborn.session.frames.length, n, "no frame, no oracle");
|
|
180
|
+
assert.equal(stubborn.session.of(p.jobId, "job.result").length, 1);
|
|
181
|
+
assert.equal(stubborn.m.running, 0); assert.equal(stubborn.m.reservedBytes, 0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an orphan the CP gave up: job.cancel before the job.assign -> the assign that arrives later is acked accepted and NOT run; a finished job's id is not turned into a tombstone", async () => {
|
|
185
|
+
const { m, runner, session } = mgr();
|
|
186
|
+
const orphan = params();
|
|
187
|
+
assert.deepEqual(m.handleCancel({ params: { jobId: orphan.jobId, reason: "timeout" } }), { accept: true });
|
|
188
|
+
assert.deepEqual(m.handleAssign(ctxOf(orphan)), { accept: true });
|
|
189
|
+
await tick(30);
|
|
190
|
+
assert.equal(runner.calls.started.length, 0);
|
|
191
|
+
assert.equal(m.activeCount, 0); assert.equal(m.reservedBytes, 0); assert.equal(m.running, 0);
|
|
192
|
+
assert.equal(session.frames.length, 0, "nothing was sent for it");
|
|
193
|
+
assert.equal(m.stats.cancelledBeforeAssign, 1);
|
|
194
|
+
// another job is unaffected
|
|
195
|
+
const ok = params();
|
|
196
|
+
m.handleAssign(ctxOf(ok));
|
|
197
|
+
await until(() => runner.calls.started.length === 1);
|
|
198
|
+
m.cancelAll();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("the tombstones are bounded: a flood of cancels for unknown ids forgets the oldest, never grows without limit", () => {
|
|
202
|
+
const { m } = mgr();
|
|
203
|
+
const first = uuid();
|
|
204
|
+
m.handleCancel({ params: { jobId: first, reason: "user" } });
|
|
205
|
+
for (let i = 0; i < 300; i++) m.handleCancel({ params: { jobId: uuid(), reason: "user" } });
|
|
206
|
+
// the oldest was evicted: its assign is no longer suppressed
|
|
207
|
+
assert.deepEqual(m.handleAssign(ctxOf(params({ jobId: first }))), { accept: true });
|
|
208
|
+
assert.equal(m.stats.cancelledBeforeAssign, 0);
|
|
209
|
+
assert.equal(m.activeCount, 1);
|
|
210
|
+
m.cancelAll();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ------------------------------------------------------------------------------------------------------ readFile of an abandoned job
|
|
214
|
+
|
|
215
|
+
test("readFile: after cancel / timeout / abandonment the runner reads nothing (no call to the reader), and the result of a read in flight is dropped", async () => {
|
|
216
|
+
let reads = 0;
|
|
217
|
+
const live = [];
|
|
218
|
+
const readFile = async ({ signal }) => { reads++; live.push(signal); await tick(40); return { ok: true, text: "SECRET-FILE-TEXT" }; };
|
|
219
|
+
const { m, runner } = mgr({ runner: gatedRunner({ ignoreAbort: true }), readFile });
|
|
220
|
+
const p = params();
|
|
221
|
+
m.handleAssign(ctxOf(p));
|
|
222
|
+
await until(() => runner.calls.started.length === 1);
|
|
223
|
+
const ctx = runner.calls.ctxs.get(p.jobId);
|
|
224
|
+
assert.deepEqual(await ctx.readFile("r"), { ok: true, text: "SECRET-FILE-TEXT" });
|
|
225
|
+
const inflight = ctx.readFile("r"); // in flight when the cancel arrives
|
|
226
|
+
m.handleCancel({ params: { jobId: p.jobId, reason: "user" } });
|
|
227
|
+
assert.deepEqual(await inflight, { ok: false, status: "cancelled" }, "the text of a read finished after the cancel is not handed over");
|
|
228
|
+
assert.equal(reads, 2);
|
|
229
|
+
assert.deepEqual(await ctx.readFile("r"), { ok: false, status: "cancelled" });
|
|
230
|
+
assert.equal(reads, 2, "no new read after the cancel");
|
|
231
|
+
await until(() => m.running === 0, 2000); // abandoned after the grace: the slot and the memory are free
|
|
232
|
+
assert.deepEqual(await ctx.readFile("r"), { ok: false, status: "cancelled" });
|
|
233
|
+
assert.equal(reads, 2, "nor after the runner was abandoned");
|
|
234
|
+
assert.equal(live.at(-1).aborted, true, "the signal handed to the reader is aborted: the descriptor is released by the reader");
|
|
235
|
+
assert.equal(m.reservedBytes, 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("readFile: a job that ended on its own (the runner returned) also closes the signal, so a lingering read is released", async () => {
|
|
239
|
+
const signals = [];
|
|
240
|
+
const readFile = async ({ signal }) => { signals.push(signal); await tick(30); return { ok: true, text: "x" }; };
|
|
241
|
+
const runner = { name: "leaky", needsLlm: false, supportsOptions: true, cancel: noop, async start(job, ctx) { ctx.readFile("r").catch(noop); return { outcome: "no_change" }; } };
|
|
242
|
+
const { m, session } = mgr({ runner, readFile });
|
|
243
|
+
const p = params();
|
|
244
|
+
m.handleAssign(ctxOf(p));
|
|
245
|
+
await until(() => session.of(p.jobId, "job.result").length === 1);
|
|
246
|
+
assert.equal(signals.length, 1);
|
|
247
|
+
assert.equal(signals[0].aborted, true);
|
|
248
|
+
assert.equal(m.running, 0);
|
|
249
|
+
assert.equal(m.stats.finished.succeeded, 1, "the aborted signal did not turn a success into a failure");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ------------------------------------------------------------------------------------------------------ atomic limits
|
|
253
|
+
|
|
254
|
+
test("limits under a synchronous burst: 200 job.assign in one tick -> exactly maxConcurrent run, exactly maxQueued wait, the rest rate_limited; counters never exceed the limits", async () => {
|
|
255
|
+
const { m, runner } = mgr({ env: { LOCAL_AGENT_JOBS_MAX_CONCURRENT: "3", LOCAL_AGENT_JOBS_MAX_QUEUED: "5" } });
|
|
256
|
+
const verdicts = [];
|
|
257
|
+
for (let i = 0; i < 200; i++) verdicts.push(m.handleAssign(ctxOf(params())));
|
|
258
|
+
assert.equal(verdicts.filter((v) => v.accept).length, 8);
|
|
259
|
+
assert.equal(verdicts.filter((v) => v.reject === "rate_limited").length, 192);
|
|
260
|
+
assert.equal(m.running, 3); assert.equal(m.activeCount, 8);
|
|
261
|
+
await until(() => runner.calls.started.length === 3);
|
|
262
|
+
await tick(30);
|
|
263
|
+
assert.equal(runner.calls.started.length, 3, "never more than maxConcurrent started");
|
|
264
|
+
// finish them one by one: the queue drains in order, running never exceeds 3
|
|
265
|
+
let maxRunning = 0;
|
|
266
|
+
for (let guard = 0; guard < 20 && m.activeCount > 0; guard++) {
|
|
267
|
+
maxRunning = Math.max(maxRunning, m.running);
|
|
268
|
+
for (const [id, gate] of [...runner.gates]) { gate.resolve({ outcome: "no_change" }); runner.gates.delete(id); }
|
|
269
|
+
await tick(15);
|
|
270
|
+
}
|
|
271
|
+
assert.equal(runner.calls.started.length, 8);
|
|
272
|
+
assert.ok(maxRunning <= 3);
|
|
273
|
+
assert.equal(m.activeCount, 0); assert.equal(m.running, 0); assert.equal(m.reservedBytes, 0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("limits: cancelling the waiting job frees its place in the queue; the queue keeps its order", async () => {
|
|
277
|
+
const { m, runner } = mgr({ env: { LOCAL_AGENT_JOBS_MAX_CONCURRENT: "1", LOCAL_AGENT_JOBS_MAX_QUEUED: "1" } });
|
|
278
|
+
const [a, b, c] = [params(), params(), params()];
|
|
279
|
+
m.handleAssign(ctxOf(a)); m.handleAssign(ctxOf(b));
|
|
280
|
+
assert.deepEqual(m.handleAssign(ctxOf(c)), { reject: "rate_limited" });
|
|
281
|
+
await until(() => runner.calls.started.length === 1);
|
|
282
|
+
m.handleCancel({ params: { jobId: b.jobId, reason: "user" } });
|
|
283
|
+
assert.deepEqual(m.handleAssign(ctxOf(c)), { accept: true }, "b's place is free again");
|
|
284
|
+
runner.gates.get(a.jobId).resolve({ outcome: "no_change" });
|
|
285
|
+
await until(() => runner.calls.started.length === 2);
|
|
286
|
+
assert.deepEqual(runner.calls.started, [a.jobId, c.jobId], "b never ran, c took its turn");
|
|
287
|
+
m.cancelAll();
|
|
288
|
+
});
|