Files
AgentHub/server/lib/clone-queue.js
T
nntrivi2001 49f87c4da3 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).
2026-10-19 17:19:41 +07:00

183 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file The machine-wide limit on `git clone`s running at once, and each
* clone's progress. A clone's index-pack resolves deltas on every core it
* finds, so a few big (~1 GB) clones started together pinned all of them and
* stalled the dashboard and every pane beside it. Clones past the limit wait
* here, in arrival order, and each one gets its share of the cores
* (`threads`) — the clone's own timeout starts only once it runs.
* `AGENTHUB_CLONE_CONCURRENCY` overrides the default (half the cores, at
* least 1). A clone the browser named with a token (`track`) reports its
* place in the queue, then git's own `--progress` lines (`report`), to
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const os = require("node:os");
/** A finished clone's progress stays readable this long, then is dropped. */
const KEEP_DONE_MS = 60_000;
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
function cores() {
return Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) || 1);
}
/** How many clones may run at once. */
function concurrency() {
const set = Number.parseInt(process.env.AGENTHUB_CLONE_CONCURRENCY, 10);
if (Number.isInteger(set) && set > 0) return set;
return Math.max(1, Math.floor(cores() / 2));
}
/** index-pack threads for one clone: the cores split between the running ones. */
function threads() {
return Math.max(1, Math.floor(cores() / concurrency()));
}
let running = 0;
/** `{start, job}` in arrival order; `job` is null for an untracked clone. */
const waiting = [];
/** token → job: `{owner, state, phase, label, percent, detail, finishedAt}`. */
const jobs = new Map();
function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
start();
}
}
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
async function withCloneSlot(fn, job = null) {
await new Promise((start) => {
waiting.push({ start, job });
next();
});
try {
return await fn();
} finally {
running--;
next();
}
}
/** `{running, waiting, limit}` — for a status read or a test. */
function cloneQueueState() {
return { running, waiting: waiting.length, limit: concurrency() };
}
function sweep(now = Date.now()) {
for (const [token, job] of jobs) {
if (job.finishedAt && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(token);
}
}
/**
* A progress record for the clone the browser named `token`, owned by
* `owner` (`scope.ownerFor`). Throws `EBADFIELD` for a malformed token or one
* still in use.
*/
function track(token, owner) {
sweep();
if (typeof token !== "string" || !TOKEN_RE.test(token) || jobs.has(token)) {
throw Object.assign(new Error("progressToken: 16–64 of [A-Za-z0-9_-], unused"), {
code: "EBADFIELD",
});
}
const job = {
owner,
state: "queued",
phase: null,
label: null,
percent: null,
detail: null,
finishedAt: null,
};
jobs.set(token, job);
return job;
}
/** Marks a tracked clone over: `done` or `failed`. */
function finish(job, ok) {
if (!job) return;
job.state = ok ? "done" : "failed";
job.finishedAt = Date.now();
}
/** git's progress words → a phase the UI translates; anything else is `other`. */
const PHASES = [
[/^remote: /, "remote"],
[/^Receiving objects/, "receiving"],
[/^Resolving deltas/, "resolving"],
[/^Updating files/, "checkout"],
];
const LINE_RE = /^((?:remote: )?[A-Za-z][A-Za-z ]*?):\s+(\d{1,3})% \(([^)]*)\)(.*)$/;
/**
* Feeds a chunk of `git clone --progress` stderr into `job`: the LAST
* complete progress line in it wins (git redraws a line with `\r`).
*/
function report(job, chunk) {
if (!job) return;
const lines = String(chunk).split(/[\r\n]+/);
for (let i = lines.length - 1; i >= 0; i--) {
const m = LINE_RE.exec(lines[i].trim());
if (!m) continue;
job.label = m[1].replace(/^remote: /, "");
job.phase = (PHASES.find(([re]) => re.test(m[1])) || [null, "other"])[1];
job.percent = Math.min(100, Number(m[2]));
// "1234/5678), 120.50 MiB | 5.20 MiB/s, done." → counts + size + speed
job.detail =
`${m[3]}${m[4]}`
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
return;
}
}
/**
* What `GET /api/lanes/clone/progress/:token` answers, or null when there is
* no such clone or it is not `owner`'s. A queued clone carries its 1-based
* `position` among the waiting ones.
*/
function progress(token, owner) {
sweep();
const job = jobs.get(token);
if (!job || job.owner !== owner) return null;
const position = waiting.findIndex((w) => w.job === job);
return {
state: job.state,
position: position >= 0 ? position + 1 : null,
...cloneQueueState(),
phase: job.phase,
label: job.label,
percent: job.percent,
detail: job.detail,
};
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
.split(/[\r\n]+/)
.filter((l) => l.trim() && !LINE_RE.test(l.trim()))
.join("\n");
}
module.exports = {
withCloneSlot,
cloneQueueState,
concurrency,
threads,
track,
finish,
report,
progress,
stripProgress,
};