feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user