feat(clone): background clone jobs that survive a reload and a server restart

Both clone POSTs take background: true: every check answers synchronously,
then 202 {job}. git runs in a detached runner (scripts/clone-runner.js, its
own process group, stderr to a file — no SIGPIPE when the server restarts);
the server tails it for progress and runs the after-step (workspace / extra
folder) once when it exits. Jobs persist in <data dir>/runtime/clone-jobs.json
(0600, atomic, throttled progress writes); on start a live runner is adopted
(counted against the clone limit), an ended one finished, a queued one
requeued (interrupted if it carried a typed login), the rest interrupted.
GET /clone/jobs, DELETE /clone/jobs/:id, POST /clone/jobs/:id/cancel (queued:
dequeued; running: SIGTERM to the runner's group -> cancelled, no after-step)
and /retry (new job from the stored request, never a credential). The
held-open path is unchanged; worktree.gitCommand builds the runner's argv
and git-identity.cloneFailure the shared error classification.
This commit is contained in:
2026-11-02 10:47:16 +07:00
parent 57cbab916a
commit a27589418c
13 changed files with 2033 additions and 143 deletions
+47 -5
View File
@@ -9,6 +9,8 @@
* 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.
* A background clone (`server/lib/clone-jobs.js`) passes its own job record,
* whose `onChange` hears each state change and progress line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -46,15 +48,18 @@ function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
if (job) {
job.state = "running";
job.onChange?.("state");
}
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 });
await new Promise((start, reject) => {
waiting.push({ start, reject, job });
next();
});
try {
@@ -136,6 +141,7 @@ function report(job, chunk) {
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
job.onChange?.("progress");
return;
}
}
@@ -149,10 +155,9 @@ 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,
position: position(job),
...cloneQueueState(),
phase: job.phase,
label: job.label,
@@ -161,6 +166,40 @@ function progress(token, owner) {
};
}
/**
* Counts a clone this process did not start — a background clone's runner
* adopted after a restart (clone-jobs.js) — against the limit, at once.
* Returns its release.
*/
function occupy() {
running++;
let released = false;
return () => {
if (released) return;
released = true;
running--;
next();
};
}
/**
* Takes a still-waiting clone out of the queue: its `withCloneSlot` rejects
* with `ECANCELLED` and it never starts. False when it is not waiting.
*/
function dequeue(job) {
const i = waiting.findIndex((w) => w.job === job);
if (i < 0) return false;
const [w] = waiting.splice(i, 1);
w.reject(Object.assign(new Error("Clone cancelled"), { code: "ECANCELLED" }));
return true;
}
/** A queued clone's 1-based place among the waiting ones, else null. */
function position(job) {
const i = waiting.findIndex((w) => w.job === job);
return i >= 0 ? i + 1 : null;
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
@@ -178,5 +217,8 @@ module.exports = {
finish,
report,
progress,
position,
dequeue,
occupy,
stripProgress,
};