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).
183 lines
5.3 KiB
JavaScript
183 lines
5.3 KiB
JavaScript
/**
|
||
* @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,
|
||
};
|