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
+42 -27
View File
@@ -40,34 +40,12 @@ function lowPriority() {
}
/**
* 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.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
* The command a git call runs — `{cmd, argv, env, gitCmd}` — exactly as
* `git()` builds it (scrubbed env, a member's `sudo -n -u ah-<name> git`,
* nice/ionice for `background`), without running it. A background clone's
* detached runner (`scripts/clone-runner.js`) executes this very command.
*/
async function git(
cwd,
args,
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
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,
} = {}
) {
function gitCommand(cwd, args, { literal = false, env: extraEnv = {}, background = false } = {}) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -118,6 +96,41 @@ async function git(
argv = [...rest, cmd, ...argv];
cmd = first;
}
return { cmd, argv, env, gitCmd };
}
/**
* 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.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(
cwd,
args,
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
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,
// An AbortSignal: aborting SIGTERMs the child (a cancelled clone).
signal = null,
} = {}
) {
const { cmd, argv, env, gitCmd } = gitCommand(cwd, args, { literal, env: extraEnv, background });
try {
const run =
@@ -142,6 +155,7 @@ async function git(
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
timeout,
encoding,
...(signal ? { signal } : {}),
});
// promisify(execFile) hands back its child; the stdin runner has none.
if (onStderr && pending.child?.stderr) {
@@ -1006,6 +1020,7 @@ async function hasNoRemotes(dir) {
}
module.exports = {
gitCommand,
LANES_ROOT,
git,
isGitRepo,