49f87c4da3
Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById).
133 lines
5.1 KiB
JavaScript
133 lines
5.1 KiB
JavaScript
/**
|
|
* @file Tests for `server/lib/clone-queue.js`: no more clones run at once
|
|
* than the limit, the rest start in arrival order as slots free, a failing
|
|
* clone frees its slot too, the per-clone thread share, and a tracked
|
|
* clone's progress: its queue position, git's --progress lines parsed, and
|
|
* shown only to its owner.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, afterEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const os = require("node:os");
|
|
|
|
const queue = require("../lib/clone-queue");
|
|
|
|
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
|
|
|
|
function gate() {
|
|
let open;
|
|
const p = new Promise((r) => (open = r));
|
|
return { p, open };
|
|
}
|
|
|
|
describe("clone-queue", () => {
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
|
|
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
|
|
});
|
|
|
|
it("runs at most the limit at once, the rest in arrival order", async () => {
|
|
process.env.AGENTHUB_CLONE_CONCURRENCY = "2";
|
|
const started = [];
|
|
const gates = [gate(), gate(), gate(), gate()];
|
|
const runs = gates.map((g, i) =>
|
|
queue.withCloneSlot(async () => {
|
|
started.push(i);
|
|
await g.p;
|
|
return i;
|
|
})
|
|
);
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.deepEqual(started, [0, 1]);
|
|
assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 });
|
|
gates[1].open();
|
|
await runs[1];
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.deepEqual(started, [0, 1, 2]);
|
|
gates[0].open();
|
|
gates[2].open();
|
|
gates[3].open();
|
|
assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]);
|
|
assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 });
|
|
});
|
|
|
|
it("frees the slot when a clone fails", async () => {
|
|
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
|
await assert.rejects(
|
|
queue.withCloneSlot(async () => {
|
|
throw new Error("boom");
|
|
}),
|
|
/boom/
|
|
);
|
|
assert.equal(await queue.withCloneSlot(async () => "ok"), "ok");
|
|
assert.equal(queue.cloneQueueState().running, 0);
|
|
});
|
|
|
|
it("splits the cores between the running clones", () => {
|
|
const cores = os.availableParallelism?.() ?? os.cpus().length;
|
|
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
|
assert.equal(queue.threads(), Math.max(1, cores));
|
|
process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4);
|
|
assert.equal(queue.threads(), 1);
|
|
delete process.env.AGENTHUB_CLONE_CONCURRENCY;
|
|
assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2)));
|
|
});
|
|
|
|
it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => {
|
|
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
|
const first = gate();
|
|
const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7);
|
|
const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7);
|
|
const ra = queue.withCloneSlot(() => first.p, a);
|
|
let release;
|
|
const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b);
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running");
|
|
const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7);
|
|
assert.equal(waitingView.state, "queued");
|
|
assert.equal(waitingView.position, 1);
|
|
assert.equal(waitingView.running, 1);
|
|
// Another user — or nobody — never sees it.
|
|
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null);
|
|
assert.equal(queue.progress("tok-cccccccccccccccc", 7), null);
|
|
|
|
queue.report(
|
|
a,
|
|
"Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" +
|
|
"Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s"
|
|
);
|
|
const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7);
|
|
assert.equal(v.phase, "receiving");
|
|
assert.equal(v.percent, 45);
|
|
assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s");
|
|
queue.report(a, "Resolving deltas: 100% (300/300), done.\n");
|
|
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving");
|
|
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300");
|
|
|
|
first.open();
|
|
await ra;
|
|
queue.finish(a, true);
|
|
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done");
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running");
|
|
release();
|
|
await rb;
|
|
queue.finish(b, false);
|
|
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed");
|
|
});
|
|
|
|
it("refuses a malformed or reused token, and keeps progress out of an error", () => {
|
|
assert.throws(() => queue.track("short", null), { code: "EBADFIELD" });
|
|
assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" });
|
|
queue.track("tok-dddddddddddddddd", null);
|
|
assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" });
|
|
assert.equal(
|
|
queue.stripProgress(
|
|
"Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n"
|
|
),
|
|
"Cloning into 'r'...\nfatal: early EOF"
|
|
);
|
|
});
|
|
});
|