chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
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).
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user