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
@@ -13,4 +13,8 @@ selection:
# Feature designer
Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible. The plan is complete when an implementer can execute it without inventing missing contracts or crossing another assignment's ownership.
Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible.
Emit exactly one eligible `implement` assignment, one eligible `verify` assignment, and one eligible independent `review` assignment. For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment whose owned paths isolate prototype artifacts from production implementation ownership. The prototype assignment is required even when the plan prose already describes prototypes. For `none` or `minor` UI impact, emit no prototype assignment.
The plan is complete only when every required assignment is present and an implementer can execute it without inventing missing contracts or crossing another assignment's ownership.
+115 -37
View File
@@ -131,8 +131,16 @@ function commandContext(options: {
hasUI?: boolean;
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>;
customResults?: unknown[];
onCustomRender?: (lines: string[]) => void;
}) {
const notifications: string[] = [];
const customResults = [...(options.customResults ?? [])];
const overlayTheme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = {
cwd: "/repo",
sessionManager: {
@@ -145,6 +153,24 @@ function commandContext(options: {
notify(message: string) {
notifications.push(message);
},
custom: async (
factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: typeof overlayTheme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
let completed: unknown;
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
overlayTheme,
{},
(value) => { completed = value; },
);
options.onCustomRender?.(component.render(100));
return customResults.length > 0 ? customResults.shift() : completed;
},
input: options.input ?? (async () => undefined),
select: options.select ?? (async () => undefined),
},
@@ -233,13 +259,11 @@ test("/workflow start prompts in the TUI and requires a message otherwise", asyn
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
input: async (title) => {
prompt = title;
return "Add workflow lifecycle commands";
},
customResults: ["Add workflow lifecycle commands"],
onCustomRender: (lines) => { prompt = lines.join("\n"); },
}).context,
);
assert.equal(prompt, "What do you want to start working on?");
assert.match(prompt, /What do you want to start working on\?/);
assert.ok(
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
);
@@ -284,8 +308,7 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
],
assignments: [],
}) as unknown as WorkflowRun;
const selectedTitles: string[] = [];
const selectedOptions: string[][] = [];
const renderedSelections: string[] = [];
const inspected: string[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
@@ -325,29 +348,17 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
selectedTitles.push(title);
selectedOptions.push(options);
return undefined;
},
onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
});
await registered.command()("continue", interactive.context);
await registered.command()("resume", interactive.context);
assert.deepEqual(selectedTitles, [
"Continue Workflow Run",
"Continue Workflow Run",
]);
assert.equal(selectedOptions[0]?.length, 2);
assert.equal(selectedOptions[1]?.length, 2);
assert.deepEqual(selectedOptions[0], [
"shared-feature · run-1 · waiting",
"shared-feature · run-2 · waiting",
]);
assert.ok(
selectedOptions.every((options) =>
options.every((label) => !label.includes("historical")),
),
);
assert.equal(renderedSelections.length, 2);
for (const rendered of renderedSelections) {
assert.match(rendered, /Continue Workflow Run/);
assert.match(rendered, /shared-feature · run-1/);
assert.match(rendered, /shared-feature · run-2/);
assert.doesNotMatch(rendered, /historical/);
}
await registered.command()("resume run-2", interactive.context);
assert.deepEqual(inspected, ["run-2"]);
@@ -437,23 +448,20 @@ test("/workflow stop selects among active runs owned by the current session", as
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
let choices: string[] = [];
let choices = "";
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
select: async (title, options) => {
assert.equal(title, "Stop Workflow Run");
choices = options;
return options[1];
},
customResults: [run("run-2", "waiting", "session-1")],
onCustomRender: (lines) => { choices = lines.join("\n"); },
});
await registered.command()("stop", command.context);
assert.deepEqual(choices, [
"duplicate-label · run-1 · running",
"duplicate-label · run-2 · waiting",
]);
assert.match(choices, /Stop Workflow Run/);
assert.match(choices, /duplicate-label · run-1/);
assert.match(choices, /duplicate-label · run-2/);
assert.doesNotMatch(choices, /completed|historical/);
assert.deepEqual(
commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
@@ -686,6 +694,76 @@ test("cancelled fresh-session replacement clears the source-bound intent", async
});
});
test("dashboard continuation immediately enters the fresh-session transition", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const implementationContext: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 10, implementationContext);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = undefined;
next.runRevision = 12;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const registered = fakeApi();
const dashboardArguments: string[] = [];
registerWorkflowContextTransitions(
registered.api,
{
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime,
async (args) => {
dashboardArguments.push(args);
return { continueRunId: "run-1" };
},
);
let newSessionCalls = 0;
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async () => {
newSessionCalls++;
return { cancelled: true };
}) as ExtensionCommandContext["newSession"],
});
await registered.command()("run-1", command.context);
assert.deepEqual(dashboardArguments, ["run-1"]);
assert.equal(newSessionCalls, 1);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "continue-context", "recover-context-intent"],
);
});
});
test("a later startup recovers an unconsumed claimed handoff", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
+13 -6
View File
@@ -17,6 +17,7 @@ import {
import { isValidContextContinuation } from "./continuation.ts";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
import { workflowInput, workflowSelect } from "./tui.ts";
const MAX_HANDOFF_BYTES = 32 * 1024;
type NewSessionOptions = NonNullable<
@@ -259,11 +260,16 @@ async function selectRun(
title: string,
runs: WorkflowRun[],
) {
const labels = deriveRunLabels(runs).map(
(label, index) => `${label} · ${runs[index]!.status}`,
const labels = deriveRunLabels(runs);
return workflowSelect(
ctx,
title,
runs.map((run, index) => ({
value: run,
label: labels[index]!,
description: run.status,
})),
);
const selected = await ctx.ui.select(title, labels);
return runs[labels.indexOf(selected ?? "")];
}
async function continueRun(
@@ -336,9 +342,10 @@ async function handleStart(
return;
}
initialRequest =
(await ctx.ui.input(
(await workflowInput(
ctx,
"Start Workflow Run",
"What do you want to start working on?",
"Describe the feature request",
))?.trim() ?? "";
if (!initialRequest) return;
}
+453 -214
View File
@@ -8,16 +8,12 @@ import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import {
DEFAULT_WORKFLOW_CONFIG,
type WorkflowRun,
} from "./domain.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import {
FocusedWorkflowDashboard,
openWorkflowDashboard,
} from "./dashboard.ts";
import { openWorkflowDashboard } from "./dashboard.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
@@ -86,14 +82,30 @@ function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
};
}
const theme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
function prototypeCandidate() {
return {
id: "candidate-1",
revision: 2,
assignmentId: "prototype-assignment-1",
profileName: "frontend-prototyper",
status: "succeeded" as const,
path: "/repo/prototypes/candidate-1",
route: "/search?variant=1",
structuralRationale: "Compact drill-down flow",
responsiveChecks: ["mobile", "desktop"],
browserEvidence: ["validated in browser"],
launchInstructions: "Run the prototype server.",
visualDigest: "visual-digest",
artifactPath: "/state/artifacts/candidate-1.md",
retainedArtifacts: ["/state/artifacts/candidate-1.png"],
};
}
function view(workflow: WorkflowRun): WorkflowView {
return {
runId: workflow.runId,
featureName: workflow.featureName,
request: workflow.request,
revision: workflow.revision,
status: workflow.status,
background: workflow.background,
@@ -113,76 +125,141 @@ function view(workflow: WorkflowRun): WorkflowView {
};
}
test("focused dashboard renders compact and narrow cards within width", () => {
let selected: string | null | undefined;
const dashboard = new FocusedWorkflowDashboard(
[run()],
theme,
(value) => {
selected = value;
function tuiContext(results: unknown[], sessionId = "session-1") {
const notifications: string[] = [];
const renders: string[][] = [];
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const unsupported = () => {
throw new Error("built-in bounded dialog must not be called");
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: {
getSessionId: () => sessionId,
},
);
dashboard.handleInput("\r");
const wide = dashboard.render(100);
assert.ok(wide.some((line) => line.includes("Progress")));
assert.ok(wide.some((line) => line.includes("Worktree")));
assert.ok(wide.every((line) => visibleWidth(line) <= 100));
const narrow = dashboard.render(48);
assert.equal(narrow.some((line) => line.includes("Worktree")), false);
assert.ok(narrow.every((line) => visibleWidth(line) <= 48));
dashboard.handleInput("\r");
assert.equal(selected, "run-1");
});
ui: {
custom: async (
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
customCalls++;
const component = factory(
{
requestRender() {},
terminal: { columns: 100, rows: 24 },
},
theme,
{},
() => {},
);
renders.push(component.render(100));
return results.shift();
},
select: unsupported,
confirm: unsupported,
input: unsupported,
editor: unsupported,
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
return { context, notifications, renders, customCalls: () => customCalls };
}
test("dashboard disambiguates duplicate feature labels without changing selection IDs", () => {
let selected: string | null | undefined;
const dashboard = new FocusedWorkflowDashboard(
[
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => sessionId },
ui: {
async custom(
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => {
render(width: number): string[];
handleInput?(data: string): void;
},
) {
customCalls++;
let completed: unknown;
const component = factory(
{
requestRender() {},
terminal: { columns: 80, rows: 16 },
},
theme,
{},
(value) => { completed = value; },
);
component.render(56);
for (const input of inputs.shift() ?? []) component.handleInput?.(input);
return completed;
},
notify() {},
},
} as unknown as ExtensionCommandContext;
return { context, customCalls: () => customCalls };
}
const pi = {
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
test("dashboard fresh-session action returns an immediate continuation result", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
gates: [
{
id: "context-gate",
kind: "implementation-context",
status: "open",
revision: 12,
openedAt: 1,
},
],
theme,
(value) => { selected = value; },
implementationContext: {
gateId: "context-gate",
runRevision: 12,
planRevision: 3,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
});
const tui = tuiContext(["run-1", "continue"]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const lines = dashboard.render(100);
assert.ok(lines.some((line) => line.includes("search-page · 12345678-a")));
assert.ok(lines.some((line) => line.includes("search-page · 12345678-b")));
dashboard.handleInput("\r");
dashboard.handleInput("\r");
assert.equal(selected, "12345678-aaaa");
assert.deepEqual(result, { continueRunId: "run-1" });
assert.equal(tui.customCalls(), 2);
});
test("dashboard disambiguates collisions outside its visible window at common widths", () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName: index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
for (const width of [80, 60]) {
const lines = dashboard.render(width);
assert.ok(lines.some((line) => line.includes("shared-label · 12345678-a")));
assert.ok(lines.every((line) => visibleWidth(line) <= width));
}
});
test("dashboard windows long-lived session run lists", () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({ runId: `run-${index}`, featureName: `feature-${index}` }),
);
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
const lines = dashboard.render(100);
assert.ok(lines.length <= 16);
assert.ok(lines.some((line) => line.includes("of 30 runs")));
});
test("dashboard records explicit Plan Approval through the engine", async () => {
test("dashboard records Plan Approval using only custom TUI overlays", async () => {
const workflow = run({
planArtifact: {
path: "/state/artifacts/plan.md",
@@ -191,26 +268,14 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
},
});
const commands: WorkflowCommand[] = [];
const selections = [
"Record Gate Decision",
"Approve plan · UI impact minor",
];
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: {
getSessionId: () => "session-1",
},
ui: {
custom: async () => "run-1",
select: async () => selections.shift(),
confirm: async () => true,
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const pi = {
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
const tui = tuiContext([
"run-1",
"decide",
"approve",
true,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
@@ -223,7 +288,7 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
},
}) as never,
},
ctx,
tui.context,
);
const decision = commands[0];
assert.equal(decision?.action, "decide");
@@ -235,6 +300,188 @@ test("dashboard records explicit Plan Approval through the engine", async () =>
acceptedUiImpact: "minor",
},
);
assert.ok(tui.customCalls() >= 4);
});
test("prototype reader Escape returns to candidate actions without accepting", async () => {
const candidate = prototypeCandidate();
const workflow = run({
stages: run().stages.map((stage) =>
stage.kind === "prototype"
? { ...stage, status: "waiting" as const }
: stage,
),
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"back",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
assert.deepEqual(commands, []);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype 2"))
.length >= 2,
);
});
test("prototype acceptance requires the explicit candidate Accept action", async () => {
const candidate = prototypeCandidate();
const workflow = run({
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"accept",
true,
"",
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
const decision = commands[0];
assert.deepEqual(
decision?.action === "decide" ? decision.decision : undefined,
{
kind: "accept-prototype",
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true,
},
);
});
test("Escape from run detail returns one level to the run list", async () => {
const tui = tuiContext(["run-1", undefined, undefined]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard handleInput drives selection and Escape back-stack navigation", async () => {
const tui = interactiveTuiContext([["\r"], ["\x1b"], ["\x1b"]]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard duplicate labels retain their canonical run selection IDs", async () => {
const workflows = [
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
];
const tui = tuiContext(["12345678-bbbb", undefined, undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const list = tui.renders[0]?.join("\n") ?? "";
assert.match(list, /search-page · 12345678-a/);
assert.match(list, /search-page · 12345678-b/);
assert.match(tui.renders[1]?.join("\n") ?? "", /search-page · 12345678-b/);
});
test("dashboard disambiguates duplicate labels across the full run list", async () => {
const workflows = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName:
index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const tui = tuiContext([undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const rendered = tui.renders[0]?.join("\n") ?? "";
assert.match(rendered, /shared-label · 12345678-a/);
assert.match(rendered, /1-12 of 30/);
assert.doesNotMatch(rendered, /shared-label\s+WAITING/);
});
test("historical parent sessions receive inspection-only dashboard actions", async () => {
@@ -242,34 +489,22 @@ test("historical parent sessions receive inspection-only dashboard actions", asy
parentSessionIds: ["session-1", "session-2"],
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
});
let actions: string[] = [];
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-2" },
ui: {
custom: async () => "run-1",
select: async (_title: string, choices: string[]) => {
actions = choices;
return undefined;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const tui = tuiContext(["run-1", undefined, undefined], "session-2");
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
);
assert.equal(actions.includes("Record Gate Decision"), false);
assert.equal(actions.includes("Continue context transition"), false);
assert.equal(actions.includes("Open plan"), true);
const actions = tui.renders[1]?.join("\n") ?? "";
assert.match(actions, /Review verified plan/);
assert.doesNotMatch(actions, /Record Gate Decision/);
assert.doesNotMatch(actions, /Continue context transition/);
});
test("dashboard opens retained report content on demand", async () => {
test("dashboard verifies and opens retained report content", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-"));
try {
const reportPath = path.join(directory, "report.md");
@@ -284,36 +519,65 @@ test("dashboard opens retained report content on demand", async () => {
revision: 12,
},
});
let opened = "";
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => "Open report",
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const tui = tuiContext([
"run-1",
"report",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
);
assert.match(opened, /Completed report[\s\S]*Verified/);
assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes("Completed report"),
),
);
assert.ok(tui.renders.some((lines) => lines.join("\n").includes("Verified.")));
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("dashboard verifies and opens Assignment transcripts on demand", async () => {
test("retained artifact reader Escape returns through artifact and candidate selection", async () => {
const candidate = prototypeCandidate();
const workflow = run({ prototypeCandidates: [candidate] });
const tui = tuiContext([
"run-1",
"prototypes",
candidate,
candidate.artifactPath,
undefined,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Retained artifact"))
.length >= 2,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype evidence"))
.length >= 2,
);
});
test("dashboard verifies and opens Assignment transcripts", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-"));
try {
const transcriptPath = path.join(directory, "assignment.json");
@@ -344,107 +608,82 @@ test("dashboard verifies and opens Assignment transcripts on demand", async () =
},
],
});
const selections = [
"Browse Assignment transcripts",
"1. review/independent-reviewer · succeeded",
];
let opened = "";
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => selections.shift(),
editor: async (_title: string, contents: string) => {
opened = contents;
return contents;
},
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const assignment = workflow.assignments[0];
assert.ok(assignment);
const tui = tuiContext([
"run-1",
"transcripts",
assignment,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
);
assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes('"text":"verified"'),
),
);
assert.ok(
tui.renders.filter((lines) =>
lines.join("\n").includes("Assignment transcripts"),
).length >= 2,
);
assert.match(opened, /assistant.*verified/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const notifications: string[] = [];
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("missing runs must not open the dashboard");
test("RPC and print dashboards stay linear and never call custom", async () => {
for (const mode of ["rpc", "print", "json"] as const) {
const notifications: string[] = [];
const ctx = {
hasUI: mode === "rpc",
mode,
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("custom must not be called outside TUI mode");
},
notify: (message: string) => notifications.push(message),
},
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
} as unknown as ExtensionCommandContext;
assert.deepEqual(
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
),
{},
);
if (mode === "rpc") assert.match(notifications[0] ?? "", /search-page/);
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const tui = tuiContext([]);
const result = await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
tui.context,
"missing-run-id",
);
assert.deepEqual(result, {});
assert.deepEqual(notifications, [
assert.deepEqual(tui.notifications, [
"Workflow Run missing-run-id was not found for this session.",
]);
});
test("dashboard exposes continuation only for pending handoff intents", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
gates: [
{
id: "context-gate",
kind: "implementation-context",
status: "open",
revision: 12,
openedAt: 1,
},
],
implementationContext: {
gateId: "context-gate",
runRevision: 12,
planRevision: 3,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
});
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => "run-1",
select: async () => "Continue context transition",
notify: () => {},
},
} as unknown as ExtensionCommandContext;
const result = await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
);
assert.deepEqual(result, { continueRunId: "run-1" });
});
+322 -374
View File
@@ -5,12 +5,6 @@ import type {
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
type Component,
} from "@earendil-works/pi-tui";
import { isValidContextContinuation } from "./continuation.ts";
import type {
GateDecision,
@@ -27,36 +21,29 @@ import {
readVerifiedReportArtifact,
readVerifiedTranscriptArtifact,
} from "./store.ts";
import {
workflowConfirm,
workflowEditor,
workflowInput,
workflowPlanReader,
workflowSelect,
} from "./tui.ts";
interface DashboardTheme {
fg(
color:
| "accent"
| "dim"
| "error"
| "muted"
| "success"
| "text"
| "warning",
text: string,
): string;
bold(text: string): string;
}
function statusColor(status: WorkflowRun["status"]) {
if (status === "completed") return "success" as const;
if (status === "failed" || status === "aborted") return "error" as const;
if (status === "waiting") return "warning" as const;
return "accent" as const;
}
const CORE_STAGES = [
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
] as const;
function activeStage(run: WorkflowRun) {
return (
[...run.stages]
.reverse()
.find((stage) =>
stage.status === "running" || stage.status === "waiting"
)?.kind ?? run.status
.find((stage) => stage.status === "running" || stage.status === "waiting")
?.kind ?? run.status
);
}
@@ -64,152 +51,29 @@ function selectedGate(run: WorkflowRun) {
return [...run.gates].reverse().find((gate) => gate.status === "open");
}
function stageLine(run: WorkflowRun, theme: DashboardTheme) {
function stageSummary(run: WorkflowRun) {
return run.stages
.filter((stage) =>
["discover", "design", "prototype", "implement", "verify", "review"].includes(
stage.kind,
),
)
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
.map((stage) => {
const marker =
stage.status === "completed"
? theme.fg("success", "✓")
? "✓"
: stage.status === "failed"
? theme.fg("error", "×")
? "×"
: stage.status === "running" || stage.status === "waiting"
? theme.fg("accent", "●")
: theme.fg("dim", "·");
? "●"
: "·";
return `${marker} ${stage.kind}`;
})
.join(" ");
}
export class FocusedWorkflowDashboard implements Component {
private readonly runs: WorkflowRun[];
private readonly theme: DashboardTheme;
private readonly done: (result: string | null) => void;
private readonly labels: string[];
private selected = 0;
private detail = false;
constructor(
runs: WorkflowRun[],
theme: DashboardTheme,
done: (result: string | null) => void,
initialRunId?: string,
) {
this.runs = runs;
this.theme = theme;
this.done = done;
this.labels = deriveRunLabels(runs);
const initial = initialRunId
? runs.findIndex((run) => run.runId === initialRunId)
: -1;
if (initial >= 0) {
this.selected = initial;
this.detail = true;
}
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
if (this.detail) this.detail = false;
else this.done(null);
return;
}
if (matchesKey(data, Key.up) && this.selected > 0) {
this.selected--;
return;
}
if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) {
this.selected++;
return;
}
if (matchesKey(data, Key.enter)) {
const run = this.runs[this.selected];
if (!run) return;
if (this.detail) this.done(run.runId);
else this.detail = true;
}
}
render(width: number) {
const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""];
if (this.runs.length === 0) {
lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session."));
lines.push("", this.theme.fg("dim", "esc close"));
return lines.map((line) => truncateToWidth(line, width));
}
const windowSize = 10;
const windowStart = Math.max(
0,
Math.min(
this.selected - Math.floor(windowSize / 2),
Math.max(0, this.runs.length - windowSize),
),
);
const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize);
for (const [visibleIndex, run] of visibleRuns.entries()) {
const index = windowStart + visibleIndex;
const prefix = index === this.selected ? "> " : " ";
const name = this.labels[index]!;
const status = this.theme.fg(
statusColor(run.status),
run.status.toUpperCase().padEnd(9),
);
lines.push(
`${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`,
);
}
if (this.runs.length > windowSize) {
lines.push(
this.theme.fg(
"dim",
`${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`,
),
);
}
if (this.detail) {
const run = this.runs[this.selected]!;
const gate = selectedGate(run);
lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width))));
lines.push(this.theme.bold(this.labels[this.selected]!));
if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch));
lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`);
if (gate) {
lines.push(
"",
`${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`,
);
}
if (run.planArtifact) {
lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`);
}
if (run.reportArtifact) {
lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`);
}
if (width >= 80 && run.workspace.root) {
lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`);
}
lines.push(
"",
this.theme.fg("dim", "enter actions esc runs"),
);
} else {
lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close"));
}
return lines.map((line) => truncateToWidth(line, width));
}
invalidate() {}
}
function runSummary(run: WorkflowRun, label: string) {
const gate = selectedGate(run);
return [
`${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
`Progress: ${stageSummary(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
@@ -243,8 +107,7 @@ async function decide(
}
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run);
const payload = gate?.payload;
const payload = selectedGate(run)?.payload;
if (!payload || typeof payload !== "object") return undefined;
const questions = (payload as { questions?: unknown }).questions;
if (!Array.isArray(questions)) return undefined;
@@ -254,7 +117,7 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const id = (item as { id?: unknown }).id;
const prompt = (item as { prompt?: unknown }).prompt;
if (typeof id !== "string" || typeof prompt !== "string") continue;
const answer = await ctx.ui.input(prompt, "Required answer");
const answer = await workflowInput(ctx, "Required question", prompt);
if (!answer?.trim()) return undefined;
answers[id] = answer.trim();
}
@@ -263,24 +126,33 @@ async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
if (!run.planArtifact || !run.uiImpact) return undefined;
const action = await ctx.ui.select("Plan Approval", [
"Inspect plan",
`Approve plan · UI impact ${run.uiImpact}`,
"Request revision",
]);
if (action === "Inspect plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024));
return undefined;
}
if (action === "Request revision") {
const feedback = await ctx.ui.editor("Plan revision feedback", "");
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
if (action?.startsWith("Approve plan")) {
const approved = await ctx.ui.confirm(
while (true) {
const action = await workflowSelect(ctx, "Plan Approval", [
{ value: "inspect" as const, label: "Review verified plan" },
{
value: "approve" as const,
label: `Approve plan · UI impact ${run.uiImpact}`,
},
{ value: "revise" as const, label: "Request revision" },
]);
if (!action) return undefined;
if (action === "inspect") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
continue;
}
if (action === "revise") {
const feedback = await workflowEditor(
ctx,
"Plan revision feedback",
"Describe the required plan changes.",
);
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
const approved = await workflowConfirm(
ctx,
"Approve plan",
`Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
);
@@ -292,78 +164,121 @@ async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
}
: undefined;
}
return undefined;
}
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3);
const items = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const action = await ctx.ui.select("Prototype Acceptance", [
...items,
"Request refinement",
"Abort run",
]);
if (action === "Request refinement") {
const feedback = await ctx.ui.editor("Prototype refinement feedback", "");
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
while (true) {
const action = await workflowSelect<
| { kind: "candidate"; candidate: PrototypeCandidate }
| { kind: "refine" }
| { kind: "abort" }
>(ctx, "Prototype Acceptance", [
...candidates.map((candidate, index) => ({
value: { kind: "candidate" as const, candidate },
label: `${index + 1}. ${candidate.structuralRationale}`,
description: candidate.route,
})),
{ value: { kind: "refine" as const }, label: "Request refinement" },
{ value: { kind: "abort" as const }, label: "Abort run" },
]);
if (!action) return undefined;
if (action.kind === "refine") {
const feedback = await workflowEditor(
ctx,
"Prototype refinement feedback",
"Describe what the next candidates must change.",
);
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
}
if (action.kind === "abort") {
return (await workflowConfirm(
ctx,
"Abort Workflow Run",
"Abort this run and preserve its current evidence?",
))
? ("abort" as const)
: undefined;
}
const candidate = action.candidate;
while (true) {
const candidateAction = await workflowSelect(
ctx,
`Prototype ${candidate.revision}`,
[
{ value: "review" as const, label: "Review candidate evidence" },
{ value: "accept" as const, label: "Accept this candidate" },
{ value: "back" as const, label: "Back to candidates" },
],
);
if (!candidateAction || candidateAction === "back") break;
if (candidateAction === "review") {
await workflowPlanReader(
ctx,
`prototype ${candidate.revision}`,
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n\n"),
);
continue;
}
const browserReviewed = await workflowConfirm(
ctx,
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) continue;
const feedback = await workflowInput(
ctx,
"Acceptance feedback",
"Optional feedback. Submit an empty value for none.",
);
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
}
}
if (action === "Abort run") return "abort" as const;
const index = items.indexOf(action ?? "");
const candidate: PrototypeCandidate | undefined = candidates[index];
if (!candidate) return undefined;
ctx.ui.notify(
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n"),
"info",
);
const browserReviewed = await ctx.ui.confirm(
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) return undefined;
const feedback = await ctx.ui.input("Acceptance feedback", "Optional");
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
}
async function implementationContextDecision(ctx: ExtensionContext) {
const strategy = await ctx.ui.select("Implementation Context", [
"Fresh plan-seeded session",
"Compact current session",
"Keep current context unchanged",
const strategy = await workflowSelect(ctx, "Implementation Context", [
{
value: "fresh-session" as const,
label: "Fresh plan-seeded session",
description: "Replace this session and continue immediately.",
},
{
value: "smart-compact" as const,
label: "Compact current session",
},
{
value: "current-session" as const,
label: "Keep current context unchanged",
},
]);
if (strategy === "Fresh plan-seeded session") {
return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const };
}
if (strategy === "Compact current session") {
return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const };
}
if (strategy === "Keep current context unchanged") {
return { kind: "select-implementation-context" as const, strategy: "current-session" as const };
}
return undefined;
return strategy
? { kind: "select-implementation-context" as const, strategy }
: undefined;
}
async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]);
if (!resolution) return undefined;
return {
kind: "recover" as const,
resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort",
};
const resolution = await workflowSelect(ctx, "Recovery", [
{ value: "retry" as const, label: "Retry" },
{ value: "inspect" as const, label: "Inspect" },
{ value: "abort" as const, label: "Abort" },
]);
return resolution
? { kind: "recover" as const, resolution }
: undefined;
}
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
@@ -386,128 +301,161 @@ function validContinuation(run: WorkflowRun, sessionId?: string) {
}
function isActiveParent(run: WorkflowRun, sessionId?: string) {
return Boolean(
sessionId && run.lease?.activeParentSessionId === sessionId,
);
return Boolean(sessionId && run.lease?.activeParentSessionId === sessionId);
}
async function browsePrototypeArtifacts(
ctx: ExtensionContext,
run: WorkflowRun,
) {
const candidates = run.prototypeCandidates;
const labels = candidates.map(
(candidate, index) =>
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
);
const selected = await ctx.ui.select("Prototype evidence", labels);
const candidate = candidates[labels.indexOf(selected ?? "")];
if (!candidate) return;
ctx.ui.notify(
`${candidate.launchInstructions}\nRoute: ${candidate.route}`,
"info",
);
const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts];
const artifact = await ctx.ui.select("Retained artifact", artifacts);
if (!artifact) return;
if (artifact.endsWith(".json")) {
const contents = await readFile(artifact, "utf8");
await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024));
} else {
ctx.ui.notify(artifact, "info");
async function browsePrototypeArtifacts(ctx: ExtensionContext, run: WorkflowRun) {
while (true) {
const candidate = await workflowSelect(
ctx,
"Prototype evidence",
run.prototypeCandidates.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.structuralRationale}`,
description: entry.route,
})),
);
if (!candidate) return;
while (true) {
const artifact = await workflowSelect(
ctx,
"Retained artifact",
[candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
value: entry,
label: entry,
})),
);
if (!artifact) break;
if (artifact.endsWith(".json")) {
await workflowPlanReader(
ctx,
"prototype evidence",
await readFile(artifact, "utf8"),
);
} else {
await workflowPlanReader(
ctx,
"prototype evidence",
`${candidate.launchInstructions}\n\nRoute: ${candidate.route}\n\nArtifact: ${artifact}`,
);
}
}
}
}
async function browseAssignmentTranscripts(
ctx: ExtensionContext,
run: WorkflowRun,
) {
async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact,
);
const labels = assignments.map(
(assignment, index) =>
`${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`,
);
const selected = await ctx.ui.select("Assignment transcripts", labels);
const assignment = assignments[labels.indexOf(selected ?? "")];
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await ctx.ui.editor(
`${assignment.role} transcript`,
transcript.slice(0, 32 * 1024),
);
while (true) {
const assignment = await workflowSelect(
ctx,
"Assignment transcripts",
assignments.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.role}/${entry.profileName}`,
description: entry.status,
})),
);
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
}
}
type DashboardActionResult =
| { kind: "back" }
| { kind: "continue"; runId: string }
| { kind: "closed" };
async function actionSheet(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
run: WorkflowRun,
initialRun: WorkflowRun,
label: string,
) {
const actions = [
"Inspect run",
...(selectedGate(run) &&
isActiveParent(run, ctx.sessionManager.getSessionId())
? ["Record Gate Decision"]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? ["Continue context transition"]
: []),
...(run.planArtifact ? ["Open plan"] : []),
...(run.reportArtifact ? ["Open report"] : []),
...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? ["Browse Assignment transcripts"]
: []),
...(run.workspace.root ? ["Show worktree path"] : []),
];
const action = await ctx.ui.select(label, actions);
if (action === "Inspect run") ctx.ui.notify(runSummary(run, label), "info");
else if (action === "Open plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact!);
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024));
} else if (action === "Open report") {
const report = await readVerifiedReportArtifact(run.reportArtifact!);
await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024));
} else if (action === "Browse prototype evidence") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "Browse Assignment transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "Show worktree path") {
ctx.ui.notify(run.workspace.root!, "info");
}
else if (action === "Continue context transition") {
return { continueRunId: run.runId };
} else if (action === "Record Gate Decision") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
if (
decision.kind === "select-implementation-context" &&
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
"info",
);
} else {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
): Promise<DashboardActionResult> {
let run = initialRun;
while (true) {
const actions = [
{ value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
: []),
...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
...(run.prototypeCandidates.length > 0
? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
: []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
: []),
...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
];
const action = await workflowSelect(ctx, label, actions);
if (!action) return { kind: "back" };
if (action === "continue") return { kind: "continue", runId: run.runId };
if (action === "inspect") {
await workflowPlanReader(ctx, label, runSummary(run, label));
} else if (action === "plan") {
await workflowPlanReader(
ctx,
label,
await readVerifiedPlanArtifact(run.planArtifact!),
);
} else if (action === "report") {
await workflowPlanReader(
ctx,
`${label} report`,
await readVerifiedReportArtifact(run.reportArtifact!),
);
} else if (action === "prototypes") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "worktree") {
await workflowPlanReader(ctx, "worktree", run.workspace.root!);
} else if (action === "decide") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
if (
decision.kind === "select-implementation-context" &&
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
"info",
);
} else if (
decision.kind === "select-implementation-context" &&
decision.strategy === "fresh-session"
) {
return { kind: "continue", runId: run.runId };
} else {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
}
}
const refreshed = (await runtime.store.listRuns()).find((candidate) => candidate.runId === run.runId);
if (!refreshed || ["completed", "failed", "aborted"].includes(refreshed.status)) {
return { kind: "closed" };
}
run = refreshed;
}
}
return {};
}
export interface DashboardResult {
@@ -523,12 +471,13 @@ export async function openWorkflowDashboard(
const sessionId = ctx.sessionManager.getSessionId();
const runs = (await runtime.store.listRuns())
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
.sort((left, right) => right.revision - left.revision);
.sort((left, right) => {
const leftTime = Math.max(left.lease?.heartbeatAt ?? 0, ...left.gates.map((gate) => gate.openedAt));
const rightTime = Math.max(right.lease?.heartbeatAt ?? 0, ...right.gates.map((gate) => gate.openedAt));
return rightTime - leftTime;
});
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
ctx.ui.notify(
`Workflow Run ${initialRunId} was not found for this session.`,
"error",
);
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
return {};
}
const labels = deriveRunLabels(runs);
@@ -537,34 +486,33 @@ export async function openWorkflowDashboard(
const omitted = runs.length - visibleRuns.length;
ctx.ui.notify(
runs.length > 0
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
}`
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
: "No Workflow Runs.",
"info",
);
return {};
}
const selected = await ctx.ui.custom<string | null>(
(tui, theme, _keybindings, done) => {
const dashboard = new FocusedWorkflowDashboard(
runs,
theme as DashboardTheme,
done,
initialRunId,
let selectedRunId = initialRunId;
while (true) {
if (!selectedRunId) {
selectedRunId = await workflowSelect(
ctx,
"pi-workflow",
runs.map((run, index) => ({
value: run.runId,
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
description: selectedGate(run)?.kind,
})),
);
return {
render: (width) => dashboard.render(width),
invalidate: () => dashboard.invalidate(),
handleInput: (data) => {
dashboard.handleInput(data);
tui.requestRender();
},
};
},
);
if (!selected) return {};
const index = runs.findIndex((candidate) => candidate.runId === selected);
const run = runs[index];
return run ? actionSheet(pi, runtime, ctx, run, labels[index]!) : {};
if (!selectedRunId) return {};
}
const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
const run = runs[index];
if (!run) return {};
const result = await actionSheet(pi, runtime, ctx, run, labels[index]!);
if (result.kind === "continue") return { continueRunId: result.runId };
if (result.kind === "closed") return {};
selectedRunId = undefined;
}
}
+8
View File
@@ -154,12 +154,20 @@ export interface RunLease {
worktreeIdentity?: string;
}
export interface SourceChangeEvidence {
path: string;
status: string;
}
export interface WorkflowWorkspace {
state: "not-required" | "creating" | "ready" | "failed";
sourceRoot: string;
root?: string;
branch?: string;
baseHead?: string;
sourceChanges?: SourceChangeEvidence[];
sourceChangesOmitted?: number;
dirtySourceAllowed?: boolean;
error?: string;
}
+718 -17
View File
@@ -29,6 +29,7 @@ import {
type HostCommandExecutor,
type StartWorkflowCommand,
type WorkflowCommand,
type WorkflowView,
type WorkspaceManager,
} from "./engine.ts";
import { loadAgentProfiles } from "./profiles.ts";
@@ -113,10 +114,12 @@ class FakeWorkspace implements WorkspaceManager {
branchExists = false;
createError?: Error;
reconcileState: WorkflowRun["workspace"]["state"] = "ready";
beforePrepare?: () => Promise<void>;
beforeCreate?: (plan: WorkspacePlan) => Promise<void>;
async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> {
this.prepareCalls++;
await this.beforePrepare?.();
return {
sourceRoot: options.sourceRoot,
worktreeRoot: path.join(options.sourceRoot, options.configuredRoot),
@@ -128,6 +131,9 @@ class FakeWorkspace implements WorkspaceManager {
branch: `feature/${options.featureName}`,
baseHead: "abc123",
branchExists: this.branchExists,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
}
@@ -316,6 +322,449 @@ function design(uiImpact: "none" | "minor" | "non-trivial" = "minor") {
};
}
async function createClaimedContextHandoff(
setup: Fixture,
): Promise<WorkflowView> {
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-claimed-context",
});
const selected = await setup.engine().execute({
action: "decide",
runId: approved.runId,
gateId: approved.openGate?.id ?? "missing",
expectedRevision: approved.revision,
decision: {
kind: "select-implementation-context",
strategy: "fresh-session",
},
idempotencyKey: "select-claimed-context",
});
const intent = selected.implementationContext;
assert.ok(intent);
const continued = await setup.engine().execute({
action: "continue-context",
runId: selected.runId,
gateId: intent.gateId,
preparationId: intent.preparationId,
planRevision: intent.planRevision,
contextRevision: intent.runRevision,
expectedRevision: selected.revision,
sourceSessionFile: "/sessions/source.jsonl",
});
const pending = continued.implementationContext;
assert.ok(pending);
return setup.engine().execute({
action: "claim-context-handoff",
runId: continued.runId,
gateId: pending.gateId,
preparationId: pending.preparationId,
planRevision: pending.planRevision,
contextRevision: pending.runRevision,
expectedRevision: continued.revision,
sourceSessionFile: "/sessions/source.jsonl",
targetSessionFile: "/sessions/target.jsonl",
reason: "new",
});
}
test("non-trivial UI design fails with the exact missing-prototype validation error", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
const missingPrototype = design("non-trivial");
missingPrototype.assignments = missingPrototype.assignments.filter(
(assignment) => assignment.role !== "prototype",
);
setup.executor.enqueue("design", missingPrototype);
const failed = await setup.engine().execute(startCommand());
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "invalid-design-output");
assert.equal(
failed.error?.message,
"Design must select exactly one prototype Assignment",
);
});
});
test("feature designer contract requires the non-trivial prototype assignment", async () => {
const instructions = await readFile(
new URL("./agents/feature-designer.md", import.meta.url),
"utf8",
);
assert.match(
instructions,
/For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment/,
);
assert.match(instructions, /owned paths isolate prototype artifacts/);
assert.match(instructions, /one eligible `implement` assignment/);
assert.match(instructions, /one eligible `verify` assignment/);
assert.match(instructions, /one eligible independent `review` assignment/);
});
test("design Assignment execution times out with persisted evidence", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
const blocking = new BlockingExecutor(design("minor"));
const operation = setup.engine("session-1", {
executor: {
async run(request) {
if (request.role === "discover") {
return setup.executor.run(request);
}
return blocking.run(request);
},
},
assignmentExecutionTimeoutMs: 20,
assignmentAbortTimeoutMs: 5,
}).execute(startCommand());
await blocking.started;
const failed = await operation;
blocking.release();
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "assignment-timeout");
assert.match(failed.error?.message ?? "", /Assignment design timed out after 20ms/);
const persisted = await setup.store.loadRun(failed.runId);
const designAssignment = persisted.assignments.find(
(assignment) => assignment.role === "design",
);
assert.equal(designAssignment?.status, "interrupted");
assert.match(designAssignment?.error ?? "", /timed out after 20ms/);
});
});
test("executor rejection clears its deadline before transcript persistence", async (context) => {
await withTempDir(async (directory) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const setup = await fixture(directory);
let markStarted = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let rejectExecution = (_error: Error) => {};
const execution = new Promise<never>((_resolve, reject) => {
rejectExecution = reject;
});
const executor: AssignmentExecutor = {
run() {
markStarted();
return execution;
},
};
const saveTranscript = setup.store.saveAssignmentTranscript.bind(setup.store);
let markTranscriptStarted = () => {};
const transcriptStarted = new Promise<void>((resolve) => {
markTranscriptStarted = resolve;
});
let finishTranscript = () => {};
const transcriptMayFinish = new Promise<void>((resolve) => {
finishTranscript = resolve;
});
setup.store.saveAssignmentTranscript = async (
runId,
assignmentId,
transcript,
) => {
markTranscriptStarted();
await transcriptMayFinish;
return saveTranscript(runId, assignmentId, transcript);
};
const operation = setup.engine("session-1", {
executor,
assignmentExecutionTimeoutMs: 10,
}).execute(startCommand());
await started;
context.mock.timers.tick(9);
rejectExecution(Object.assign(new Error("provider rejected near deadline"), {
assignmentMetadata: {
transcript: [{ role: "assistant", text: "partial response" }],
},
}));
await transcriptStarted;
context.mock.timers.tick(1);
finishTranscript();
const failed = await operation;
assert.equal(failed.status, "failed");
assert.equal(failed.error?.code, "assignment-failed");
assert.match(failed.error?.message ?? "", /provider rejected near deadline/);
assert.equal(failed.assignments[0]?.status, "failed");
});
});
test("assignment timeout retains its mutation fence until the executor settles", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const blocking = new BlockingExecutor({
summary: "late implementation",
changedPaths: ["src/index.ts"],
focusedTests: ["late behavior"],
});
const executor: AssignmentExecutor = {
async run(request) {
return request.role === "implement"
? blocking.run(request)
: setup.executor.run(request);
},
};
const engine = setup.engine("session-1", {
executor,
assignmentExecutionTimeoutMs: 20,
assignmentAbortTimeoutMs: 5,
});
const planned = await engine.execute(startCommand());
const approved = await engine.execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-timeout-plan",
});
const operation = engine.execute({
action: "decide",
runId: approved.runId,
gateId: approved.openGate?.id ?? "missing",
expectedRevision: approved.revision,
decision: {
kind: "select-implementation-context",
strategy: "current-session",
},
idempotencyKey: "start-timeout-implementation",
});
await blocking.started;
const timedOut = await operation;
assert.equal(timedOut.status, "waiting");
assert.equal(timedOut.openGate?.kind, "recovery");
assert.equal(timedOut.error?.code, "mutation-recovery-required");
const interrupted = timedOut.assignments.find(
(assignment) => assignment.role === "implement",
);
assert.equal(interrupted?.status, "interrupted");
assert.ok(interrupted?.executionToken);
assert.ok(
await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
),
);
await assert.rejects(
engine.execute({
action: "decide",
runId: timedOut.runId,
gateId: timedOut.openGate?.id ?? "missing",
expectedRevision: timedOut.revision,
decision: { kind: "recover", resolution: "retry" },
idempotencyKey: "recover-before-timeout-settlement",
}),
/still active/,
);
blocking.release();
for (let attempt = 0; attempt < 100; attempt++) {
const lease = await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
);
if (!lease) break;
await new Promise((resolve) => setTimeout(resolve, 1));
}
assert.equal(
await setup.store.loadAssignmentLease(
timedOut.runId,
interrupted?.id ?? "missing",
),
undefined,
);
setup.executor.enqueue("remediate", {
summary: "recovered implementation",
changedPaths: ["src/index.ts"],
focusedTests: ["recovered behavior"],
});
setup.executor.enqueue("verify", {
succeeded: true,
focusedBehavioralTests: true,
summary: "verified after settlement",
});
setup.executor.enqueue("review", {
specification: { verdict: "approved", findings: [] },
standards: { verdict: "approved", findings: [] },
});
const recovered = await engine.execute({
action: "decide",
runId: timedOut.runId,
gateId: timedOut.openGate?.id ?? "missing",
expectedRevision: timedOut.revision,
decision: { kind: "recover", resolution: "retry" },
idempotencyKey: "recover-after-timeout-settlement",
});
assert.equal(recovered.status, "completed", recovered.error?.message);
});
});
test("old heartbeat never permits takeover from a live run owner", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate?.id ?? "missing",
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-before-live-owner-recovery",
});
const owned = await setup.store.loadRun(approved.runId);
assert.ok(owned.implementationContext);
owned.lease = {
processId: 901,
activeParentSessionId: "session-1",
heartbeatAt: 1,
worktreeIdentity: owned.workspace.root,
};
owned.revision++;
owned.implementationContext.runRevision = owned.revision;
await setup.store.saveRun(owned, owned.revision - 1);
assert.deepEqual(
await setup.engine("session-2", {
now: () => 100_000,
processId: 902,
isProcessAlive: (processId) => processId === 901,
}).recover(),
[],
);
const fenced = await setup.store.loadRun(owned.runId);
assert.equal(fenced.lease?.activeParentSessionId, "session-1");
assert.equal(
fenced.implementationContext?.activeParentSessionId,
"session-1",
);
assert.equal(fenced.lease?.processId, 901);
assert.equal(fenced.parentSessionIds.includes("session-2"), false);
});
});
test("startup recovery fails closed for a fresh live owner and atomically rotates dead ownership", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", {
...discoveryWithQuestions(),
questions: [],
});
setup.executor.enqueue("design", design("minor"));
const planned = await setup.engine().execute(startCommand());
const approved = await setup.engine().execute({
action: "decide",
runId: planned.runId,
gateId: planned.openGate!.id,
expectedRevision: planned.revision,
decision: {
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
idempotencyKey: "approve-before-owner-recovery",
});
const abandoned = await setup.store.loadRun(approved.runId);
assert.ok(abandoned.implementationContext);
abandoned.lease = {
processId: 901,
activeParentSessionId: "session-1",
heartbeatAt: 1,
worktreeIdentity: abandoned.workspace.root,
};
abandoned.revision++;
abandoned.implementationContext.runRevision = abandoned.revision;
await setup.store.saveRun(abandoned, abandoned.revision - 1);
const liveRecovery = await setup.engine("session-2", {
processId: 902,
isProcessAlive: (processId) => processId === 901,
}).recover();
assert.deepEqual(liveRecovery, []);
const stillOwned = await setup.store.loadRun(abandoned.runId);
assert.equal(stillOwned.lease?.activeParentSessionId, "session-1");
assert.equal(
stillOwned.implementationContext?.activeParentSessionId,
"session-1",
);
await setup.engine("session-2", {
processId: 902,
isProcessAlive: () => false,
}).recover();
const adopted = await setup.store.loadRun(abandoned.runId);
assert.equal(adopted.lease?.activeParentSessionId, "session-2");
assert.equal(
adopted.implementationContext?.activeParentSessionId,
"session-2",
);
assert.equal(adopted.lease?.processId, 902);
assert.ok(adopted.parentSessionIds.includes("session-2"));
adopted.lease = undefined;
adopted.revision++;
adopted.implementationContext!.runRevision = adopted.revision;
await setup.store.saveRun(adopted, adopted.revision - 1);
await setup.engine("session-3", {
processId: 903,
isProcessAlive: () => false,
}).recover();
const adoptedFromAbsent = await setup.store.loadRun(abandoned.runId);
assert.equal(adoptedFromAbsent.lease?.activeParentSessionId, "session-3");
assert.equal(
adoptedFromAbsent.implementationContext?.activeParentSessionId,
"session-3",
);
assert.equal(adoptedFromAbsent.lease?.processId, 903);
});
});
test("analysis scripts run persisted read-only Assignments with tool intersection", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
@@ -1032,6 +1481,98 @@ test("fresh-session handoff is revision-bound and rotates the active parent only
});
});
test("expired context recovery rejects another live process without mutation", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const before = await setup.store.loadRun(claimed.runId);
await assert.rejects(
setup.engine("unrelated-session", {
now: () => 310_001,
processId: 200,
isProcessAlive: (processId) => processId === 100,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
}),
/owned by live process 100/,
);
assert.deepEqual(await setup.store.loadRun(claimed.runId), before);
});
});
test("expired context recovery succeeds when the owning process is dead", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const recovered = await setup.engine("replacement-session", {
now: () => 310_001,
processId: 200,
isProcessAlive: () => false,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
});
assert.equal(recovered.revision, claimed.revision + 1);
assert.equal(recovered.status, "waiting");
assert.equal(recovered.implementationContext?.status, "handoff-pending");
assert.equal(recovered.implementationContext?.sourceSessionFile, undefined);
assert.equal(recovered.implementationContext?.targetSessionFile, undefined);
assert.match(recovered.implementationContext?.error ?? "", /during expired/);
});
});
test("expired context recovery succeeds for the owning process", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const claimed = await createClaimedContextHandoff(setup);
const claim = claimed.implementationContext;
assert.ok(claim);
const recovered = await setup.engine("session-1", {
now: () => 310_001,
processId: 100,
isProcessAlive: (processId) => processId === 100,
}).execute({
action: "recover-context-intent",
runId: claimed.runId,
gateId: claim.gateId,
preparationId: claim.preparationId,
planRevision: claim.planRevision,
contextRevision: claim.runRevision,
expectedRevision: claimed.revision,
reason: "expired",
});
assert.equal(recovered.revision, claimed.revision + 1);
assert.equal(recovered.status, "waiting");
assert.equal(recovered.implementationContext?.status, "handoff-pending");
assert.equal(recovered.implementationContext?.sourceSessionFile, undefined);
assert.equal(recovered.implementationContext?.targetSessionFile, undefined);
assert.match(recovered.implementationContext?.error ?? "", /during expired/);
});
});
test("smart compaction reports are fenced and unavailable alone enables fallback", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
@@ -1190,7 +1731,6 @@ test("recovery marks an interrupted normal compaction intent failed", async () =
});
const recovered = await setup.engine("session-1", {
now: () => 100_000,
leaseStaleMs: 1,
processId: 200,
isProcessAlive: () => false,
}).recover();
@@ -1602,7 +2142,7 @@ test("Recovery retry reruns failed verification after the remediation limit", as
hostCommands,
effectiveConfig,
processId: 200,
isProcessAlive: () => false,
isProcessAlive: (processId) => processId === 100,
});
assert.deepEqual(await transientRecovery.recover(), []);
const untouched = await setup.store.loadRun(waiting.runId);
@@ -2340,6 +2880,114 @@ test("failed execution metadata and transcripts are persisted", async () => {
});
});
test("start cancellation during slow preflight settles before rejecting without publication", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
let markPreparing = () => {};
let releasePrepare = () => {};
const preparing = new Promise<void>((resolve) => {
markPreparing = resolve;
});
const prepareBlocked = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
setup.workspace.beforePrepare = async () => {
markPreparing();
await prepareBlocked;
};
const engine = setup.engine();
const operation = engine.execute(startCommand());
await preparing;
const cancellation = engine.cancelStart(
"start-key",
"cancel-slow-preflight",
new Error("cancelled during slow preflight"),
);
let cancellationSettled = false;
void cancellation.then(() => {
cancellationSettled = true;
});
await Promise.resolve();
assert.equal(cancellationSettled, false);
releasePrepare();
await assert.rejects(operation, /cancelled during slow preflight/);
await cancellation;
assert.equal(setup.workspace.createCalls, 0);
assert.equal(setup.executor.requests.length, 0);
assert.deepEqual(await setup.store.listRuns(), []);
});
});
test("start cancellation after reservation prevents later run and worktree publication", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const reserveStart = setup.store.reserveStart.bind(setup.store);
let markReserved = () => {};
let releaseReservation = () => {};
const reserved = new Promise<void>((resolve) => {
markReserved = resolve;
});
const reservationBlocked = new Promise<void>((resolve) => {
releaseReservation = resolve;
});
setup.store.reserveStart = async (key, fingerprint, runId) => {
const reservation = await reserveStart(key, fingerprint, runId);
markReserved();
await reservationBlocked;
return reservation;
};
const engine = setup.engine();
const operation = engine.execute(startCommand());
await reserved;
const cancellation = engine.cancelStart(
"start-key",
"cancel-reserved-start",
new Error("cancelled after reservation"),
);
let cancellationSettled = false;
void cancellation.then(() => {
cancellationSettled = true;
});
await Promise.resolve();
assert.equal(cancellationSettled, false);
releaseReservation();
await assert.rejects(operation, /cancelled after reservation/);
await cancellation;
assert.equal(setup.workspace.createCalls, 0);
assert.equal(setup.executor.requests.length, 0);
assert.deepEqual(await setup.store.listRuns(), []);
});
});
test("start cancellation after publication durably aborts a settled run", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", discoveryWithQuestions());
const engine = setup.engine();
const started = await engine.execute(startCommand());
await new Promise((resolve) => setImmediate(resolve));
await engine.cancelStart(
"start-key",
"cancel-published-start",
new Error("cancelled after publication"),
);
const aborted = await setup.store.loadRun(started.runId);
assert.equal(aborted.status, "aborted");
assert.equal(
(await setup.store.getStart("start-key"))?.status,
"completed",
);
assert.equal(
((await setup.store.getStart("start-key"))?.result as WorkflowView).status,
"aborted",
);
});
});
test("start and decision idempotency prevent replay and reject changed commands", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
@@ -2353,6 +3001,10 @@ test("start and decision idempotency prevent replay and reject changed commands"
assert.equal(setup.workspace.prepareCalls, 1);
assert.equal(setup.workspace.createCalls, 1);
assert.equal(setup.executor.requests.length, 1);
await assert.rejects(
engine.execute({ ...command, allowDirtySource: true }),
/another start command/,
);
const decide = {
action: "decide" as const,
@@ -2508,7 +3160,6 @@ test("live workspace creation cannot be stolen after its heartbeat ages", async
now: () => now,
createId,
processId,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
const owner = makeEngine(
@@ -2823,7 +3474,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
executor: blocking,
now: () => now,
processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 2,
});
const start = engine.execute(startCommand());
@@ -2843,7 +3493,6 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
.engine("session-2", {
now: () => now,
processId: 100,
leaseStaleMs: 10,
isProcessAlive: () => false,
})
.recover(),
@@ -2854,7 +3503,7 @@ test("active assignment leases heartbeat and block concurrent recovery", async (
});
});
test("a stale heartbeat fences a live read-only assignment during recovery", async () => {
test("a live read-only assignment remains fenced when its heartbeat is old", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
const blocking = new BlockingExecutor(discoveryWithQuestions());
@@ -2864,27 +3513,21 @@ test("a stale heartbeat fences a live read-only assignment during recovery", asy
executor: blocking,
now: () => now,
processId: 100,
leaseStaleMs: 10,
assignmentHeartbeatMs: 1_000,
})
.execute(startCommand());
const startOutcome = start.then(
() => undefined,
(error: unknown) => error,
);
await blocking.started;
now = 100_000;
const recovery = setup.engine("session-2", {
now: () => now,
processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
const [recovered] = await recovery.recover();
assert.equal(recovered?.openGate?.kind, "recovery");
assert.equal(recovered?.assignments[0]?.status, "interrupted");
assert.deepEqual(await recovery.recover(), []);
const [running] = await setup.store.listRuns();
assert.equal(running?.assignments[0]?.status, "running");
blocking.release();
assert.match(String(await startOutcome), /lost its execution lease/);
assert.equal((await start).openGate?.kind, "questions");
});
});
@@ -2916,7 +3559,6 @@ test("a live mutating assignment is not reclaimed on heartbeat age alone", async
const recovery = setup.engine("session-2", {
now: () => 100_000,
processId: 200,
leaseStaleMs: 10,
isProcessAlive: () => true,
});
assert.deepEqual(await recovery.recover(), []);
@@ -3423,6 +4065,65 @@ test("abort fails closed for an Assignment owned by another live process", async
});
});
test("cross-instance abort fails closed for a live timed-out mutator lease", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
setup.executor.enqueue("discover", discoveryWithQuestions());
const started = await setup.engine().execute(startCommand());
const current = await setup.store.loadRun(started.runId);
const profile = setup.profiles.find((candidate) =>
candidate.roles.includes("implement"),
);
const evidence = current.preflightEligibility.find(
(candidate) => candidate.role === "implement",
);
assert.ok(profile && evidence);
current.assignments.push({
id: "timed-out-external-mutator",
stageId: `${current.runId}:implement`,
role: "implement",
profileName: profile.name,
profileDigest: profile.digest,
selection: {
eligibleProfiles: structuredClone(evidence.candidates),
selectedProfileName: profile.name,
rationale: "Timed-out mutator owned by another engine instance",
},
status: "interrupted",
mutates: true,
ownedPaths: ["src"],
executionToken: "timed-out-external-token",
error: "Assignment implement timed out",
});
current.status = "waiting";
current.revision++;
await setup.store.saveRun(current, current.revision - 1);
await setup.store.createAssignmentLease(
current.runId,
"timed-out-external-mutator",
{
token: "timed-out-external-token",
processId: 200,
heartbeatAt: 10_000,
},
);
await assert.rejects(
setup.engine("session-1", {
processId: 100,
isProcessAlive: (processId) => processId === 200,
}).execute({
action: "abort",
runId: current.runId,
expectedRevision: current.revision,
idempotencyKey: "abort-timed-out-external",
}),
/Cannot safely abort Assignment timed-out-external-mutator owned by live process 200/,
);
assert.equal((await setup.store.loadRun(current.runId)).status, "waiting");
});
});
test("abort is revision checked and idempotent", async () => {
await withTempDir(async (directory) => {
const setup = await fixture(directory);
+315 -77
View File
@@ -35,9 +35,9 @@ import {
const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_FEATURE_NAME_LENGTH = 80;
const DEFAULT_LEASE_STALE_MS = 30_000;
const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_000;
const DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS = 15_000;
const DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS = 5 * 60 * 1_000;
const ACTIVE_ASSIGNMENT_TOKENS = new Set<string>();
interface ActiveAssignmentExecution {
runId: string;
@@ -59,6 +59,7 @@ const ACTIVE_STARTS = new Map<
string,
{
fingerprint: string;
controller: AbortController;
operation: Promise<WorkflowView>;
published: Promise<WorkflowView>;
}
@@ -78,6 +79,7 @@ export interface StartWorkflowCommand {
idempotencyKey: string;
concurrency?: number;
background?: boolean;
allowDirtySource?: boolean;
script?: FeatureScriptInput;
}
@@ -372,10 +374,10 @@ export interface WorkflowEngineOptions {
now?: () => number;
createId?: () => string;
processId?: number;
leaseStaleMs?: number;
isProcessAlive?: (processId: number) => boolean;
assignmentHeartbeatMs?: number;
assignmentAbortTimeoutMs?: number;
assignmentExecutionTimeoutMs?: number;
afterAssignmentPersist?: (
assignment: WorkflowAssignment,
run: WorkflowRun,
@@ -613,7 +615,7 @@ function validateWorkflowCommand(
assertKeys(
value,
["action", "request", "featureName", "idempotencyKey"],
["background", "concurrency", "script"],
["allowDirtySource", "background", "concurrency", "script"],
);
nonEmptyString(value.request, "request");
nonEmptyString(value.featureName, "featureName");
@@ -632,6 +634,12 @@ function validateWorkflowCommand(
) {
throw new Error("background must be a boolean");
}
if (
value.allowDirtySource !== undefined &&
typeof value.allowDirtySource !== "boolean"
) {
throw new Error("allowDirtySource must be a boolean");
}
if (value.script !== undefined) validateFeatureScript(value.script);
return;
}
@@ -1243,22 +1251,76 @@ export class WorkflowEngine {
private readonly now: () => number;
private readonly createId: () => string;
private readonly processId: number;
private readonly leaseStaleMs: number;
private readonly isProcessAlive: (processId: number) => boolean;
private readonly assignmentHeartbeatMs: number;
private readonly assignmentAbortTimeoutMs: number;
private readonly assignmentExecutionTimeoutMs: number;
constructor(options: WorkflowEngineOptions) {
this.options = options;
this.now = options.now ?? Date.now;
this.createId = options.createId ?? randomUUID;
this.processId = options.processId ?? process.pid;
this.leaseStaleMs = options.leaseStaleMs ?? DEFAULT_LEASE_STALE_MS;
this.isProcessAlive = options.isProcessAlive ?? processAlive;
this.assignmentHeartbeatMs =
options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS;
this.assignmentAbortTimeoutMs =
options.assignmentAbortTimeoutMs ?? DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS;
this.assignmentExecutionTimeoutMs =
options.assignmentExecutionTimeoutMs ?? DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS;
if (this.assignmentExecutionTimeoutMs < 1) {
throw new Error("Assignment execution timeout must be positive");
}
}
async cancelStart(
idempotencyKey: string,
abortIdempotencyKey: string,
reason: Error,
): Promise<void> {
const activeKey = `${this.options.store.root}\0${idempotencyKey}`;
const active = ACTIVE_STARTS.get(activeKey);
if (!active) {
const start = await this.options.store.getStart(idempotencyKey);
if (!start) return;
const terminal = await this.abortCancelledStart(
start.runId,
abortIdempotencyKey,
);
if (terminal) {
await this.options.store.completeStart(idempotencyKey, terminal);
}
return;
}
active.controller.abort(reason);
const published = await Promise.race([
active.published.then(
(result) => result,
() => undefined,
),
active.operation.then(
() => undefined,
() => undefined,
),
]);
let terminal = published
? await this.abortCancelledStart(published.runId, abortIdempotencyKey)
: undefined;
await active.operation.catch(() => {});
if (!terminal) {
const start = await this.options.store.getStart(idempotencyKey);
if (start) {
terminal = await this.abortCancelledStart(
start.runId,
abortIdempotencyKey,
);
}
}
if (terminal) {
await this.options.store.completeStart(idempotencyKey, terminal);
}
}
async execute(command: WorkflowCommand): Promise<WorkflowView> {
@@ -1317,9 +1379,10 @@ export class WorkflowEngine {
}
if (
run.implementationContext?.status === "normal-compact-running" &&
this.leaseIsStale(run)
this.ownershipIsRecoverable(run)
) {
run = await this.transition(run, (next) => {
this.rotateOwnership(next);
const context = next.implementationContext;
if (!context) throw new Error("Implementation Context state is missing");
context.status = "failed";
@@ -1335,22 +1398,21 @@ export class WorkflowEngine {
const running = run.assignments.filter(
(assignment) =>
assignment.status === "running" ||
(assignment.status === "queued" && Boolean(assignment.executionToken)),
((assignment.status === "queued" || assignment.status === "interrupted") &&
Boolean(assignment.executionToken)),
);
const alreadyWaiting = activeGate(run)?.kind === "recovery";
if (running.length === 0 && alreadyWaiting) continue;
if (
running.length === 0 &&
alreadyWaiting &&
!this.ownershipIsRecoverable(run)
) {
continue;
}
if (await this.hasActiveAssignmentLease(run.runId, running)) continue;
if (!this.leaseIsStale(run)) continue;
if (!this.ownershipIsRecoverable(run)) continue;
run = await this.transition(run, (next) => {
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
});
if (running.length === 0) {
const claimedRevision = run.revision;
@@ -1374,15 +1436,7 @@ export class WorkflowEngine {
for (const assignment of next.assignments) {
if (interruptedIds.has(assignment.id)) assignment.status = "interrupted";
}
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
next.status = "waiting";
if (!activeGate(next)) {
const gate: WorkflowGate = {
@@ -1428,11 +1482,13 @@ export class WorkflowEngine {
resolvePublished = resolve;
rejectPublished = reject;
});
const operation = this.startOwned(command, resolvePublished);
const controller = new AbortController();
const operation = this.startOwned(command, controller.signal, resolvePublished);
void operation.catch(rejectPublished);
void published.catch(() => {});
ACTIVE_STARTS.set(activeKey, {
fingerprint: commandFingerprint,
controller,
operation,
published,
});
@@ -1467,11 +1523,25 @@ export class WorkflowEngine {
}
return active.operation;
}
const operation = this.startAnalysisOwned(command);
let resolvePublished = (_view: WorkflowView) => {};
let rejectPublished = (_error: unknown) => {};
const published = new Promise<WorkflowView>((resolve, reject) => {
resolvePublished = resolve;
rejectPublished = reject;
});
const controller = new AbortController();
const operation = this.startAnalysisOwned(
command,
controller.signal,
resolvePublished,
);
void operation.catch(rejectPublished);
void published.catch(() => {});
ACTIVE_STARTS.set(activeKey, {
fingerprint: commandFingerprint,
controller,
operation,
published: operation,
published,
});
void operation
.finally(() => {
@@ -1483,33 +1553,47 @@ export class WorkflowEngine {
return operation;
}
private async startAnalysisOwned(command: StartAnalysisWorkflowCommand) {
private async startAnalysisOwned(
command: StartAnalysisWorkflowCommand,
cancellationSignal: AbortSignal,
onPublished?: (view: WorkflowView) => void,
) {
this.throwIfStartCancelled(cancellationSignal);
if (!this.options.scriptRuntime) {
throw new Error("Analysis scripts require the restricted script runtime");
}
const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) {
this.validateExistingStart(existing, commandFingerprint);
if (existing.status === "completed") {
return existing.result as unknown as WorkflowView;
const result = existing.result as unknown as WorkflowView;
onPublished?.(result);
return result;
}
}
const runId = existing?.runId ?? this.createId();
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart(
command.idempotencyKey,
commandFingerprint,
runId,
);
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint);
let run: WorkflowRun;
try {
run = await this.options.store.loadRun(reservation.record.runId);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialAnalysisRun(reservation.record.runId, command);
this.throwIfStartCancelled(cancellationSignal);
run = (await this.options.store.createRun(run)).run;
}
onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (run.status !== "completed" && run.status !== "failed") {
run = await this.runWorkflowScript(run);
}
@@ -1520,11 +1604,15 @@ export class WorkflowEngine {
private async startOwned(
command: StartWorkflowCommand,
cancellationSignal: AbortSignal,
onPublished?: (view: WorkflowView) => void,
) {
this.throwIfStartCancelled(cancellationSignal);
this.validateStart(command);
this.throwIfStartCancelled(cancellationSignal);
const commandFingerprint = fingerprint(command);
const existing = await this.options.store.getStart(command.idempotencyKey);
this.throwIfStartCancelled(cancellationSignal);
if (existing) {
this.validateExistingStart(existing, commandFingerprint);
if (existing.status === "completed") {
@@ -1546,6 +1634,7 @@ export class WorkflowEngine {
};
const preflightEligibility = this.preflightEligibility(initialFacts);
selectInitialDiscoveryProfile(this.options.profiles, initialFacts);
this.throwIfStartCancelled(cancellationSignal);
let persistedRun: WorkflowRun | undefined;
if (existing?.status === "pending") {
@@ -1556,6 +1645,7 @@ export class WorkflowEngine {
}
}
let runId = existing?.runId ?? this.createId();
this.throwIfStartCancelled(cancellationSignal);
let workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({
@@ -1563,11 +1653,14 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName,
runId,
allowDirtySource: command.allowDirtySource,
});
this.throwIfStartCancelled(cancellationSignal);
if (workspacePlan.branchExists) {
const owner = await this.options.store.findRunByBranch(
workspacePlan.branch,
);
this.throwIfStartCancelled(cancellationSignal);
const resumable =
existing?.status === "pending" && owner?.runId === runId;
const uncertainPending = existing?.status === "pending" && !owner;
@@ -1583,11 +1676,13 @@ export class WorkflowEngine {
}
}
this.throwIfStartCancelled(cancellationSignal);
const reservation = await this.options.store.reserveStart(
command.idempotencyKey,
commandFingerprint,
runId,
);
this.throwIfStartCancelled(cancellationSignal);
this.validateExistingStart(reservation.record, commandFingerprint);
if (reservation.record.runId !== runId) {
runId = reservation.record.runId;
@@ -1597,6 +1692,7 @@ export class WorkflowEngine {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
persistedRun = undefined;
}
this.throwIfStartCancelled(cancellationSignal);
workspacePlan = persistedRun
? this.workspacePlanForRun(persistedRun)
: await this.options.workspace.prepare({
@@ -1604,28 +1700,34 @@ export class WorkflowEngine {
configuredRoot: this.options.effectiveConfig.worktrees.root,
featureName: command.featureName,
runId,
allowDirtySource: command.allowDirtySource,
});
this.throwIfStartCancelled(cancellationSignal);
}
{
this.throwIfStartCancelled(cancellationSignal);
let run: WorkflowRun;
let persistedBeforeThisAttempt = true;
try {
run = persistedRun ?? (await this.options.store.loadRun(runId));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.throwIfStartCancelled(cancellationSignal);
run = this.initialRun(
runId,
command,
workspacePlan,
preflightEligibility,
);
this.throwIfStartCancelled(cancellationSignal);
const publication = await this.options.store.createRun(run);
persistedBeforeThisAttempt = !publication.created;
run = publication.run;
}
onPublished?.(view(run));
this.throwIfStartCancelled(cancellationSignal);
if (persistedBeforeThisAttempt) {
const claimed = await this.claimPersistedStartRun(run);
@@ -1634,16 +1736,19 @@ export class WorkflowEngine {
}
if (run.workspace.state === "creating" && persistedBeforeThisAttempt) {
this.throwIfStartCancelled(cancellationSignal);
const reconciled = await this.options.workspace.reconcile(
run.workspace,
run.lease?.worktreeIdentity,
);
this.throwIfStartCancelled(cancellationSignal);
if (reconciled.state !== "creating") {
run = await this.persistWorkspaceOutcome(run, reconciled);
}
}
if (run.workspace.state === "creating") {
this.throwIfStartCancelled(cancellationSignal);
let workspace: WorkflowRun["workspace"];
try {
workspace = await this.options.workspace.create(workspacePlan);
@@ -1654,6 +1759,7 @@ export class WorkflowEngine {
error: error instanceof Error ? error.message : String(error),
};
}
this.throwIfStartCancelled(cancellationSignal);
run = await this.persistWorkspaceOutcome(run, workspace);
}
@@ -1661,12 +1767,14 @@ export class WorkflowEngine {
const discoverStatus = stage(run, "discover").status;
const designStatus = stage(run, "design").status;
if (discoverStatus === "pending" || discoverStatus === "running") {
run = await this.runDiscovery(run);
this.throwIfStartCancelled(cancellationSignal);
run = await this.runDiscovery(run, cancellationSignal);
} else if (
discoverStatus === "completed" &&
(designStatus === "pending" || designStatus === "running")
) {
run = await this.runDesign(run);
this.throwIfStartCancelled(cancellationSignal);
run = await this.runDesign(run, false, cancellationSignal);
}
}
@@ -1691,18 +1799,10 @@ export class WorkflowEngine {
) {
return undefined;
}
if (!this.leaseIsStale(run)) return undefined;
if (!this.ownershipIsRecoverable(run)) return undefined;
try {
return await this.transition(run, (next) => {
next.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: next.workspace.root,
};
next.parentSessionIds = [
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
];
this.rotateOwnership(next);
});
} catch (error) {
if (
@@ -1787,6 +1887,9 @@ export class WorkflowEngine {
branch: "",
baseHead: "",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
},
[],
);
@@ -1880,6 +1983,9 @@ export class WorkflowEngine {
root: workspacePlan.worktreePath,
branch: workspacePlan.branch,
baseHead: workspacePlan.baseHead,
sourceChanges: structuredClone(workspacePlan.sourceChanges),
sourceChangesOmitted: workspacePlan.sourceChangesOmitted,
dirtySourceAllowed: workspacePlan.dirtySourceAllowed,
},
effectiveConfig: {
...structuredClone(this.options.effectiveConfig),
@@ -2340,7 +2446,10 @@ export class WorkflowEngine {
}
}
private async runDiscovery(run: WorkflowRun) {
private async runDiscovery(
run: WorkflowRun,
cancellationSignal?: AbortSignal,
) {
const selected = selectionEvidence(
run.pinnedProfiles,
{ ...run.facts, stage: "discover" },
@@ -2350,6 +2459,8 @@ export class WorkflowEngine {
run,
"discover",
selected,
false,
cancellationSignal,
);
run = execution.run;
if (!execution.output) return run;
@@ -2389,7 +2500,9 @@ export class WorkflowEngine {
});
if (output.questions.length > 0) return run;
run = await this.runWorkflowScript(run, "before-design");
return run.status === "failed" ? run : this.runDesign(run);
return run.status === "failed"
? run
: this.runDesign(run, false, cancellationSignal);
}
private plannerCandidates(run: WorkflowRun) {
@@ -2493,7 +2606,11 @@ export class WorkflowEngine {
return planned;
}
private async runDesign(run: WorkflowRun, forceNewAssignment = false) {
private async runDesign(
run: WorkflowRun,
forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) {
const selected = selectionEvidence(
run.pinnedProfiles,
{ ...run.facts, stage: "design" },
@@ -2504,6 +2621,7 @@ export class WorkflowEngine {
"design",
selected,
forceNewAssignment,
cancellationSignal,
);
run = execution.run;
if (!execution.output) return run;
@@ -2606,6 +2724,7 @@ export class WorkflowEngine {
role: "discover" | "design",
selected: ReturnType<typeof selectionEvidence>,
forceNewAssignment = false,
cancellationSignal?: AbortSignal,
) {
const stageId = stage(run, role).id;
const existing = [...run.assignments]
@@ -2620,6 +2739,7 @@ export class WorkflowEngine {
output: existing.status === "succeeded" ? existing.result : undefined,
};
}
if (cancellationSignal) this.throwIfStartCancelled(cancellationSignal);
return this.runAssignment(run, role, selected);
}
@@ -2658,6 +2778,11 @@ export class WorkflowEngine {
const assignmentId = planned?.id ?? this.createId();
const executionToken = this.createId();
const abortController = new AbortController();
let executionTimeout: ReturnType<typeof setTimeout> | undefined;
let executorExecution: Promise<unknown> | undefined;
let executionTimedOut = false;
let retainExecutionFence = false;
let cleanupStarted = false;
let resolveSettled = () => {};
let resolveResumeAfterAbort = () => {};
const activeExecution: ActiveAssignmentExecution = {
@@ -2740,6 +2865,17 @@ export class WorkflowEngine {
assignment.status = "running";
});
};
const cleanupExecutionFence = async () => {
if (cleanupStarted) return;
cleanupStarted = true;
await stopHeartbeat().catch(() => {});
ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken);
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken);
await this.options.store
.releaseAssignmentLease(run.runId, assignmentId, executionToken)
.catch(() => {});
activeExecution.resolveSettled();
};
if (!this.options.executor.reservesCapacity) await markRunning();
try {
@@ -2748,7 +2884,7 @@ export class WorkflowEngine {
let transcriptArtifact: WorkflowAssignment["transcriptArtifact"];
try {
const evidence = await this.assignmentEvidence(run, role);
const executionResult = await this.options.executor.run({
executorExecution = this.options.executor.run({
runId: run.runId,
role,
request: planned
@@ -2771,6 +2907,31 @@ export class WorkflowEngine {
? { onReserved: markRunning }
: {}),
});
void executorExecution.catch(() => {});
const timeout = new Promise<never>((_resolve, reject) => {
executionTimeout = setTimeout(() => {
executionTimedOut = true;
const timeoutError = new Error(
`Assignment ${role} timed out after ${this.assignmentExecutionTimeoutMs}ms`,
);
abortController.abort(timeoutError);
reject(timeoutError);
}, this.assignmentExecutionTimeoutMs);
});
const settledExecution = executorExecution.then(
(result) => {
if (executionTimeout) clearTimeout(executionTimeout);
return result;
},
(error: unknown) => {
if (executionTimeout) clearTimeout(executionTimeout);
throw error;
},
);
const executionResult = await Promise.race([
settledExecution,
timeout,
]);
metadata = isAssignmentExecutionResult(executionResult)
? executionResult.metadata
: undefined;
@@ -2799,13 +2960,30 @@ export class WorkflowEngine {
: undefined;
activeExecution.metadata = metadata;
activeExecution.transcriptArtifact = transcriptArtifact;
await stopHeartbeat();
if (executionTimedOut && executorExecution) {
let abortGrace: ReturnType<typeof setTimeout> | undefined;
const settledDuringGrace = await Promise.race([
executorExecution.then(
() => true,
() => true,
),
new Promise<false>((resolve) => {
abortGrace = setTimeout(
() => resolve(false),
this.assignmentAbortTimeoutMs,
);
}),
]);
if (abortGrace) clearTimeout(abortGrace);
if (!settledDuringGrace) retainExecutionFence = true;
}
if (!retainExecutionFence) await stopHeartbeat();
await this.assertAssignmentLease(
run.runId,
assignmentId,
executionToken,
);
if (abortController.signal.aborted) {
if (abortController.signal.aborted && !executionTimedOut) {
activeExecution.resolveSettled();
await activeExecution.resumeAfterAbort;
run = await this.options.store.loadRun(run.runId);
@@ -2816,7 +2994,7 @@ export class WorkflowEngine {
(item) => item.id === assignmentId,
);
if (assignment) {
assignment.status = "failed";
assignment.status = executionTimedOut ? "interrupted" : "failed";
assignment.error =
error instanceof Error ? error.message : String(error);
applyExecutionMetadata(assignment, metadata, transcriptArtifact);
@@ -2849,7 +3027,7 @@ export class WorkflowEngine {
} else {
next.status = "failed";
next.error = {
code: "assignment-failed",
code: executionTimedOut ? "assignment-timeout" : "assignment-failed",
message,
stageId: targetStage.id,
assignmentId,
@@ -2881,14 +3059,16 @@ export class WorkflowEngine {
);
return { run, output };
} finally {
if (executionTimeout) clearTimeout(executionTimeout);
abortController.abort();
await stopHeartbeat().catch(() => {});
ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken);
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken);
await this.options.store
.releaseAssignmentLease(run.runId, assignmentId, executionToken)
.catch(() => {});
activeExecution.resolveSettled();
if (retainExecutionFence && executorExecution) {
void executorExecution.then(
() => cleanupExecutionFence(),
() => cleanupExecutionFence(),
);
} else {
await cleanupExecutionFence();
}
}
}
@@ -2998,6 +3178,23 @@ export class WorkflowEngine {
}
}
validateDecisionPayload(gate, command.decision);
if (
command.decision.kind === "recover" &&
command.decision.resolution !== "inspect"
) {
const fencedAssignments = run.assignments.filter(
(assignment) =>
Boolean(assignment.executionToken) &&
(assignment.status === "queued" ||
assignment.status === "running" ||
assignment.status === "interrupted"),
);
if (await this.hasActiveAssignmentLease(run.runId, fencedAssignments)) {
throw new Error(
"Recovery is blocked while an interrupted Assignment is still active",
);
}
}
if (
command.decision.kind === "recover" &&
command.decision.resolution === "retry" &&
@@ -4025,7 +4222,6 @@ export class WorkflowEngine {
command: RecoverContextIntentCommand,
) {
if (
command.reason !== "expired" &&
run.lease &&
run.lease.processId !== this.processId &&
this.isProcessAlive(run.lease.processId)
@@ -4101,6 +4297,38 @@ export class WorkflowEngine {
return view(run);
}
private throwIfStartCancelled(signal: AbortSignal) {
if (!signal.aborted) return;
throw signal.reason instanceof Error
? signal.reason
: new Error("Workflow start was cancelled");
}
private async abortCancelledStart(
runId: string,
idempotencyKey: string,
): Promise<WorkflowView | undefined> {
while (true) {
const run = await this.options.store.loadRun(runId).catch(() => undefined);
if (!run) return undefined;
if (isTerminalStatus(run.status)) return view(run);
try {
return await this.execute({
action: "abort",
runId,
expectedRevision: run.revision,
idempotencyKey,
});
} catch (error) {
const latest = await this.options.store.loadRun(runId).catch(() => undefined);
if (!latest) return undefined;
if (isTerminalStatus(latest.status)) return view(latest);
if (latest.revision !== run.revision) continue;
throw error;
}
}
}
private async abort(run: WorkflowRun, command: AbortWorkflowCommand) {
const duplicate = this.commandReceipt(run, command);
if (duplicate) {
@@ -4132,7 +4360,9 @@ export class WorkflowEngine {
(assignment) =>
!localAssignmentIds.has(assignment.id) &&
Boolean(assignment.executionToken) &&
(assignment.status === "queued" || assignment.status === "running"),
(assignment.status === "queued" ||
assignment.status === "running" ||
assignment.status === "interrupted"),
);
for (const assignment of externallyOwned) {
const lease = await this.options.store.loadAssignmentLease(
@@ -4429,6 +4659,9 @@ export class WorkflowEngine {
branch: workspace.branch,
baseHead: workspace.baseHead,
branchExists: false,
sourceChanges: structuredClone(workspace.sourceChanges ?? []),
sourceChangesOmitted: workspace.sourceChangesOmitted ?? 0,
dirtySourceAllowed: workspace.dirtySourceAllowed ?? false,
};
}
@@ -4554,19 +4787,30 @@ export class WorkflowEngine {
command.decision.resolution !== "retry" ||
activeGate(run)?.id !== command.gateId ||
!run.parentSessionIds.includes(this.options.parentSessionId) ||
!run.lease ||
this.isProcessAlive(run.lease.processId)
!this.ownershipIsRecoverable(run)
) {
return run;
}
const claimed = structuredClone(run);
claimed.lease = {
this.rotateOwnership(claimed);
return claimed;
}
private rotateOwnership(run: WorkflowRun) {
run.lease = {
processId: this.processId,
activeParentSessionId: this.options.parentSessionId,
heartbeatAt: this.now(),
worktreeIdentity: claimed.workspace.root,
worktreeIdentity: run.workspace.root,
};
return claimed;
if (run.implementationContext) {
run.implementationContext.activeParentSessionId =
this.options.parentSessionId;
run.implementationContext.runRevision = run.revision + 1;
}
run.parentSessionIds = [
...new Set([...run.parentSessionIds, this.options.parentSessionId]),
];
}
private assertActiveParent(run: WorkflowRun) {
@@ -4642,26 +4886,20 @@ export class WorkflowEngine {
lease.processId === this.processId
? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token)
: this.isProcessAlive(lease.processId);
const heartbeatIsFresh =
this.now() - lease.heartbeatAt <= this.leaseStaleMs;
if (ownerIsRunning && (heartbeatIsFresh || assignment.mutates))
return true;
if (ownerIsRunning) return true;
}
return false;
}
private leaseIsStale(run: WorkflowRun) {
private ownershipIsRecoverable(run: WorkflowRun) {
if (!run.lease) return true;
if (
run.lease?.processId === this.processId &&
run.lease.processId === this.processId &&
run.lease.activeParentSessionId === this.options.parentSessionId
) {
return false;
}
return (
!run.lease ||
this.now() - run.lease.heartbeatAt > this.leaseStaleMs ||
!this.isProcessAlive(run.lease.processId)
);
return !this.isProcessAlive(run.lease.processId);
}
private validateStart(command: StartWorkflowCommand) {
+3
View File
@@ -809,6 +809,9 @@ test("production executor drives Discover and Design through Plan Approval", asy
branch: `feature/${options.featureName}`,
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
},
async create(plan): Promise<WorkflowRun["workspace"]> {
+9 -1
View File
@@ -24,6 +24,10 @@ import {
import { runScriptSandbox } from "./script-runtime.ts";
import { WorkflowStore } from "./store.ts";
import { registerPiWorkflowTool } from "./tool.ts";
import {
refreshWorkflowProgress,
registerWorkflowProgress,
} from "./progress.ts";
import {
ApprovedHostCommandExecutor,
FilesystemPrototypeValidator,
@@ -111,7 +115,10 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
let runtime: ContextTransitionRuntime;
runtime = createWorkflowRuntime({
afterRunPersist: async (ctx) => {
await reconcileWorkflowDeliveries(pi, runtime, ctx);
await Promise.all([
reconcileWorkflowDeliveries(pi, runtime, ctx),
refreshWorkflowProgress(runtime, ctx),
]);
},
});
registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
@@ -120,4 +127,5 @@ export default function piWorkflowExtension(pi: ExtensionAPI) {
registerPiWorkflowTool(pi, runtime);
registerWorkflowRecovery(pi, runtime);
registerWorkflowNotifications(pi, runtime);
registerWorkflowProgress(pi, runtime);
}
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import {
projectWorkflowProgress,
refreshWorkflowProgress,
registerWorkflowProgress,
renderWorkflowProgress,
} from "./progress.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
runId: "run-1",
revision: 3,
mode: "feature",
status: "waiting",
background: false,
request: "Build dashboard",
featureName: "dashboard",
parentSessionIds: ["session-1"],
lease: {
processId: 1,
activeParentSessionId: "session-1",
heartbeatAt: 10,
},
workspace: { state: "ready", sourceRoot: "/repo" },
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "design",
taskKind: "feature",
requestTerms: [],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [
{ id: "d", kind: "discover", status: "completed" },
{ id: "p", kind: "design", status: "waiting" },
{ id: "pt", kind: "prototype", status: "pending" },
{ id: "i", kind: "implement", status: "pending" },
{ id: "v", kind: "verify", status: "pending" },
{ id: "r", kind: "review", status: "pending" },
],
gates: [
{
id: "gate",
kind: "plan-approval",
status: "open",
revision: 3,
openedAt: 12,
},
],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [],
commandReceipts: [],
uiImpactEvidence: [],
affectedUiSurfaces: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
...overrides,
};
}
test("progress projects the newest attached active run", () => {
const older = run({ runId: "older", featureName: "older", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 5 } });
const newest = run({ runId: "newest", featureName: "newest", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 20 } });
const terminal = run({ runId: "terminal", status: "completed", lease: undefined });
const historical = run({ runId: "historical", parentSessionIds: ["session-2"] });
const progress = projectWorkflowProgress([older, terminal, newest, historical], "session-1");
assert.deepEqual(progress, {
runId: "newest",
label: "newest",
stage: "DESIGN",
completed: 1,
total: 6,
gate: "Plan Approval",
});
});
test("progress clears when no attached run remains active", () => {
assert.equal(
projectWorkflowProgress([run({ status: "failed", lease: undefined })], "session-1"),
undefined,
);
assert.equal(projectWorkflowProgress([run()], "session-2"), undefined);
});
test("progress lifecycle refreshes from persistence and clears on terminal state and shutdown", async () => {
let runs = [run()];
let widget: unknown;
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
const pi = {
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
handlers.set(event, handler);
},
} as unknown as ExtensionAPI;
const runtime = {
store: { listRuns: async () => runs },
} as never;
const ctx = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
setWidget(key: string, value: unknown) {
assert.equal(key, "pi-workflow-active-run");
widget = value;
},
},
} as unknown as ExtensionContext;
registerWorkflowProgress(pi, runtime);
await handlers.get("session_start")?.({}, ctx);
assert.equal(typeof widget, "function");
runs = [run({ status: "completed", lease: undefined })];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(widget, undefined);
runs = [run()];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(typeof widget, "function");
await handlers.get("session_shutdown")?.({}, ctx);
assert.equal(widget, undefined);
});
test("progress rendering is one cell-width-safe line", () => {
const progress = projectWorkflowProgress([run()], "session-1");
assert.ok(progress);
const wide = renderWorkflowProgress(progress, 80);
assert.match(wide, /workflow dashboard DESIGN 1\/6 Gate: Plan Approval/);
const narrow = renderWorkflowProgress(progress, 28);
assert.ok(visibleWidth(narrow) <= 28);
assert.equal(narrow.includes("\n"), false);
});
+124
View File
@@ -0,0 +1,124 @@
import type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { ContextTransitionRuntime } from "./context-transition.ts";
import type { WorkflowRun } from "./domain.ts";
import { deriveRunLabels } from "./run-label.ts";
const WIDGET_KEY = "pi-workflow-active-run";
const CORE_STAGES = new Set([
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
]);
export interface WorkflowProgress {
runId: string;
label: string;
stage: string;
completed: number;
total: number;
gate?: string;
}
function activityTime(run: WorkflowRun) {
return Math.max(
run.lease?.heartbeatAt ?? 0,
...run.gates.map((gate) => gate.openedAt),
...run.stages.flatMap((stage) => [stage.startedAt ?? 0, stage.finishedAt ?? 0]),
);
}
function readableGate(kind: string) {
return kind
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function newestAttachedActiveRun(
runs: WorkflowRun[],
sessionId: string | undefined,
) {
if (!sessionId) return undefined;
return runs
.filter(
(run) =>
(run.status === "running" || run.status === "waiting") &&
run.parentSessionIds.includes(sessionId),
)
.sort((left, right) => activityTime(right) - activityTime(left))[0];
}
export function projectWorkflowProgress(
runs: WorkflowRun[],
sessionId: string | undefined,
): WorkflowProgress | undefined {
const run = newestAttachedActiveRun(runs, sessionId);
if (!run) return undefined;
const active = [...run.stages]
.reverse()
.find((stage) => stage.status === "running" || stage.status === "waiting");
const gate = [...run.gates].reverse().find((entry) => entry.status === "open");
const label = deriveRunLabels(runs)[runs.indexOf(run)] ?? run.runId.slice(0, 8);
const stages = run.stages.filter((stage) => CORE_STAGES.has(stage.kind));
return {
runId: run.runId,
label,
stage: String(active?.kind ?? run.status).toUpperCase(),
completed: stages.filter((stage) => stage.status === "completed").length,
total: stages.length,
...(gate ? { gate: readableGate(gate.kind) } : {}),
};
}
export function renderWorkflowProgress(
progress: WorkflowProgress,
width: number,
theme?: Pick<Theme, "fg">,
) {
const paint = (token: "accent" | "dim" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const base = `${paint("accent", "workflow")} ${paint("text", progress.label)} ${paint("muted", progress.stage)} ${progress.completed}/${progress.total}`;
const full = progress.gate ? `${base} ${paint("dim", `Gate: ${progress.gate}`)}` : base;
return truncateToWidth(full, Math.max(1, width), "");
}
export async function refreshWorkflowProgress(
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
) {
if (ctx.mode !== "tui") return;
const progress = projectWorkflowProgress(
await runtime.store.listRuns(),
ctx.sessionManager.getSessionId(),
);
if (!progress) {
ctx.ui.setWidget(WIDGET_KEY, undefined);
return;
}
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
render(width: number) {
return [renderWorkflowProgress(progress, width, theme)];
},
invalidate() {},
}));
}
export function registerWorkflowProgress(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
pi.on("session_start", async (_event, ctx) => {
await refreshWorkflowProgress(runtime, ctx);
});
pi.on("session_shutdown", async (_event, ctx) => {
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
});
}
+393
View File
@@ -10,6 +10,7 @@ import {
registerPiWorkflowTool,
workflowCommandFromTool,
} from "./tool.ts";
import { WorkspaceValidationError } from "./workspace.ts";
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return {
@@ -79,6 +80,25 @@ test("tool commands derive active tools for feature and analysis scripts", () =>
},
["read", "ls"],
);
assert.deepEqual(
workflowCommandFromTool(
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
["read"],
),
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
);
assert.equal(analysis.action, "start-analysis");
assert.deepEqual(
analysis.action === "start-analysis" ? analysis.activeToolNames : [],
@@ -189,6 +209,379 @@ test("compact tool details expose only valid continuation intents", () => {
);
});
test("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError(
"dirty-source",
"source changes are omitted",
{
sourceChanges: [{ status: "??", path: "local-notes.md" }],
sourceChangesOmitted: 2,
},
);
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
let confirmation = "";
await definition.execute(
"dirty-call",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key",
},
new AbortController().signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async (factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: { fg(color: string, text: string): string; bg(color: string, text: string): string },
keybindings: object,
done: (value: boolean) => void,
) => { render(width: number): string[] }) => {
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
{
fg: (_color, text) => text,
bg: (_color, text) => text,
},
{},
() => {},
);
confirmation = component.render(100).join("\n");
return true;
},
},
} as unknown as ExtensionContext,
);
assert.match(confirmation, /local-notes\.md/);
assert.match(confirmation, /2 additional paths omitted/);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true],
);
const explicitContext = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
confirmation = "unexpected prompt";
return false;
},
},
} as unknown as ExtensionContext;
confirmation = "";
await assert.rejects(
definition.execute(
"dirty-call-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
explicitContext,
),
/source changes are omitted/,
);
assert.equal(confirmation, "");
await definition.execute(
"dirty-call-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
explicitContext,
);
assert.equal(confirmation, "");
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true, false, true],
);
});
test("RPC dirty-source starts never open TUI overlays and require the flag", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
const ctx = {
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("RPC must not open a custom overlay");
},
},
} as unknown as ExtensionContext;
await assert.rejects(
definition.execute(
"rpc-dirty-rejected",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-rejected",
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await assert.rejects(
definition.execute(
"rpc-dirty-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await definition.execute(
"rpc-dirty-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
ctx,
);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[undefined, false, true],
);
});
test("start tool cancellation waits for engine settlement before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
let markStarted = () => {};
let releaseStart = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const blocked = new Promise<void>((resolve) => {
releaseStart = resolve;
});
let cancelled = false;
let cancellationReason = new Error("cancelled before mutation");
let laterMutation = false;
let engineOperation: Promise<WorkflowView> | undefined;
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async cancelStart(
_idempotencyKey: string,
_abortIdempotencyKey: string,
reason: Error,
) {
cancelled = true;
cancellationReason = reason;
await engineOperation?.catch(() => {});
},
execute() {
engineOperation = (async () => {
markStarted();
await blocked;
if (cancelled) throw cancellationReason;
laterMutation = true;
return workflowView();
})();
return engineOperation;
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-slow-start",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-slow-start-key",
},
controller.signal,
undefined,
{
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
);
await started;
let toolSettled = false;
void operation.then(
() => {
toolSettled = true;
},
() => {
toolSettled = true;
},
);
controller.abort(new Error("cancelled by tool caller"));
await Promise.resolve();
assert.equal(toolSettled, false);
releaseStart();
await assert.rejects(operation, /cancelled by tool caller/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(laterMutation, false);
});
test("cancellation during dirty-source confirmation cannot start a retry", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
let worktreeCreations = 0;
let showConfirmation = () => {};
const confirmationShown = new Promise<void>((resolve) => {
showConfirmation = resolve;
});
let finishConfirmation = (_value: boolean) => {};
const confirmation = new Promise<boolean>((resolve) => {
finishConfirmation = resolve;
});
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {
async getStart() {
return { runId: "not-created" };
},
async loadRun() {
throw new Error("not created");
},
} as never,
async engineForContext() {
return {
async cancelStart() {},
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && command.allowDirtySource === true) {
worktreeCreations++;
return workflowView();
}
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-dirty-confirmation",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-dirty-key",
},
controller.signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
showConfirmation();
return confirmation;
},
},
} as unknown as ExtensionContext,
);
await confirmationShown;
controller.abort(new Error("cancelled while confirming"));
await assert.rejects(operation, /cancelled while confirming/);
finishConfirmation(true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false],
);
assert.equal(worktreeCreations, 0);
});
test("tool cancellation aborts its active Workflow Run before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
+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();
+304
View File
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { CURSOR_MARKER, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
import {
createPlanReaderModel,
createPlanReaderState,
reducePlanReaderState,
renderPlanReader,
sanitizeTerminalText,
workflowEditor,
workflowInput,
WorkflowSelectionOverlay,
} from "./tui.ts";
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const plan = [
"# Workflow plan",
"intro",
"## First section",
"first body",
"### Detail",
"detail body",
"## Last section",
"last body",
].join("\n");
test("selection overlay windows long lists, stays width-safe, and returns stable values", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Workflow Runs",
Array.from({ length: 30 }, (_, index) => ({
value: `run-${index}`,
label: `feature-${index}`,
description: index === 0 ? "waiting" : undefined,
})),
theme as never,
(value) => { selected = value; },
);
const initial = overlay.render(48);
assert.ok(initial.some((line) => line.includes("1-12 of 30")));
assert.ok(initial.every((line) => visibleWidth(line) <= 48));
overlay.handleInput("\x1b[F");
const last = overlay.render(48);
assert.ok(last.some((line) => line.includes("19-30 of 30")));
overlay.handleInput("\r");
assert.equal(selected, "run-29");
});
test("selection overlay budgets wrapped descriptions for short narrow terminals", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Short terminal",
Array.from({ length: 20 }, (_, index) => ({
value: `choice-${index}`,
label: `choice-${index}`,
description:
index === 19
? "A long selected description that wraps repeatedly without hiding navigation controls"
: undefined,
})),
theme as never,
(value) => { selected = value; },
8,
);
overlay.handleInput("\x1b[F");
const lines = overlay.render(28);
assert.ok(lines.length <= 8);
assert.ok(lines.every((line) => visibleWidth(line) <= 28));
assert.ok(lines.some((line) => line.includes("> choice-19")));
assert.ok(lines.some((line) => line.includes("enter select")));
overlay.handleInput("\x1b[A");
assert.ok(overlay.render(28).some((line) => line.includes("> choice-18")));
overlay.handleInput("\r");
assert.equal(selected, "choice-18");
});
type InteractiveOverlay = Component & Partial<Focusable>;
type OverlayFactory = (
tui: TUI,
overlayTheme: Theme,
keybindings: object,
done: (value: unknown) => void,
) => InteractiveOverlay;
function interactionContext(
interact: (component: InteractiveOverlay) => void,
rows = 24,
) {
const tui = {
requestRender() {},
terminal: { columns: 80, rows },
} as unknown as TUI;
return {
mode: "tui",
ui: {
async custom(factory: OverlayFactory) {
let completed: unknown;
const component = factory(
tui,
theme as unknown as Theme,
{},
(value) => { completed = value; },
);
interact(component);
return completed;
},
},
} as unknown as ExtensionContext;
}
test("input overlay propagates focus, renders a cursor marker, and handles input", async () => {
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(48).join("\n");
component.focused = true;
component.handleInput?.("approved");
focused = component.render(48).join("\n");
component.handleInput?.("\r");
}),
"Answer",
"Provide a value",
);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /approved/);
assert.equal(result, "approved");
});
test("input overlay keeps focused input and controls visible below a long prompt", async () => {
const prompt = Array.from(
{ length: 20 },
(_, index) => `long prompt segment ${index}`,
).join(" ");
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(40).join("\n");
component.focused = true;
component.handleInput?.("yes");
focused = component.render(40).join("\n");
component.handleInput?.("\r");
}, 8),
"Short terminal",
prompt,
);
assert.ok(unfocused.split("\n").length <= 5);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.ok(focused.split("\n").length <= 5);
assert.ok(focused.split("\n").every((line) => visibleWidth(line) <= 40));
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /yes/);
assert.match(focused, /enter submit esc back/);
assert.match(focused, /…/);
assert.equal(result, "yes");
});
test("editor overlay propagates focus and Escape resolves through the wrapper", async () => {
let focused = "";
const result = await workflowEditor(
interactionContext((component) => {
component.focused = true;
component.handleInput?.("x");
focused = component.render(56).join("\n");
component.handleInput?.("\x1b");
}),
"Feedback",
"Revise the plan",
"draft",
);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /draft/);
assert.equal(result, undefined);
});
test("plan reader parses H1-H3 and tracks bounded navigation state", () => {
const model = createPlanReaderModel("example", plan);
assert.deepEqual(
model.headings.map(({ level, text, line }) => ({ level, text, line })),
[
{ level: 1, text: "Workflow plan", line: 0 },
{ level: 2, text: "First section", line: 2 },
{ level: 3, text: "Detail", line: 4 },
{ level: 2, text: "Last section", line: 6 },
],
);
let state = createPlanReaderState();
state = reducePlanReaderState(model, state, "up", { width: 60, height: 24 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "page-down", { width: 60, height: 12 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "home", { width: 60, height: 12 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "end", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "down", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
});
test("plan reader renders outline at 80 columns and one pane below 80", () => {
const model = createPlanReaderModel("example", plan);
const wide = renderPlanReader(model, { topRow: 4 }, { width: 80, height: 24 });
assert.ok(wide.some((line) => line.includes("> Detail")));
assert.ok(wide.some((line) => line.includes("detail body")));
assert.ok(wide.every((line) => visibleWidth(line) <= 80));
assert.ok(wide.length <= 24);
const narrow = renderPlanReader(model, { topRow: 4 }, { width: 60, height: 12 });
assert.match(narrow[0] ?? "", /Detail/);
assert.equal(narrow.some((line) => line.includes("> Detail")), false);
assert.ok(narrow.every((line) => visibleWidth(line) <= 60));
assert.ok(narrow.length <= 12);
});
test("wide outline windows enough headings to keep the current section visible", () => {
const contents = Array.from(
{ length: 40 },
(_, index) => `## Section ${index}\nbody ${index}`,
).join("\n");
const model = createPlanReaderModel("many sections", contents);
const lines = renderPlanReader(
model,
{ topRow: 70 },
{ width: 80, height: 12 },
);
assert.ok(lines.some((line) => />\s+Section 35/.test(line)));
assert.equal(lines.some((line) => line.includes("Section 0")), false);
});
test("plan reader reports truthful minimum size below either boundary", () => {
const model = createPlanReaderModel("example", plan);
for (const dimensions of [
{ width: 47, height: 11 },
{ width: 47, height: 24 },
{ width: 80, height: 11 },
]) {
const lines = renderPlanReader(model, createPlanReaderState(), dimensions);
assert.ok(lines.some((line) => line.includes("needs 48x12")));
assert.ok(
lines.some((line) =>
line.includes(`current terminal is ${dimensions.width}x${dimensions.height}`),
),
);
assert.ok(lines.every((line) => visibleWidth(line) <= dimensions.width));
assert.ok(lines.length <= dimensions.height);
}
});
test("reader sanitizes terminal control sequences before rendering", () => {
const unsafe = "# Safe\n\x1b[31mred\x1b[0m\n\x1b]8;;https://bad.invalid\x07link\x1b]8;;\x07\nend\u0000";
const sanitized = sanitizeTerminalText(unsafe);
assert.equal(sanitized.includes("\x1b"), false);
assert.equal(sanitized.includes("\u0000"), false);
const model = createPlanReaderModel("unsafe", unsafe);
const lines = renderPlanReader(model, createPlanReaderState(), { width: 48, height: 12 });
assert.equal(lines.join("\n").includes("\x1b"), false);
assert.ok(lines.every((line) => visibleWidth(line) <= 48));
});
test("plan reader reaches every wrapped display row before following content", () => {
const longLine = Array.from(
{ length: 40 },
(_, index) => `chunk-${String(index).padStart(2, "0")}`,
).join(" ");
const model = createPlanReaderModel(
"wrapped rows",
`# Wrapped section\n${longLine}\nfollowing-content`,
);
const expectedTopRows = [
"# Wrapped section",
...wrapTextWithAnsi(longLine, 46),
"following-content",
];
let state = createPlanReaderState();
for (const expected of expectedTopRows) {
const frame = renderPlanReader(model, state, { width: 48, height: 12 });
assert.equal(frame[2]?.trim(), expected);
assert.match(frame[0] ?? "", /Wrapped sect/);
state = reducePlanReaderState(
model,
state,
"down",
{ width: 48, height: 12 },
);
}
assert.ok(expectedTopRows.length > 5);
});
test("reader only includes the active viewport near document end", () => {
const contents = Array.from({ length: 200 }, (_, index) => `line-${index}`).join("\n");
const model = createPlanReaderModel("large", contents);
const lines = renderPlanReader(model, { topRow: 190 }, { width: 60, height: 12 });
assert.ok(lines.some((line) => line.includes("line-190")));
assert.equal(lines.some((line) => line.includes("line-20")), false);
assert.ok(lines.length <= 12);
});
+702
View File
@@ -0,0 +1,702 @@
import type {
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Input,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
type TUI,
} from "@earendil-works/pi-tui";
export interface OverlayChoice<T> {
value: T;
label: string;
description?: string;
}
function sanitizeLine(value: string) {
return value
.replace(
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g,
"",
)
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
.replaceAll("\t", " ");
}
export function sanitizeTerminalText(value: string) {
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n");
}
function fit(value: string, width: number) {
const fitted = truncateToWidth(value, Math.max(1, width), "");
return value.includes("\x1b") ? fitted : sanitizeLine(fitted);
}
function pad(value: string, width: number) {
const bounded = fit(value, width);
return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
}
function card(
theme: Theme,
title: string,
contents: string[],
width: number,
) {
const innerWidth = Math.max(1, width - 2);
const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth);
const titleWidth = visibleWidth(safeTitle);
const trailing = Math.max(0, innerWidth - titleWidth);
return [
`${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`,
...contents.map(
(line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`,
),
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
].map((line) => fit(line, width));
}
export class WorkflowSelectionOverlay<T> implements Component {
private selected = 0;
private readonly title: string;
private readonly choices: OverlayChoice<T>[];
private readonly theme: Theme;
private readonly done: (value: T | undefined) => void;
private readonly maxRows: number;
constructor(
title: string,
choices: OverlayChoice<T>[],
theme: Theme,
done: (value: T | undefined) => void,
maxRows = 24,
) {
this.title = title;
this.choices = choices;
this.theme = theme;
this.done = done;
this.maxRows = Math.max(4, Math.floor(maxRows));
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done(undefined);
} else if (matchesKey(data, Key.up)) {
this.selected = Math.max(0, this.selected - 1);
} else if (matchesKey(data, Key.down)) {
this.selected = Math.min(this.choices.length - 1, this.selected + 1);
} else if (matchesKey(data, Key.home)) {
this.selected = 0;
} else if (matchesKey(data, Key.end)) {
this.selected = Math.max(0, this.choices.length - 1);
} else if (matchesKey(data, Key.enter)) {
const choice = this.choices[this.selected];
if (choice) this.done(choice.value);
}
}
render(width: number) {
const bodyRows = Math.max(2, this.maxRows - 2);
const showLeadingSpace = bodyRows >= 5;
const showPagination = this.choices.length > 1 && bodyRows >= 3;
const selectedChoice = this.choices[this.selected];
const descriptionWidth = Math.max(1, width - 8);
const selectedDescription = selectedChoice?.description
? sanitizeTerminalText(selectedChoice.description)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, descriptionWidth);
return wrapped.length > 0 ? wrapped : [""];
})
: [];
const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination);
const choiceRows = Math.max(1, bodyRows - fixedRows);
const descriptionRows = Math.min(
selectedDescription.length,
Math.max(0, Math.floor((choiceRows - 1) / 2)),
);
const maxVisible = Math.max(
1,
Math.min(12, this.choices.length || 1, choiceRows - descriptionRows),
);
const start = Math.max(
0,
Math.min(
this.selected - Math.floor(maxVisible / 2),
Math.max(0, this.choices.length - maxVisible),
),
);
const body: string[] = showLeadingSpace ? [""] : [];
for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) {
const index = start + offset;
const selected = index === this.selected;
const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`;
body.push(
selected
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg("text", text),
);
if (selected) {
for (const line of selectedDescription.slice(0, descriptionRows)) {
body.push(this.theme.fg("muted", ` ${line}`));
}
}
}
if (this.choices.length === 0) {
body.push(this.theme.fg("muted", " No choices available"));
}
if (showPagination && this.choices.length > maxVisible) {
body.push(
this.theme.fg(
"dim",
` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`,
),
);
}
body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back"));
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {}
}
abstract class FocusedTextOverlay implements Component, Focusable {
private _focused = false;
abstract readonly child: Input | Editor;
get focused() {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.child.focused = value;
}
abstract render(width: number): string[];
abstract handleInput(data: string): void;
abstract invalidate(): void;
}
class WorkflowInputOverlay extends FocusedTextOverlay {
readonly child = new Input();
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
private readonly maxRows: number;
constructor(
title: string,
prompt: string,
theme: Theme,
done: (value: string | undefined) => void,
maxRows: number,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
this.maxRows = Math.max(4, Math.floor(maxRows));
this.child.onSubmit = (value) => done(value);
this.child.onEscape = () => done(undefined);
}
handleInput(data: string) {
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const wrappedPrompt = sanitizeTerminalText(this.prompt)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, innerWidth);
return wrapped.length > 0 ? wrapped : [""];
});
const bodyRows = this.maxRows - 2;
const showLeadingSpace = bodyRows >= 5;
const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace));
const prompt = wrappedPrompt.slice(0, promptRows);
if (wrappedPrompt.length > promptRows && prompt.length > 0) {
const last = prompt.length - 1;
prompt[last] = fit(
`${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`,
innerWidth,
);
}
const body = [
...(showLeadingSpace ? [""] : []),
...prompt.map((line) => ` ${line}`),
` ${this.child.render(innerWidth)[0] ?? ""}`,
this.theme.fg("dim", " enter submit esc back"),
];
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {
this.child.invalidate();
}
}
class WorkflowEditorOverlay extends FocusedTextOverlay {
readonly child: Editor;
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
constructor(
tui: TUI,
title: string,
prompt: string,
initial: string,
theme: Theme,
done: (value: string | undefined) => void,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
const editorTheme: EditorTheme = {
borderColor: (value) => theme.fg("borderAccent", value),
selectList: {
selectedPrefix: (value) => theme.fg("accent", value),
selectedText: (value) => theme.fg("accent", value),
description: (value) => theme.fg("muted", value),
scrollInfo: (value) => theme.fg("dim", value),
noMatch: (value) => theme.fg("warning", value),
},
};
this.child = new Editor(tui, editorTheme, { paddingX: 0 });
this.child.setText(initial);
this.child.onSubmit = (value) => done(value);
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) return;
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth);
return card(
this.theme,
this.title,
["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")],
width,
);
}
invalidate() {
this.child.invalidate();
}
}
function assertTui(ctx: ExtensionContext) {
if (ctx.mode !== "tui") {
throw new Error("pi-workflow overlays require TUI mode");
}
}
export async function workflowSelect<T>(
ctx: ExtensionContext,
title: string,
choices: OverlayChoice<T>[],
) {
assertTui(ctx);
return ctx.ui.custom<T | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowSelectionOverlay(
title,
choices,
theme,
done,
Math.floor(tui.terminal.rows * 0.8),
);
return {
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "center",
width: "70%",
minWidth: 36,
maxHeight: "80%",
margin: 1,
},
},
);
}
export function workflowConfirm(
ctx: ExtensionContext,
title: string,
message: string,
) {
return workflowSelect(ctx, title, [
{ value: true, label: "Confirm", description: message },
{ value: false, label: "Cancel" },
]).then((value) => value === true);
}
export async function workflowInput(
ctx: ExtensionContext,
title: string,
prompt: string,
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowInputOverlay(
title,
prompt,
theme,
done,
Math.max(4, Math.floor(tui.terminal.rows * 0.7)),
);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 },
},
);
}
export async function workflowEditor(
ctx: ExtensionContext,
title: string,
prompt: string,
initial = "",
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.escape)) done(undefined);
else overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 },
},
);
}
export interface PlanHeading {
level: 1 | 2 | 3;
text: string;
line: number;
}
export interface PlanReaderModel {
title: string;
lines: string[];
headings: PlanHeading[];
}
export interface PlanReaderState {
topRow: number;
}
export type PlanReaderAction =
| "up"
| "down"
| "page-up"
| "page-down"
| "home"
| "end";
interface PlanDisplayRow {
text: string;
sourceLine: number;
}
export function createPlanReaderModel(title: string, contents: string): PlanReaderModel {
const lines = sanitizeTerminalText(contents).split("\n");
const headings = lines.flatMap((line, index): PlanHeading[] => {
const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
return match
? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }]
: [];
});
return { title: sanitizeLine(title), lines, headings };
}
export function createPlanReaderState(): PlanReaderState {
return { topRow: 0 };
}
function readerBodyHeight(height: number) {
return Math.max(1, height - 4);
}
function readerOutlineWidth(width: number) {
return Math.max(24, Math.min(32, Math.floor(width * 0.34)));
}
function readerDocumentWidth(width: number) {
return width < 80
? Math.max(1, width - 2)
: Math.max(1, width - readerOutlineWidth(width) - 3);
}
function displayRows(model: PlanReaderModel, width: number) {
return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => {
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({
text,
sourceLine,
}));
});
}
export function reducePlanReaderState(
model: PlanReaderModel,
state: PlanReaderState,
action: PlanReaderAction,
dimensions: { width: number; height: number },
): PlanReaderState {
const rows = displayRows(model, readerDocumentWidth(dimensions.width));
const last = Math.max(0, rows.length - 1);
const page = Math.max(1, readerBodyHeight(dimensions.height) - 1);
const delta =
action === "up"
? -1
: action === "down"
? 1
: action === "page-up"
? -page
: action === "page-down"
? page
: 0;
const topRow =
action === "home"
? 0
: action === "end"
? last
: Math.max(0, Math.min(last, state.topRow + delta));
return { topRow };
}
function currentHeading(model: PlanReaderModel, sourceLine: number) {
return [...model.headings].reverse().find((heading) => heading.line <= sourceLine);
}
function viewportRows(
rows: PlanDisplayRow[],
topRow: number,
height: number,
) {
return rows.slice(topRow, topRow + readerBodyHeight(height));
}
export function renderPlanReader(
model: PlanReaderModel,
state: PlanReaderState,
dimensions: { width: number; height: number },
theme?: Theme,
) {
const { width, height } = dimensions;
if (width < 48 || height < 12) {
const lines = [
"",
"terminal too small",
"",
"pi-workflow plan needs 48x12",
`current terminal is ${width}x${height}`,
"",
"resize the terminal or press esc",
];
const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2));
return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height);
}
const documentWidth = readerDocumentWidth(width);
const rows = displayRows(model, documentWidth);
const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow));
const sourceLine = rows[topRow]?.sourceLine ?? 0;
const heading = currentHeading(model, sourceLine);
const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`;
const headingLabel = width < 80 && heading ? ` · ${heading.text}` : "";
const header = fit(` pi-workflow plan · ${model.title}${headingLabel} · ${position}`, width);
const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width);
const bodyHeight = readerBodyHeight(height);
const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const viewport = viewportRows(rows, topRow, height);
if (width < 80) {
const body = viewport.map((row) => {
const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const;
return fit(` ${paint(token, row.text)}`, width);
});
while (body.length < bodyHeight) body.push("");
return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height);
}
const outlineWidth = readerOutlineWidth(width);
const gap = 3;
const body = viewport.map((row) => row.text);
const selectedHeading = heading?.line;
const selectedHeadingIndex = Math.max(
0,
model.headings.findIndex((entry) => entry.line === selectedHeading),
);
const outlineStart = Math.max(
0,
Math.min(
selectedHeadingIndex - Math.floor(bodyHeight / 2),
Math.max(0, model.headings.length - bodyHeight),
),
);
const outline = model.headings
.slice(outlineStart, outlineStart + bodyHeight)
.map((entry) => {
const prefix = entry.line === selectedHeading ? "> " : " ";
const indent = " ".repeat(entry.level - 1);
const text = `${prefix}${indent}${entry.text}`;
return entry.line === selectedHeading
? paint("accent", text)
: paint(entry.level === 1 ? "text" : "muted", text);
});
const renderedRows = Array.from({ length: bodyHeight }, (_, index) => {
const left = pad(outline[index] ?? "", outlineWidth);
const right = fit(body[index] ?? "", documentWidth);
return fit(`${left}${" ".repeat(gap)}${right}`, width);
});
return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height);
}
class PlanReaderComponent implements Component {
private state = createPlanReaderState();
private width: number;
private readonly tui: TUI;
private readonly model: PlanReaderModel;
private readonly theme: Theme;
private readonly done: () => void;
constructor(
tui: TUI,
model: PlanReaderModel,
theme: Theme,
done: () => void,
) {
this.tui = tui;
this.width = tui.terminal.columns;
this.model = model;
this.theme = theme;
this.done = done;
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done();
return;
}
const action = matchesKey(data, Key.up)
? "up"
: matchesKey(data, Key.down)
? "down"
: matchesKey(data, Key.pageUp)
? "page-up"
: matchesKey(data, Key.pageDown)
? "page-down"
: matchesKey(data, Key.home)
? "home"
: matchesKey(data, Key.end)
? "end"
: undefined;
if (action) {
this.state = reducePlanReaderState(
this.model,
this.state,
action,
{ width: this.width, height: this.tui.terminal.rows },
);
}
}
render(width: number) {
this.width = width;
return renderPlanReader(
this.model,
this.state,
{ width, height: this.tui.terminal.rows },
this.theme,
);
}
invalidate() {}
}
export async function workflowPlanReader(
ctx: ExtensionContext,
title: string,
contents: string,
) {
assertTui(ctx);
const model = createPlanReaderModel(title, contents);
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const reader = new PlanReaderComponent(tui, model, theme, done);
return {
render: (width) => reader.render(width),
invalidate: () => reader.invalidate(),
handleInput: (data) => {
reader.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
},
},
);
}
+126 -9
View File
@@ -20,11 +20,13 @@ import {
FilesystemPrototypeValidator,
GitStateProbe,
GitWorkspaceManager,
INTERNAL_GIT_TIMEOUT_MS,
MutationScope,
WorkspaceSafetyError,
WorkspaceValidationError,
type CommandResult,
type CommandRunner,
type CommandRunOptions,
} from "./workspace.ts";
const execFileAsync = promisify(execFile);
@@ -80,16 +82,25 @@ async function withTempDir(run: (directory: string) => Promise<void>) {
}
class FakeRunner implements CommandRunner {
readonly calls: Array<{ cwd: string; executable: string; args: string[] }> =
[];
readonly calls: Array<{
cwd: string;
executable: string;
args: string[];
options?: CommandRunOptions;
}> = [];
private readonly respond: (args: string[]) => CommandResult;
constructor(respond: (args: string[]) => CommandResult) {
this.respond = respond;
}
async run(cwd: string, executable: string, args: string[]) {
this.calls.push({ cwd, executable, args: [...args] });
async run(
cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
) {
this.calls.push({ cwd, executable, args: [...args], options });
return this.respond(args);
}
}
@@ -100,14 +111,23 @@ function result(code = 0, stdout = "", stderr = ""): CommandResult {
function prepareResponses(
repository: string,
options: { dirty?: boolean; ignored?: boolean; branchExists?: boolean } = {},
options: {
dirty?: boolean;
unmerged?: boolean;
ignored?: boolean;
branchExists?: boolean;
} = {},
) {
return (args: string[]) => {
const command = args.join(" ");
if (command === "rev-parse --show-toplevel")
return result(0, `${repository}\n`);
if (command === "status --porcelain --untracked-files=normal") {
return result(0, options.dirty ? " M src/file.ts\n" : "");
if (command === "status --porcelain=v1 -z --untracked-files=all") {
if (options.unmerged) return result(0, "UU src/conflict.ts\0");
return result(
0,
options.dirty ? " M src/file.ts\0?? src/new-file.ts\0" : "",
);
}
if (command.startsWith("check-ignore ")) {
return result(options.ignored === false ? 1 : 0);
@@ -481,6 +501,59 @@ test("Git probe and approved host commands enforce identity and ownership", asyn
});
});
test("command runner reports bounded termination failure as exit 125", async () => {
await withTempDir(async (directory) => {
const runner = new ExecFileCommandRunner({
async terminateProcessTree(processId) {
if (process.platform !== "win32") {
process.kill(-processId, "SIGKILL");
} else {
process.kill(processId, "SIGKILL");
}
throw new Error("injected termination failure");
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /injected termination failure/);
assert.ok(Date.now() - started < 2_000);
});
});
test("command runner force-kills a child when the termination helper never settles", async () => {
await withTempDir(async (directory) => {
let childProcessId: number | undefined;
const runner = new ExecFileCommandRunner({
terminateProcessTree(processId) {
childProcessId = processId;
return new Promise<void>(() => {});
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /termination did not finish within its deadline/);
assert.ok(Date.now() - started < 2_000);
const terminatedProcessId = childProcessId;
assert.ok(terminatedProcessId);
assert.throws(
() => process.kill(terminatedProcessId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
});
});
test("Git change detection excludes unchanged pre-existing dirty paths", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
@@ -557,6 +630,11 @@ test("workspace preparation validates Git state and builds the exact feature bra
"abc123",
]);
assert.ok(runner.calls.every((call) => call.executable === "git"));
assert.ok(
runner.calls.every(
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
),
);
});
});
@@ -602,7 +680,7 @@ test("real Git creation and reconciliation preserve branch and HEAD identity", a
});
});
test("dirty sources and unignored worktree roots fail before creation", async () => {
test("dirty sources require approval with bounded evidence while conflicts remain blocked", async () => {
await withTempDir(async (repository) => {
const dirty = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { dirty: true })),
@@ -614,9 +692,42 @@ test("dirty sources and unignored worktree roots fail before creation", async ()
featureName: "feature",
runId: "run-1",
}),
(error) => {
assert.ok(error instanceof WorkspaceValidationError);
assert.equal(error.code, "dirty-source");
assert.deepEqual(error.sourceChanges, [
{ path: "src/file.ts", status: " M" },
{ path: "src/new-file.ts", status: "??" },
]);
assert.equal(error.sourceChangesOmitted, 0);
return true;
},
);
const allowed = await dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
});
assert.equal(allowed.baseHead, "abc123");
assert.equal(allowed.dirtySourceAllowed, true);
assert.equal(allowed.sourceChanges.length, 2);
const unmerged = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { unmerged: true })),
);
await assert.rejects(
unmerged.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "dirty-source",
error.code === "unmerged-source",
);
const unignored = new GitWorkspaceManager(
@@ -743,6 +854,9 @@ test("uncertain worktree-add results are reconciled before returning", async ()
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const partial = new GitWorkspaceManager(
@@ -786,6 +900,9 @@ test("retry adopts an exact orphan branch left by interrupted creation", async (
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
assert.equal((await manager.create(plan)).state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [
+172 -24
View File
@@ -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,
});
}
}