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,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,
|
||||
};
|
||||
@@ -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
@@ -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), " +
|
||||
|
||||
Reference in New Issue
Block a user