feat(pi-workflow): improve workflow usability and recovery

This commit is contained in:
jb
2026-08-26 00:20:24 -04:00
parent c291ca083c
commit ea74dbc6a7
23 changed files with 4422 additions and 913 deletions
+77 -28
View File
@@ -12,6 +12,8 @@ import {
} from "./context-transition.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { deriveRunLabel } from "./run-label.ts";
import { workflowConfirm } from "./tui.ts";
import { WorkspaceValidationError } from "./workspace.ts";
const ACTIONS = ["start", "inspect", "decide", "abort"] as const;
const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
@@ -79,6 +81,7 @@ export const PiWorkflowParameters = Type.Object(
workflow: Type.Optional(WorkflowDefinition),
concurrency: Type.Optional(Type.Integer({ minimum: 1 })),
background: Type.Optional(Type.Boolean()),
allowDirtySource: Type.Optional(Type.Boolean()),
runId: Type.Optional(Type.String()),
gateId: Type.Optional(Type.String()),
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -149,6 +152,9 @@ export function workflowCommandFromTool(
...(input.background !== undefined
? { background: input.background }
: {}),
...(input.allowDirtySource !== undefined
? { allowDirtySource: input.allowDirtySource }
: {}),
...(definition.mode === "feature-script"
? {
script: {
@@ -397,17 +403,7 @@ async function cancelToolWorkflow(
input: PiWorkflowInput,
toolCallId: string,
) {
let runId = input.runId;
if (!runId && input.action === "start" && input.idempotencyKey) {
for (let attempt = 0; attempt < 50; attempt++) {
const start = await runtime.store.getStart(input.idempotencyKey);
if (start) {
runId = start.runId;
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
const runId = input.runId;
if (!runId) return;
const current = await runtime.store.loadRun(runId).catch(() => undefined);
if (
@@ -426,6 +422,25 @@ async function cancelToolWorkflow(
}).catch(() => {});
}
function dirtySourceWarning(error: WorkspaceValidationError) {
const visibleChanges = error.sourceChanges.slice(0, 6);
const changes = visibleChanges.map(
(change) => `${change.status} ${change.path}`,
);
const omitted =
error.sourceChanges.length - visibleChanges.length +
error.sourceChangesOmitted;
if (omitted > 0) {
changes.push(`[${omitted} additional paths omitted]`);
}
return [
"The workflow worktree will start at the repository's current HEAD.",
"These staged, unstaged, or untracked source-only changes will be omitted:",
...changes,
"Continue only if the accepted plan should exclude those changes.",
].join("\n");
}
export function registerPiWorkflowTool(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
@@ -434,7 +449,7 @@ export function registerPiWorkflowTool(
name: "pi_workflow",
label: "Pi Workflow",
description:
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.",
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.",
promptSnippet:
"Plan and build features through persisted Gates, worktrees, verification, and independent review",
promptGuidelines: [
@@ -447,29 +462,63 @@ export function registerPiWorkflowTool(
const engine = await runtime.engineForContext(ctx);
const command = workflowCommandFromTool(params, pi.getActiveTools());
const executionSignal = signal ?? new AbortController().signal;
const operation =
command.action === "decide"
? executeHostDecision(pi, engine, command)
: engine.execute(command);
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: new Error("pi_workflow tool call was cancelled");
const operation = (async () => {
if (executionSignal.aborted) throw cancellationError();
if (command.action === "decide") {
return executeHostDecision(pi, engine, command);
}
const initial =
command.action === "start" &&
ctx.mode === "tui" &&
command.allowDirtySource === undefined
? { ...command, allowDirtySource: false }
: command;
try {
return await engine.execute(initial);
} catch (error) {
if (
command.action !== "start" ||
ctx.mode !== "tui" ||
command.allowDirtySource !== undefined ||
!(error instanceof WorkspaceValidationError) ||
error.code !== "dirty-source"
) {
throw error;
}
const confirmed = await workflowConfirm(
ctx,
"Dirty source checkout",
dirtySourceWarning(error),
);
if (executionSignal.aborted) throw cancellationError();
if (!confirmed) {
throw new Error("Workflow start cancelled because source changes would be omitted");
}
return engine.execute({ ...command, allowDirtySource: true });
}
})();
let rejectCancellation = (_error: unknown) => {};
const cancellation = new Promise<never>((_resolve, reject) => {
rejectCancellation = reject;
});
let cancellationWork: Promise<void> | undefined;
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: new Error("pi_workflow tool call was cancelled");
const onAbort = () => {
cancellationWork = cancelToolWorkflow(
runtime,
engine,
params,
toolCallId,
cancellationWork =
command.action === "start" || command.action === "start-analysis"
? engine.cancelStart(
command.idempotencyKey,
`tool-cancel-${toolCallId}`,
cancellationError(),
)
: cancelToolWorkflow(runtime, engine, params, toolCallId);
void cancellationWork.then(
() => rejectCancellation(cancellationError()),
() => rejectCancellation(cancellationError()),
);
void cancellationWork.finally(() => {
rejectCancellation(cancellationError());
});
};
executionSignal.addEventListener("abort", onAbort, { once: true });
if (executionSignal.aborted) onAbort();