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:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* @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,
};
+28 -8
View File
@@ -44,8 +44,8 @@ const gitCredentials = require("./git-credentials");
const runAs = require("./run-as");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
/** A clone may take a while on a big repo; past this it is killed. */
const CLONE_TIMEOUT_MS = 10 * 60_000;
/** A clone may take a while on a big repo (~1 GB); past this it is killed. */
const CLONE_TIMEOUT_MS = 30 * 60_000;
const HELPER = path.join(__dirname, "git-credential-helper.js");
const err = (code, message) => Object.assign(new Error(message), { code });
@@ -841,15 +841,33 @@ function sshFailure(stderr) {
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
* (`{username, password, remember}`) — https only, for this URL's host only.
* `partial` makes it a blobless clone (`--filter=blob:none`): every commit
* and tree, but a file's contents only when a checkout first needs them —
* far less to download and unpack for a big repo, at the cost of a fetch
* from the remote when an old revision is checked out later.
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
* was refused, else the git error.
*/
async function cloneRepo({ url, parent, name, once = null }) {
function cloneRepo({ job = null, ...opts }) {
// Waits for a free clone slot (clone-queue.js) before anything — a typed
// login sits in a member's store only while its own clone runs, and the
// timeout counts from there. `job` (clone-queue `track`) gets the queue
// position and git's progress.
const queue = require("./clone-queue");
return queue.withCloneSlot(
() => cloneNow(opts, queue.threads(), (chunk) => queue.report(job, chunk)),
job
);
}
async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
const pre = [];
// index-pack's delta threads: this clone's share of the cores, not all of
// them (clone-queue.js). Not persisted — a later fetch keeps git's default.
const pre = ["-c", `pack.threads=${threads}`];
const persist = [];
const env = {};
let restore = null;
@@ -911,9 +929,13 @@ async function cloneRepo({ url, parent, name, once = null }) {
let ok = false;
try {
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
const filter = partial ? ["--filter=blob:none"] : [];
// --progress: git prints it to a pipe too, for the UI's progress line.
await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], {
timeout: CLONE_TIMEOUT_MS,
env,
background: true,
onStderr: onProgress,
});
ok = true;
} catch (e) {
@@ -932,9 +954,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
}
throw err(
"ECLONE",
String(stderr || e.message)
.trim()
.slice(-800)
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
);
} finally {
// A login typed without "remember" — or one that did not work — leaves
+43 -2
View File
@@ -19,6 +19,26 @@ const PROTECTED_BRANCHES = new Set(["main", "master"]);
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
const STATUS_FILE_CAP = 20;
/**
* `nice`/`ionice` before a heavy git (a clone) so the dashboard and the panes
* beside it keep their share of the machine; children (index-pack, sudo's
* git) inherit both. Empty where neither exists (Windows).
*/
let lowPriorityPrefix = null;
function lowPriority() {
if (lowPriorityPrefix) return lowPriorityPrefix;
const find = (name) =>
["/usr/bin", "/bin"].map((d) => path.join(d, name)).find((f) => fs.existsSync(f));
const nice = process.platform === "win32" ? null : find("nice");
const ionice = process.platform === "linux" ? find("ionice") : null;
lowPriorityPrefix = [
...(nice ? [nice, "-n", "10"] : []),
// best-effort class, lowest level: never the idle class, which can wait forever
...(ionice ? [ionice, "-c", "2", "-n", "7"] : []),
];
return lowPriorityPrefix;
}
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
@@ -42,6 +62,10 @@ async function git(
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
// true: run under nice/ionice (a clone — see lowPriority above).
background = false,
// Called with each stderr chunk as it arrives (a clone's --progress).
onStderr = null,
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
@@ -88,6 +112,12 @@ async function git(
if (literal) pass.GIT_LITERAL_PATHSPECS = "1";
[cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass);
}
const gitCmd = cmd;
if (background && lowPriority().length) {
const [first, ...rest] = lowPriority();
argv = [...rest, cmd, ...argv];
cmd = first;
}
try {
const run =
@@ -100,7 +130,7 @@ async function git(
);
child.stdin.end(input);
});
const result = await run(cmd, argv, {
const pending = run(cmd, argv, {
cwd,
env,
// A read with its own size cap passes a bigger buffer and treats an
@@ -113,12 +143,23 @@ async function git(
timeout,
encoding,
});
// promisify(execFile) hands back its child; the stdin runner has none.
if (onStderr && pending.child?.stderr) {
pending.child.stderr.on("data", (d) => {
try {
onStderr(String(d));
} catch {
/* a progress reader never fails the git call */
}
});
}
const result = await pending;
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
// With the folder present it is git itself that is absent — say so, with
// the fix, rather than `git status failed` (like ETMUXMISSING for tmux).
if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) {
if (err.code === "ENOENT" && gitCmd === "git" && fs.existsSync(cwd)) {
const error = new Error(
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +