Make workspace archive self-healing during setup (#2987)

* fix(server): make workspace archive self-healing

Background setup could keep writing after its workspace record was archived, causing disk cleanup to fail and the initiating client to restore a local ghost. Track setup per daemon so archive can stop it, and let repeated archive requests finish residual cleanup after the record has committed.

* fix(server): await setup process termination

Archive must not proceed from setup cancellation until the process-tree terminator has completed. Join that promise to the streamed command's existing finish path so child close cannot release workspace cleanup early.
This commit is contained in:
Mohamed Boudra
2026-08-07 16:47:33 +02:00
committed by GitHub
parent 6abfa242d8
commit c9a9d8fdce
8 changed files with 269 additions and 32 deletions
+22 -3
View File
@@ -37,6 +37,7 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
import { terminateWithTreeKill } from "./tree-kill.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -449,6 +450,7 @@ async function execSetupCommandStreamed(options: {
env: NodeJS.ProcessEnv;
index: number;
total: number;
signal?: AbortSignal;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult> {
return new Promise((resolvePromise) => {
@@ -456,6 +458,7 @@ async function execSetupCommandStreamed(options: {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
let settled = false;
let termination: Promise<unknown> | null = null;
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
const text = stripAnsi(chunk);
@@ -478,11 +481,13 @@ async function execSetupCommandStreamed(options: {
});
};
const finish = (exitCode: number | null) => {
const finish = async (exitCode: number | null) => {
if (settled) {
return;
}
settled = true;
options.signal?.removeEventListener("abort", abort);
await termination;
const result: WorktreeSetupCommandResult = {
command: options.command,
cwd: options.cwd,
@@ -521,6 +526,18 @@ async function execSetupCommandStreamed(options: {
stdio: ["ignore", "pipe", "pipe"],
});
const abort = () => {
termination ??= terminateWithTreeKill(child, {
gracefulTimeoutMs: 1000,
forceTimeoutMs: 1000,
});
};
if (options.signal?.aborted) {
abort();
} else {
options.signal?.addEventListener("abort", abort, { once: true });
}
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
@@ -531,11 +548,11 @@ async function execSetupCommandStreamed(options: {
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
void finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
void finish(typeof code === "number" ? code : null);
});
});
}
@@ -621,6 +638,7 @@ export async function runWorktreeSetupCommands(options: {
cleanupOnFailure: boolean;
repoRootPath?: string;
runtimeEnv?: WorktreeRuntimeEnv;
signal?: AbortSignal;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
@@ -647,6 +665,7 @@ export async function runWorktreeSetupCommands(options: {
env: setupEnv,
index: index + 1,
total: setupCommands.length,
signal: options.signal,
onEvent: options.onEvent,
})
: await execSetupCommand(cmd, {