feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
import * as path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import type { WorkflowWorkspace } from "./domain.ts";
|
||||
import type {
|
||||
SourceChangeEvidence,
|
||||
WorkflowWorkspace,
|
||||
} from "./domain.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -470,7 +473,9 @@ export class GitStateProbe {
|
||||
}
|
||||
|
||||
private async git(cwd: string, args: string[]) {
|
||||
const result = await this.runner.run(cwd, "git", args);
|
||||
const result = await this.runner.run(cwd, "git", args, {
|
||||
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Git state probe failed: ${result.stderr.trim()}`);
|
||||
}
|
||||
@@ -505,6 +510,7 @@ const PACKAGE_EXEC_SUBCOMMANDS = new Map<string, Set<string>>([
|
||||
["bun", new Set(["x"])],
|
||||
]);
|
||||
|
||||
export const INTERNAL_GIT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000;
|
||||
|
||||
export class ApprovedHostCommandExecutor {
|
||||
@@ -745,22 +751,21 @@ export class GitDiffProvider {
|
||||
|
||||
async read(worktreeRoot: string, baseHead = "HEAD") {
|
||||
const root = await realpath(path.resolve(worktreeRoot));
|
||||
const diff = await this.runner.run(root, "git", [
|
||||
"diff",
|
||||
"--binary",
|
||||
"--no-ext-diff",
|
||||
baseHead,
|
||||
"--",
|
||||
]);
|
||||
const diff = await this.runner.run(
|
||||
root,
|
||||
"git",
|
||||
["diff", "--binary", "--no-ext-diff", baseHead, "--"],
|
||||
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
|
||||
);
|
||||
if (diff.code !== 0) {
|
||||
throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`);
|
||||
}
|
||||
const untracked = await this.runner.run(root, "git", [
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
]);
|
||||
const untracked = await this.runner.run(
|
||||
root,
|
||||
"git",
|
||||
["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
{ timeoutMs: INTERNAL_GIT_TIMEOUT_MS },
|
||||
);
|
||||
if (untracked.code !== 0) {
|
||||
throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`);
|
||||
}
|
||||
@@ -796,6 +801,9 @@ export interface WorkspacePlan {
|
||||
branch: string;
|
||||
baseHead: string;
|
||||
branchExists: boolean;
|
||||
sourceChanges: SourceChangeEvidence[];
|
||||
sourceChangesOmitted: number;
|
||||
dirtySourceAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface PrepareWorkspaceOptions {
|
||||
@@ -803,23 +811,57 @@ export interface PrepareWorkspaceOptions {
|
||||
configuredRoot: string;
|
||||
featureName: string;
|
||||
runId: string;
|
||||
allowDirtySource?: boolean;
|
||||
}
|
||||
|
||||
export class WorkspaceValidationError extends Error {
|
||||
readonly code:
|
||||
| "not-repository"
|
||||
| "dirty-source"
|
||||
| "unmerged-source"
|
||||
| "unignored-root"
|
||||
| "existing-worktree-path"
|
||||
| "git-failed";
|
||||
readonly sourceChanges: SourceChangeEvidence[];
|
||||
readonly sourceChangesOmitted: number;
|
||||
|
||||
constructor(code: WorkspaceValidationError["code"], message: string) {
|
||||
constructor(
|
||||
code: WorkspaceValidationError["code"],
|
||||
message: string,
|
||||
evidence: {
|
||||
sourceChanges?: SourceChangeEvidence[];
|
||||
sourceChangesOmitted?: number;
|
||||
} = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkspaceValidationError";
|
||||
this.code = code;
|
||||
this.sourceChanges = evidence.sourceChanges ?? [];
|
||||
this.sourceChangesOmitted = evidence.sourceChangesOmitted ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_SOURCE_CHANGE_EVIDENCE = 20;
|
||||
|
||||
function sourceChangeEvidence(status: string) {
|
||||
const entries = Object.entries(changedPathStatuses(status)).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
const sourceChanges = entries
|
||||
.slice(0, MAX_SOURCE_CHANGE_EVIDENCE)
|
||||
.map(([changedPath, changedStatus]) => ({
|
||||
path: changedPath.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?"),
|
||||
status: changedStatus,
|
||||
}));
|
||||
return {
|
||||
sourceChanges,
|
||||
sourceChangesOmitted: Math.max(0, entries.length - sourceChanges.length),
|
||||
hasUnmerged: entries.some(([, changedStatus]) =>
|
||||
["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(changedStatus),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const PROCESS_TERMINATION_GRACE_MS = 100;
|
||||
const PROCESS_TERMINATION_POLL_MS = 10;
|
||||
const PROCESS_TERMINATION_TIMEOUT_MS = 1_000;
|
||||
@@ -845,9 +887,29 @@ function signalProcessGroup(processId: number, signal: NodeJS.Signals) {
|
||||
}
|
||||
}
|
||||
|
||||
async function forceKillAndReap(child: ChildProcess) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
if (process.platform !== "win32" && child.pid !== undefined) {
|
||||
signalProcessGroup(child.pid, "SIGKILL");
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, PROCESS_TERMINATION_GRACE_MS);
|
||||
child.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateProcessTree(processId: number) {
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("taskkill", ["/pid", String(processId), "/T", "/F"]);
|
||||
await execFileAsync(
|
||||
"taskkill",
|
||||
["/pid", String(processId), "/T", "/F"],
|
||||
{ timeout: PROCESS_TERMINATION_TIMEOUT_MS },
|
||||
);
|
||||
return;
|
||||
}
|
||||
signalProcessGroup(processId, "SIGTERM");
|
||||
@@ -865,6 +927,14 @@ async function terminateProcessTree(processId: number) {
|
||||
const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export class ExecFileCommandRunner implements CommandRunner {
|
||||
private readonly terminate: (processId: number) => Promise<void>;
|
||||
|
||||
constructor(options: {
|
||||
terminateProcessTree?: (processId: number) => Promise<void>;
|
||||
} = {}) {
|
||||
this.terminate = options.terminateProcessTree ?? terminateProcessTree;
|
||||
}
|
||||
|
||||
async run(
|
||||
cwd: string,
|
||||
executable: string,
|
||||
@@ -880,7 +950,12 @@ export class ExecFileCommandRunner implements CommandRunner {
|
||||
let timedOut = false;
|
||||
let outputExceeded = false;
|
||||
let termination: Promise<void> | undefined;
|
||||
let terminationFinished = false;
|
||||
let terminationFailure: string | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let terminationDeadline: NodeJS.Timeout | undefined;
|
||||
let terminationDeadlineReached = false;
|
||||
let settled = false;
|
||||
const child = spawn(executable, args, {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
@@ -889,7 +964,48 @@ export class ExecFileCommandRunner implements CommandRunner {
|
||||
});
|
||||
const terminate = () => {
|
||||
if (!termination && child.pid !== undefined) {
|
||||
termination = terminateProcessTree(child.pid);
|
||||
termination = this.terminate(child.pid);
|
||||
void termination.then(
|
||||
() => { terminationFinished = true; },
|
||||
(failure: unknown) => {
|
||||
terminationFinished = true;
|
||||
terminationFailure = failure instanceof Error
|
||||
? failure.message
|
||||
: String(failure);
|
||||
},
|
||||
);
|
||||
terminationDeadline = setTimeout(() => {
|
||||
if (settled) return;
|
||||
terminationDeadlineReached = true;
|
||||
const terminationError =
|
||||
terminationFailure ??
|
||||
(terminationFinished
|
||||
? "process did not exit after process tree termination"
|
||||
: "process tree termination did not finish within its deadline");
|
||||
void forceKillAndReap(child).then(
|
||||
() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
code: 125,
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: `Host command termination failed: ${terminationError}`,
|
||||
});
|
||||
},
|
||||
(failure: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const detail = failure instanceof Error
|
||||
? failure.message
|
||||
: String(failure);
|
||||
resolve({
|
||||
code: 125,
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: `Host command termination failed: ${terminationError}; final force-kill failed: ${detail}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
}, PROCESS_TERMINATION_TIMEOUT_MS + PROCESS_TERMINATION_GRACE_MS + 100);
|
||||
}
|
||||
};
|
||||
const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => {
|
||||
@@ -909,6 +1025,7 @@ export class ExecFileCommandRunner implements CommandRunner {
|
||||
spawnError = error;
|
||||
});
|
||||
child.on("close", async (code) => {
|
||||
if (settled) return;
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
let terminationError: string | undefined;
|
||||
try {
|
||||
@@ -916,6 +1033,9 @@ export class ExecFileCommandRunner implements CommandRunner {
|
||||
} catch (failure) {
|
||||
terminationError = failure instanceof Error ? failure.message : String(failure);
|
||||
}
|
||||
if (settled || terminationDeadlineReached) return;
|
||||
settled = true;
|
||||
if (terminationDeadline !== undefined) clearTimeout(terminationDeadline);
|
||||
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
if (timedOut) {
|
||||
@@ -980,8 +1100,9 @@ export class GitWorkspaceManager {
|
||||
|
||||
const status = await this.git(sourceRoot, [
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--untracked-files=normal",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--untracked-files=all",
|
||||
]);
|
||||
if (status.code !== 0) {
|
||||
throw new WorkspaceValidationError(
|
||||
@@ -989,10 +1110,19 @@ export class GitWorkspaceManager {
|
||||
`Unable to inspect source state: ${status.stderr.trim()}`,
|
||||
);
|
||||
}
|
||||
if (status.stdout.trim().length > 0) {
|
||||
const evidence = sourceChangeEvidence(status.stdout);
|
||||
if (evidence.hasUnmerged) {
|
||||
throw new WorkspaceValidationError(
|
||||
"unmerged-source",
|
||||
"Source checkout has unmerged paths; resolve conflicts before creating a workflow worktree",
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
if (evidence.sourceChanges.length > 0 && !options.allowDirtySource) {
|
||||
throw new WorkspaceValidationError(
|
||||
"dirty-source",
|
||||
"Source checkout must be clean before creating a workflow worktree",
|
||||
"Source checkout has changes that are not included in the workflow worktree created from HEAD",
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1058,6 +1188,10 @@ export class GitWorkspaceManager {
|
||||
branch,
|
||||
baseHead: head.stdout.trim(),
|
||||
branchExists: branchCheck.code === 0,
|
||||
sourceChanges: evidence.sourceChanges,
|
||||
sourceChangesOmitted: evidence.sourceChangesOmitted,
|
||||
dirtySourceAllowed:
|
||||
evidence.sourceChanges.length > 0 && options.allowDirtySource === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1075,6 +1209,9 @@ export class GitWorkspaceManager {
|
||||
root: plan.worktreePath,
|
||||
branch: plan.branch,
|
||||
baseHead: plan.baseHead,
|
||||
sourceChanges: structuredClone(plan.sourceChanges),
|
||||
sourceChangesOmitted: plan.sourceChangesOmitted,
|
||||
dirtySourceAllowed: plan.dirtySourceAllowed,
|
||||
error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`,
|
||||
};
|
||||
}
|
||||
@@ -1098,6 +1235,9 @@ export class GitWorkspaceManager {
|
||||
root: plan.worktreePath,
|
||||
branch: plan.branch,
|
||||
baseHead: plan.baseHead,
|
||||
sourceChanges: structuredClone(plan.sourceChanges),
|
||||
sourceChangesOmitted: plan.sourceChangesOmitted,
|
||||
dirtySourceAllowed: plan.dirtySourceAllowed,
|
||||
error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`,
|
||||
};
|
||||
}
|
||||
@@ -1111,6 +1251,9 @@ export class GitWorkspaceManager {
|
||||
root: plan.worktreePath,
|
||||
branch: plan.branch,
|
||||
baseHead: plan.baseHead,
|
||||
sourceChanges: structuredClone(plan.sourceChanges),
|
||||
sourceChangesOmitted: plan.sourceChangesOmitted,
|
||||
dirtySourceAllowed: plan.dirtySourceAllowed,
|
||||
error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`,
|
||||
});
|
||||
}
|
||||
@@ -1120,6 +1263,9 @@ export class GitWorkspaceManager {
|
||||
root: plan.worktreePath,
|
||||
branch: plan.branch,
|
||||
baseHead: plan.baseHead,
|
||||
sourceChanges: structuredClone(plan.sourceChanges),
|
||||
sourceChangesOmitted: plan.sourceChangesOmitted,
|
||||
dirtySourceAllowed: plan.dirtySourceAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1240,6 +1386,8 @@ export class GitWorkspaceManager {
|
||||
}
|
||||
|
||||
private git(cwd: string, args: string[]) {
|
||||
return this.runner.run(cwd, "git", args);
|
||||
return this.runner.run(cwd, "git", args, {
|
||||
timeoutMs: INTERNAL_GIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user