4368 lines
146 KiB
TypeScript
4368 lines
146 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import test from "node:test";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
DEFAULT_WORKFLOW_CONFIG,
|
|
type AgentProfile,
|
|
type GateDecision,
|
|
type GateKind,
|
|
type WorkflowAssignment,
|
|
type WorkflowRun,
|
|
} from "./domain.ts";
|
|
import {
|
|
decisionIsLegal,
|
|
WorkflowEngine,
|
|
type AssignmentExecutor,
|
|
type AssignmentRequest,
|
|
type HostCommandExecutor,
|
|
type StartWorkflowCommand,
|
|
type WorkflowCommand,
|
|
type WorkflowView,
|
|
type WorkspaceManager,
|
|
} from "./engine.ts";
|
|
import { loadAgentProfiles } from "./profiles.ts";
|
|
import { runScriptSandbox } from "./script-runtime.ts";
|
|
import { WorkflowStore } from "./store.ts";
|
|
import {
|
|
GitWorkspaceManager,
|
|
WorkspaceSafetyError,
|
|
type PrepareWorkspaceOptions,
|
|
type WorkspacePlan,
|
|
} from "./workspace.ts";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
async function withTempDir(run: (directory: string) => Promise<void>) {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-engine-"));
|
|
try {
|
|
await run(directory);
|
|
} finally {
|
|
await rm(directory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10,
|
|
retryDelay: 20,
|
|
});
|
|
}
|
|
}
|
|
|
|
class ScriptedExecutor implements AssignmentExecutor {
|
|
readonly requests: AssignmentRequest[] = [];
|
|
readonly outputs = new Map<WorkflowAssignment["role"], unknown[]>();
|
|
|
|
enqueue(role: WorkflowAssignment["role"], output: unknown) {
|
|
const queued = this.outputs.get(role) ?? [];
|
|
queued.push(output);
|
|
this.outputs.set(role, queued);
|
|
}
|
|
|
|
async run(request: AssignmentRequest) {
|
|
this.requests.push(structuredClone(request));
|
|
const queued = this.outputs.get(request.role) ?? [];
|
|
if (queued.length === 0)
|
|
throw new Error(`No scripted ${request.role} result`);
|
|
const output = queued.shift();
|
|
if (output instanceof Error) throw output;
|
|
return structuredClone(output);
|
|
}
|
|
}
|
|
|
|
class BlockingExecutor implements AssignmentExecutor {
|
|
readonly requests: AssignmentRequest[] = [];
|
|
readonly started: Promise<void>;
|
|
private readonly output: unknown;
|
|
private releaseRun?: () => void;
|
|
private markStarted?: () => void;
|
|
|
|
constructor(output: unknown) {
|
|
this.output = output;
|
|
this.started = new Promise((resolve) => {
|
|
this.markStarted = resolve;
|
|
});
|
|
}
|
|
|
|
async run(request: AssignmentRequest) {
|
|
this.requests.push(request);
|
|
this.markStarted?.();
|
|
await new Promise<void>((resolve) => {
|
|
this.releaseRun = resolve;
|
|
});
|
|
return structuredClone(this.output);
|
|
}
|
|
|
|
release() {
|
|
this.releaseRun?.();
|
|
}
|
|
}
|
|
|
|
class FakeWorkspace implements WorkspaceManager {
|
|
prepareCalls = 0;
|
|
createCalls = 0;
|
|
reconcileCalls = 0;
|
|
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),
|
|
worktreePath: path.join(
|
|
options.sourceRoot,
|
|
options.configuredRoot,
|
|
`pi-workflow-${options.featureName}-${options.runId}`,
|
|
),
|
|
branch: `feature/${options.featureName}`,
|
|
baseHead: "abc123",
|
|
branchExists: this.branchExists,
|
|
sourceChanges: [],
|
|
sourceChangesOmitted: 0,
|
|
dirtySourceAllowed: false,
|
|
};
|
|
}
|
|
|
|
async create(plan: WorkspacePlan) {
|
|
this.createCalls++;
|
|
await this.beforeCreate?.(plan);
|
|
if (this.createError) throw this.createError;
|
|
await mkdir(plan.worktreePath, { recursive: true });
|
|
return {
|
|
state: "ready" as const,
|
|
sourceRoot: plan.sourceRoot,
|
|
root: plan.worktreePath,
|
|
branch: plan.branch,
|
|
baseHead: plan.baseHead,
|
|
};
|
|
}
|
|
|
|
async reconcile(workspace: WorkflowRun["workspace"]) {
|
|
this.reconcileCalls++;
|
|
return {
|
|
...workspace,
|
|
state: this.reconcileState,
|
|
...(this.reconcileState === "failed"
|
|
? { error: "reconciliation failed" }
|
|
: { error: undefined }),
|
|
};
|
|
}
|
|
}
|
|
|
|
interface Fixture {
|
|
store: WorkflowStore;
|
|
workspace: FakeWorkspace;
|
|
executor: ScriptedExecutor;
|
|
profiles: AgentProfile[];
|
|
engine(
|
|
sessionId?: string,
|
|
overrides?: Partial<ConstructorParameters<typeof WorkflowEngine>[0]>,
|
|
): WorkflowEngine;
|
|
}
|
|
|
|
async function fixture(
|
|
directory: string,
|
|
storeOptions: ConstructorParameters<typeof WorkflowStore>[1] = {},
|
|
): Promise<Fixture> {
|
|
const store = new WorkflowStore(path.join(directory, "state"), storeOptions);
|
|
const workspace = new FakeWorkspace();
|
|
const executor = new ScriptedExecutor();
|
|
const profiles = (
|
|
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
|
).profiles;
|
|
let id = 0;
|
|
const createId = () => `id-${++id}`;
|
|
return {
|
|
store,
|
|
workspace,
|
|
executor,
|
|
profiles,
|
|
engine(sessionId = "session-1", overrides = {}) {
|
|
return new WorkflowEngine({
|
|
store,
|
|
workspace,
|
|
executor,
|
|
hostCommands: {
|
|
async run() {
|
|
return { result: { code: 0, stdout: "passed", stderr: "" } };
|
|
},
|
|
},
|
|
reviewDiff: {
|
|
async read() {
|
|
return "diff --git a/src/index.ts b/src/index.ts";
|
|
},
|
|
},
|
|
prototypeValidator: {
|
|
async validateCandidate({ output }) {
|
|
return {
|
|
artifactFiles: [],
|
|
visualDigest: createHash("sha256")
|
|
.update(output.path)
|
|
.digest("hex"),
|
|
};
|
|
},
|
|
async validateCleanup() {},
|
|
},
|
|
sourceRoot: directory,
|
|
parentSessionId: sessionId,
|
|
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
profiles,
|
|
now: () => 10_000,
|
|
createId,
|
|
processId: 100,
|
|
isProcessAlive: () => true,
|
|
...overrides,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function startCommand(
|
|
overrides: Partial<Omit<StartWorkflowCommand, "action">> = {},
|
|
): StartWorkflowCommand {
|
|
return {
|
|
action: "start" as const,
|
|
request: "Build a searchable dashboard",
|
|
featureName: "searchable-dashboard",
|
|
idempotencyKey: "start-key",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function discoveryWithQuestions() {
|
|
return {
|
|
questions: [{ id: "data-source", prompt: "Which data source?" }],
|
|
candidatePaths: ["src/routes/dashboard.ts"],
|
|
languages: ["typescript"],
|
|
tags: ["frontend", "ui"],
|
|
riskTags: ["migration"],
|
|
};
|
|
}
|
|
|
|
function enqueueSuccessfulPipeline(setup: Fixture) {
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Implemented the approved plan",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["feature behavior"],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Focused behavior verified",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
}
|
|
|
|
function design(uiImpact: "none" | "minor" | "non-trivial" = "minor") {
|
|
return {
|
|
plan: "# Approved plan\n\nImplement the dashboard.",
|
|
uiImpact,
|
|
uiImpactEvidence: ["Planner classified the dashboard change"],
|
|
affectedUiSurfaces: ["Dashboard"],
|
|
acceptanceCriteria: ["Changed behavior has focused tests"],
|
|
verificationCommands: [
|
|
{
|
|
executable: "npm",
|
|
args: ["test"],
|
|
ownedPaths: [],
|
|
kind: "test",
|
|
},
|
|
],
|
|
assignments: [
|
|
{
|
|
role: "implement",
|
|
profileName: "feature-implementer",
|
|
rationale: "Implement the approved plan",
|
|
mutates: true,
|
|
ownedPaths: ["src"],
|
|
},
|
|
{
|
|
role: "verify",
|
|
profileName: "feature-verifier",
|
|
rationale: "Verify the implementation",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
{
|
|
role: "review",
|
|
profileName: "independent-reviewer",
|
|
rationale: "Review independently",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
...(uiImpact === "non-trivial"
|
|
? [
|
|
{
|
|
role: "prototype",
|
|
profileName: "ui-prototyper",
|
|
rationale: "Prototype the non-trivial UI",
|
|
mutates: true,
|
|
ownedPaths: ["prototypes"],
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
}
|
|
|
|
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);
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const engine = setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
});
|
|
const command = {
|
|
action: "start-analysis" as const,
|
|
request: "Audit persistence behavior",
|
|
source: `
|
|
stage("Persistence audit");
|
|
const result = await assign("review", "Inspect persistence behavior");
|
|
return result.structured;
|
|
`,
|
|
args: { scope: "store" },
|
|
activeToolNames: ["read", "grep", "bash"],
|
|
idempotencyKey: "analysis-script-key",
|
|
};
|
|
const completed = await engine.execute(command);
|
|
assert.equal(completed.status, "completed");
|
|
assert.equal(completed.featureName, undefined);
|
|
assert.equal(completed.request, "Audit persistence behavior");
|
|
assert.equal(setup.workspace.prepareCalls, 0);
|
|
const persisted = await setup.store.loadRun(completed.runId);
|
|
assert.equal(persisted.mode, "analysis");
|
|
assert.equal(persisted.script?.status, "completed");
|
|
assert.deepEqual(persisted.script?.stages, ["Persistence audit"]);
|
|
assert.equal(persisted.assignments.length, 1);
|
|
assert.equal(persisted.assignments[0]?.mutates, false);
|
|
assert.deepEqual(setup.executor.requests[0]?.allowedReadTools, ["read", "grep"]);
|
|
assert.deepEqual(await engine.execute(command), completed);
|
|
assert.equal(setup.executor.requests.length, 1);
|
|
});
|
|
});
|
|
|
|
test("script Recovery reuses completed request IDs without orphaning interrupted work", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const engine = setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
});
|
|
const completed = await engine.execute({
|
|
action: "start-analysis",
|
|
request: "Audit recovery",
|
|
source: `return await assign("review", "First request");`,
|
|
activeToolNames: ["read"],
|
|
idempotencyKey: "analysis-recovery-start",
|
|
});
|
|
const interrupted = await setup.store.loadRun(completed.runId);
|
|
assert.ok(interrupted.script);
|
|
interrupted.script.source = `
|
|
const first = await assign("review", "First request");
|
|
const second = await assign("review", "Second request");
|
|
return [first, second];
|
|
`;
|
|
interrupted.script.status = "running";
|
|
interrupted.script.executed = false;
|
|
interrupted.status = "waiting";
|
|
const firstAssignment = interrupted.assignments[0]!;
|
|
interrupted.assignments.push({
|
|
...structuredClone(firstAssignment),
|
|
id: "interrupted-script-request",
|
|
status: "interrupted",
|
|
result: undefined,
|
|
scriptRequestId: 2,
|
|
scriptRequestFingerprint: "interrupted",
|
|
});
|
|
const recoveryGateId = "script-recovery-gate";
|
|
interrupted.gates.push({
|
|
id: recoveryGateId,
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: interrupted.revision + 1,
|
|
openedAt: 10_000,
|
|
payload: { interruptedAssignmentIds: ["interrupted-script-request"] },
|
|
});
|
|
interrupted.revision++;
|
|
await setup.store.saveRun(interrupted, interrupted.revision - 1);
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const recovered = await engine.execute({
|
|
action: "decide",
|
|
runId: interrupted.runId,
|
|
gateId: recoveryGateId,
|
|
expectedRevision: interrupted.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "retry-analysis-script",
|
|
});
|
|
assert.equal(recovered.status, "completed", recovered.error?.message);
|
|
assert.equal(setup.executor.requests.length, 2);
|
|
const persisted = await setup.store.loadRun(recovered.runId);
|
|
assert.equal(
|
|
persisted.assignments.find(
|
|
(assignment) => assignment.id === "interrupted-script-request",
|
|
)?.status,
|
|
"interrupted",
|
|
);
|
|
assert.equal(
|
|
persisted.assignments.filter(
|
|
(assignment) => assignment.scriptRequestId === 1,
|
|
).length,
|
|
1,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("script abort waits for active Assignments and fails closed on timeout", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const blocking = new BlockingExecutor({
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const engine = setup.engine("session-1", {
|
|
executor: blocking,
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
assignmentAbortTimeoutMs: 10,
|
|
});
|
|
const start = engine.execute({
|
|
action: "start-analysis",
|
|
request: "Audit cancellation",
|
|
source: `return await assign("review", "Block until released");`,
|
|
activeToolNames: ["read"],
|
|
idempotencyKey: "blocking-analysis",
|
|
});
|
|
await blocking.started;
|
|
const [running] = await setup.store.listRuns();
|
|
assert.ok(running);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "abort",
|
|
runId: running.runId,
|
|
expectedRevision: running.revision,
|
|
idempotencyKey: "abort-blocking-analysis",
|
|
}),
|
|
/Timed out stopping active script/,
|
|
);
|
|
assert.notEqual((await setup.store.loadRun(running.runId)).status, "aborted");
|
|
blocking.release();
|
|
const failed = await start;
|
|
assert.equal(failed.status, "failed");
|
|
assert.match(failed.error?.message ?? "", /aborted/);
|
|
});
|
|
});
|
|
|
|
test("feature scripts are insertion-bound and cannot bypass Plan Approval", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
const planned = await setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
}).execute(
|
|
startCommand({
|
|
script: {
|
|
source: `
|
|
stage("Pre-design audit");
|
|
return await assign("review", "Inspect design precedents");
|
|
`,
|
|
insertionPoint: "before-design",
|
|
activeToolNames: ["read", "grep", "find", "ls", "bash"],
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(planned.status, "waiting", planned.error?.message);
|
|
assert.equal(planned.openGate?.kind, "plan-approval");
|
|
assert.deepEqual(
|
|
setup.executor.requests.map((request) => request.role),
|
|
["discover", "review", "design"],
|
|
);
|
|
const persisted = await setup.store.loadRun(planned.runId);
|
|
assert.equal(persisted.script?.executed, true);
|
|
assert.equal(
|
|
persisted.assignments
|
|
.filter((assignment) =>
|
|
assignment.selection.rationale.startsWith("Custom read-only script"),
|
|
)
|
|
.some((assignment) => assignment.mutates),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("after-design feature scripts still leave Plan Approval mandatory", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const planned = await setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
}).execute(
|
|
startCommand({
|
|
script: {
|
|
source: `return await assign("review", "Audit the completed design");`,
|
|
insertionPoint: "after-design",
|
|
activeToolNames: ["read", "grep", "find", "ls"],
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(planned.status, "waiting");
|
|
assert.equal(planned.openGate?.kind, "plan-approval");
|
|
assert.deepEqual(
|
|
setup.executor.requests.map((request) => request.role),
|
|
["discover", "design", "review"],
|
|
);
|
|
});
|
|
});
|
|
|
|
test("before-review feature scripts cannot replace mandatory verification or review", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Implemented feature",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["feature behavior"],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Verified feature",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const engine = setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
});
|
|
const planned = await engine.execute(
|
|
startCommand({
|
|
script: {
|
|
source: `return await assign("review", "Perform pre-review analysis");`,
|
|
insertionPoint: "before-review",
|
|
activeToolNames: ["read", "grep", "find", "ls"],
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(planned.runId)).script?.executed,
|
|
false,
|
|
);
|
|
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-before-review-script",
|
|
});
|
|
const completed = await engine.execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: approved.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "context-before-review-script",
|
|
});
|
|
assert.equal(completed.status, "completed");
|
|
const roles = setup.executor.requests.map((request) => request.role);
|
|
assert.deepEqual(roles, [
|
|
"discover",
|
|
"design",
|
|
"implement",
|
|
"verify",
|
|
"review",
|
|
"review",
|
|
]);
|
|
assert.equal(
|
|
(await setup.store.loadRun(completed.runId)).script?.executed,
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("feature scripts reject mutating roles and invalid insertion points", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
const failed = await setup.engine("session-1", {
|
|
scriptRuntime: { run: runScriptSandbox },
|
|
}).execute(
|
|
startCommand({
|
|
script: {
|
|
source: `return await assign("implement", "Mutate before approval");`,
|
|
insertionPoint: "before-design",
|
|
activeToolNames: ["read"],
|
|
},
|
|
}),
|
|
);
|
|
assert.equal(failed.status, "failed");
|
|
assert.match(failed.error?.message ?? "", /not read-only or supported/);
|
|
await assert.rejects(
|
|
setup.engine().execute({
|
|
...startCommand({ idempotencyKey: "invalid-insertion" }),
|
|
script: {
|
|
source: "return null",
|
|
insertionPoint: "during-implementation",
|
|
activeToolNames: ["read"],
|
|
},
|
|
} as unknown as WorkflowCommand),
|
|
/insertionPoint is invalid/,
|
|
);
|
|
assert.equal(setup.workspace.prepareCalls, 1);
|
|
});
|
|
});
|
|
|
|
test("background feature starts return after durable publication and continue to a Gate", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const blocking = new BlockingExecutor(discoveryWithQuestions());
|
|
const persistedDeliveries: string[] = [];
|
|
const engine = setup.engine("session-1", {
|
|
executor: blocking,
|
|
afterRunPersist(run) {
|
|
persistedDeliveries.push(
|
|
...run.deliveries
|
|
.filter((delivery) => delivery.state === "pending")
|
|
.map((delivery) => delivery.key),
|
|
);
|
|
},
|
|
});
|
|
const command = startCommand({
|
|
background: true,
|
|
idempotencyKey: "background-start",
|
|
});
|
|
const published = await engine.execute(command);
|
|
assert.equal(published.background, true);
|
|
assert.equal(published.workspace.state, "creating");
|
|
await blocking.started;
|
|
blocking.release();
|
|
let completed = published;
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
completed = await engine.execute({ action: "inspect", runId: published.runId });
|
|
if (completed.openGate?.kind === "questions") break;
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
}
|
|
assert.equal(completed.openGate?.kind, "questions");
|
|
assert.equal(completed.status, "waiting");
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
if ((await setup.store.getStart(command.idempotencyKey))?.status === "completed") {
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
}
|
|
assert.equal(
|
|
(await setup.store.getStart(command.idempotencyKey))?.status,
|
|
"completed",
|
|
);
|
|
assert.equal(
|
|
persistedDeliveries.some((key) => key.startsWith("gate:")),
|
|
true,
|
|
);
|
|
assert.deepEqual(await engine.execute(command), completed);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
});
|
|
|
|
test("run concurrency may lower but never raise the configured ceiling", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const started = await setup.engine().execute(
|
|
startCommand({ concurrency: 2, idempotencyKey: "concurrency-two" }),
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(started.runId)).effectiveConfig.concurrency.default,
|
|
2,
|
|
);
|
|
assert.equal(setup.executor.requests[0]?.runConcurrency, 2);
|
|
await assert.rejects(
|
|
setup.engine().execute(
|
|
startCommand({
|
|
concurrency: DEFAULT_WORKFLOW_CONFIG.concurrency.ceiling + 1,
|
|
idempotencyKey: "concurrency-too-high",
|
|
featureName: "other-feature",
|
|
}),
|
|
),
|
|
/cannot exceed ceiling/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("Questions and Plan Approval persist and resume across engine instances", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.workspace.beforeCreate = async () => {
|
|
const [persisted] = await setup.store.listRuns();
|
|
assert.equal(persisted?.workspace.state, "creating");
|
|
assert.equal(persisted?.lease?.activeParentSessionId, "session-1");
|
|
assert.equal(persisted?.assignments.length, 0);
|
|
};
|
|
|
|
const started = await setup.engine().execute(startCommand());
|
|
assert.equal(started.status, "waiting");
|
|
assert.equal(started.openGate?.kind, "questions");
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
|
|
const answered = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision,
|
|
decision: {
|
|
kind: "answer-questions",
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "answer-key",
|
|
});
|
|
assert.equal(answered.status, "waiting");
|
|
assert.equal(answered.openGate?.kind, "plan-approval");
|
|
assert.equal(answered.uiImpact, "minor");
|
|
assert.match(
|
|
await readFile(answered.planArtifact?.path ?? "missing", "utf8"),
|
|
/Implement the dashboard/,
|
|
);
|
|
const designRequest = setup.executor.requests.find(
|
|
(request) => request.role === "design",
|
|
);
|
|
assert.equal(designRequest?.decisions[0]?.value.kind, "answer-questions");
|
|
|
|
const inspected = await setup.engine().execute({
|
|
action: "inspect",
|
|
runId: answered.runId,
|
|
});
|
|
assert.deepEqual(inspected, answered);
|
|
|
|
const approved = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: answered.runId,
|
|
gateId: answered.openGate?.id ?? "missing",
|
|
expectedRevision: answered.revision,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "minor",
|
|
},
|
|
idempotencyKey: "approve-key",
|
|
});
|
|
assert.equal(approved.status, "waiting");
|
|
assert.equal(approved.openGate?.kind, "implementation-context");
|
|
assert.equal(approved.implementationContext?.status, "waiting");
|
|
assert.equal(
|
|
approved.stages.find((stage) => stage.kind === "design")?.status,
|
|
"completed",
|
|
);
|
|
const approvedPlan = await readFile(
|
|
approved.planArtifact?.path ?? "missing",
|
|
"utf8",
|
|
);
|
|
assert.match(approvedPlan, /Accepted Decisions/);
|
|
assert.match(approvedPlan, /Worktree/);
|
|
assert.match(approvedPlan, /UI Impact Evidence/);
|
|
assert.match(approvedPlan, /acceptedUiImpact/);
|
|
const persisted = await setup.store.loadRun(approved.runId);
|
|
assert.equal(persisted.decisions.length, 2);
|
|
assert.equal(persisted.deliveries.length, 3);
|
|
assert.deepEqual(
|
|
persisted.preflightEligibility.map((entry) => entry.role),
|
|
["discover", "design", "implement", "verify", "review"],
|
|
);
|
|
assert.ok(
|
|
persisted.preflightEligibility.every((entry) =>
|
|
entry.candidates.some((candidate) => candidate.eligible),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
test("Implementation Context choices are persisted and Gate-bound", async () => {
|
|
for (const strategy of ["fresh-session", "current-session"] as const) {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
if (strategy === "current-session") enqueueSuccessfulPipeline(setup);
|
|
const planned = await setup.engine().execute(startCommand());
|
|
const contextGate = 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-${strategy}`,
|
|
});
|
|
const selected = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: contextGate.runId,
|
|
gateId: contextGate.openGate?.id ?? "missing",
|
|
expectedRevision: contextGate.revision,
|
|
decision: { kind: "select-implementation-context", strategy },
|
|
idempotencyKey: `context-${strategy}`,
|
|
});
|
|
if (strategy === "fresh-session") {
|
|
assert.equal(selected.status, "waiting");
|
|
assert.equal(selected.openGate?.kind, "implementation-context");
|
|
assert.equal(selected.implementationContext?.status, "handoff-pending");
|
|
} else {
|
|
assert.equal(selected.status, "completed");
|
|
assert.equal(selected.openGate, undefined);
|
|
assert.equal(selected.implementationContext?.status, "ready");
|
|
assert.equal(selected.implementationContext?.method, "unchanged");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test("Implementation Context rejects transitions while a child Assignment is active", 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-active-context",
|
|
});
|
|
const persisted = await setup.store.loadRun(approved.runId);
|
|
const queued = persisted.assignments.find(
|
|
(assignment) => assignment.role === "implement",
|
|
);
|
|
assert.ok(queued);
|
|
queued.status = "running";
|
|
queued.executionToken = "active-context-token";
|
|
persisted.revision++;
|
|
await setup.store.saveRun(persisted, persisted.revision - 1);
|
|
await assert.rejects(
|
|
setup.engine().execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: persisted.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "select-active-context",
|
|
}),
|
|
/while an Assignment is active/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("fresh-session handoff is revision-bound and rotates the active parent only on consume", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
enqueueSuccessfulPipeline(setup);
|
|
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-handoff",
|
|
});
|
|
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-handoff",
|
|
});
|
|
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);
|
|
const claimed = await 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",
|
|
});
|
|
assert.equal(
|
|
claimed.implementationContext?.status,
|
|
"claimed-target-awaiting-withSession",
|
|
);
|
|
const claim = claimed.implementationContext;
|
|
assert.ok(claim);
|
|
await assert.rejects(
|
|
setup.engine("unrelated-session", {
|
|
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: "startup",
|
|
}),
|
|
/owned by live process 100/,
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(claimed.runId)).implementationContext?.status,
|
|
"claimed-target-awaiting-withSession",
|
|
);
|
|
const consumed = await setup.engine("session-2").execute({
|
|
action: "consume-context-handoff",
|
|
runId: claimed.runId,
|
|
gateId: claim.gateId,
|
|
preparationId: claim.preparationId,
|
|
planRevision: claim.planRevision,
|
|
contextRevision: claim.runRevision,
|
|
expectedRevision: claimed.revision,
|
|
targetSessionFile: "/sessions/target.jsonl",
|
|
previousSessionFile: "/sessions/source.jsonl",
|
|
});
|
|
assert.equal(consumed.implementationContext?.status, "ready");
|
|
assert.equal(consumed.implementationContext?.activeParentSessionId, "session-2");
|
|
assert.equal(consumed.openGate, undefined);
|
|
await assert.rejects(
|
|
setup.engine("session-1").execute({
|
|
action: "abort",
|
|
runId: consumed.runId,
|
|
expectedRevision: consumed.revision,
|
|
idempotencyKey: "historical-abort",
|
|
}),
|
|
/Historical parent sessions/,
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
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-smart",
|
|
});
|
|
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: "smart-compact",
|
|
},
|
|
idempotencyKey: "select-smart",
|
|
});
|
|
const preparation = selected.implementationContext;
|
|
assert.equal(preparation?.status, "smart-compact-pending");
|
|
const unavailable = {
|
|
action: "decide" as const,
|
|
runId: selected.runId,
|
|
gateId: selected.openGate?.id ?? "missing",
|
|
expectedRevision: selected.revision,
|
|
decision: {
|
|
kind: "report-context-preparation" as const,
|
|
preparationId: preparation?.preparationId ?? "missing",
|
|
planRevision: preparation?.planRevision ?? -1,
|
|
method: "smart_compact" as const,
|
|
outcome: "unavailable" as const,
|
|
},
|
|
idempotencyKey: "smart-unavailable",
|
|
};
|
|
await assert.rejects(setup.engine().execute(unavailable), /activeToolNames/);
|
|
await assert.rejects(
|
|
setup.engine().execute({
|
|
...unavailable,
|
|
activeToolNames: ["smart_compact"],
|
|
}),
|
|
/smart_compact is active/,
|
|
);
|
|
const fallback = await setup.engine().execute({
|
|
...unavailable,
|
|
activeToolNames: ["read", "grep"],
|
|
});
|
|
assert.equal(fallback.status, "waiting");
|
|
assert.equal(fallback.openGate?.kind, "implementation-context");
|
|
assert.equal(fallback.implementationContext?.status, "normal-compact-pending");
|
|
assert.equal(fallback.implementationContext?.outcome, "unavailable");
|
|
const fallbackIntent = fallback.implementationContext;
|
|
assert.ok(fallbackIntent);
|
|
const compacting = await setup.engine().execute({
|
|
action: "continue-context",
|
|
runId: fallback.runId,
|
|
gateId: fallbackIntent.gateId,
|
|
preparationId: fallbackIntent.preparationId,
|
|
planRevision: fallbackIntent.planRevision,
|
|
contextRevision: fallbackIntent.runRevision,
|
|
expectedRevision: fallback.revision,
|
|
});
|
|
assert.equal(compacting.implementationContext?.status, "normal-compact-running");
|
|
const compactIntent = compacting.implementationContext;
|
|
assert.ok(compactIntent);
|
|
const failed = await setup.engine().execute({
|
|
action: "complete-normal-compaction",
|
|
runId: compacting.runId,
|
|
gateId: compactIntent.gateId,
|
|
preparationId: compactIntent.preparationId,
|
|
planRevision: compactIntent.planRevision,
|
|
contextRevision: compactIntent.runRevision,
|
|
expectedRevision: compacting.revision,
|
|
outcome: "failed",
|
|
error: "Native compaction failed",
|
|
});
|
|
assert.equal(failed.status, "waiting");
|
|
assert.equal(failed.openGate?.kind, "implementation-context");
|
|
assert.equal(failed.implementationContext?.status, "failed");
|
|
assert.equal(failed.implementationContext?.error, "Native compaction failed");
|
|
});
|
|
});
|
|
|
|
test("recovery marks an interrupted normal compaction intent failed", 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-interrupted-compact",
|
|
});
|
|
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: "smart-compact",
|
|
},
|
|
idempotencyKey: "select-interrupted-compact",
|
|
});
|
|
const intent = selected.implementationContext;
|
|
assert.ok(intent);
|
|
const fallback = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: selected.runId,
|
|
gateId: intent.gateId,
|
|
expectedRevision: selected.revision,
|
|
decision: {
|
|
kind: "report-context-preparation",
|
|
preparationId: intent.preparationId,
|
|
planRevision: intent.planRevision,
|
|
method: "smart_compact",
|
|
outcome: "unavailable",
|
|
},
|
|
idempotencyKey: "unavailable-interrupted-compact",
|
|
activeToolNames: ["read"],
|
|
});
|
|
const fallbackIntent = fallback.implementationContext;
|
|
assert.ok(fallbackIntent);
|
|
await setup.engine().execute({
|
|
action: "continue-context",
|
|
runId: fallback.runId,
|
|
gateId: fallbackIntent.gateId,
|
|
preparationId: fallbackIntent.preparationId,
|
|
planRevision: fallbackIntent.planRevision,
|
|
contextRevision: fallbackIntent.runRevision,
|
|
expectedRevision: fallback.revision,
|
|
});
|
|
const recovered = await setup.engine("session-1", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => false,
|
|
}).recover();
|
|
const view = recovered.find((candidate) => candidate.runId === fallback.runId);
|
|
assert.equal(view?.implementationContext?.status, "failed");
|
|
assert.match(view?.implementationContext?.error ?? "", /interrupted/);
|
|
assert.equal(view?.openGate?.kind, "implementation-context");
|
|
});
|
|
});
|
|
|
|
test("smart compaction success, no-op, and failure outcomes remain distinct", async () => {
|
|
for (const outcome of ["succeeded", "no-op", "failed"] as const) {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
if (outcome !== "failed") enqueueSuccessfulPipeline(setup);
|
|
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-smart-${outcome}`,
|
|
});
|
|
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: "smart-compact",
|
|
},
|
|
idempotencyKey: `select-smart-${outcome}`,
|
|
});
|
|
const intent = selected.implementationContext;
|
|
assert.ok(intent);
|
|
const decision: GateDecision =
|
|
outcome === "failed"
|
|
? {
|
|
kind: "report-context-preparation",
|
|
preparationId: intent.preparationId,
|
|
planRevision: intent.planRevision,
|
|
method: "smart_compact",
|
|
outcome,
|
|
error: "smart_compact failed to prepare context",
|
|
}
|
|
: {
|
|
kind: "report-context-preparation",
|
|
preparationId: intent.preparationId,
|
|
planRevision: intent.planRevision,
|
|
method: "smart_compact",
|
|
outcome,
|
|
};
|
|
const report = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: selected.runId,
|
|
gateId: intent.gateId,
|
|
expectedRevision: selected.revision,
|
|
decision,
|
|
idempotencyKey: `report-smart-${outcome}`,
|
|
activeToolNames: ["smart_compact"],
|
|
});
|
|
assert.equal(report.implementationContext?.outcome, outcome);
|
|
assert.equal(report.implementationContext?.method, "smart_compact");
|
|
if (outcome === "failed") {
|
|
assert.equal(report.status, "waiting");
|
|
assert.equal(report.openGate?.kind, "implementation-context");
|
|
assert.match(report.implementationContext?.error ?? "", /failed/);
|
|
} else {
|
|
assert.equal(report.status, "completed");
|
|
assert.equal(report.openGate, undefined);
|
|
assert.equal(report.implementationContext?.status, "ready");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
test("implementation completes only after host verification and two-axis review", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", {
|
|
...design("minor"),
|
|
acceptanceCriteria: ["Search behavior works"],
|
|
verificationCommands: [
|
|
{
|
|
executable: "npm",
|
|
args: ["test", "--", "feature"],
|
|
ownedPaths: ["src"],
|
|
kind: "test",
|
|
},
|
|
],
|
|
});
|
|
enqueueSuccessfulPipeline(setup);
|
|
const hostCalls: string[] = [];
|
|
const hostCommands: HostCommandExecutor = {
|
|
async run(options) {
|
|
hostCalls.push(`${options.executable} ${options.args.join(" ")}`);
|
|
return { result: { code: 0, stdout: "passed", stderr: "" } };
|
|
},
|
|
};
|
|
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-lifecycle",
|
|
});
|
|
const completed = await setup.engine("session-1", { hostCommands }).execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: approved.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "context-lifecycle",
|
|
});
|
|
assert.equal(completed.status, "completed");
|
|
assert.deepEqual(hostCalls, ["npm test -- feature"]);
|
|
const persisted = await setup.store.loadRun(completed.runId);
|
|
assert.equal(persisted.verificationEvidence?.commands[0]?.status, "succeeded");
|
|
assert.equal(persisted.verificationEvidence?.focusedBehavioralTests, true);
|
|
assert.equal(persisted.reviewVerdict?.specification.verdict, "approved");
|
|
assert.equal(persisted.reviewVerdict?.standards.verdict, "approved");
|
|
assert.deepEqual(persisted.mutatorProfileNames, ["feature-implementer"]);
|
|
assert.ok(persisted.reportArtifact);
|
|
assert.match(
|
|
await readFile(persisted.reportArtifact.path, "utf8"),
|
|
/Status: completed[\s\S]*Verification[\s\S]*Review/,
|
|
);
|
|
assert.equal(
|
|
persisted.deliveries.some((delivery) =>
|
|
delivery.key.startsWith(`terminal:${persisted.runId}:completed:`),
|
|
),
|
|
true,
|
|
);
|
|
const result = JSON.parse(
|
|
await readFile(
|
|
path.join(path.dirname(path.dirname(persisted.reportArtifact.path)), "result.json"),
|
|
"utf8",
|
|
),
|
|
) as { status?: string; revision?: number; snapshotRevision?: number };
|
|
assert.equal(result.status, "completed");
|
|
assert.equal(result.revision, persisted.reportArtifact.revision);
|
|
assert.equal(result.snapshotRevision, persisted.revision - 1);
|
|
const verifierRequest = setup.executor.requests.find(
|
|
(request) => request.role === "verify",
|
|
);
|
|
const reviewerRequest = setup.executor.requests.find(
|
|
(request) => request.role === "review",
|
|
);
|
|
assert.match(verifierRequest?.approvedPlan ?? "", /Acceptance Criteria/);
|
|
assert.equal(
|
|
verifierRequest?.verificationEvidence?.commands[0]?.status,
|
|
"succeeded",
|
|
);
|
|
assert.match(reviewerRequest?.reviewDiff ?? "", /diff --git/);
|
|
assert.equal(
|
|
reviewerRequest?.verificationEvidence?.focusedBehavioralTests,
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("recovery consumes a persisted successful mutator without replay", 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-crash",
|
|
});
|
|
const crashed = await setup.store.loadRun(approved.runId);
|
|
for (const gate of crashed.gates) gate.status = "satisfied";
|
|
assert.ok(crashed.implementationContext);
|
|
crashed.implementationContext.status = "ready";
|
|
crashed.implementationContext.strategy = "current-session";
|
|
crashed.implementationContext.method = "unchanged";
|
|
crashed.status = "running";
|
|
const implement = crashed.assignments.find(
|
|
(assignment) => assignment.role === "implement",
|
|
);
|
|
assert.ok(implement);
|
|
implement.status = "succeeded";
|
|
implement.result = {
|
|
summary: "Mutation was durably completed before the crash",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["feature behavior"],
|
|
};
|
|
implement.executionToken = undefined;
|
|
const implementStage = crashed.stages.find(
|
|
(candidate) => candidate.kind === "implement",
|
|
);
|
|
assert.ok(implementStage);
|
|
implementStage.status = "running";
|
|
crashed.lease = {
|
|
processId: 999,
|
|
activeParentSessionId: "session-1",
|
|
heartbeatAt: 0,
|
|
worktreeIdentity: crashed.workspace.root,
|
|
};
|
|
crashed.revision++;
|
|
crashed.implementationContext.runRevision = crashed.revision;
|
|
await setup.store.saveRun(crashed, crashed.revision - 1);
|
|
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Recovered implementation verified",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const [recovered] = await setup.engine("session-1", {
|
|
processId: 200,
|
|
now: () => 100_000,
|
|
isProcessAlive: () => false,
|
|
}).recover();
|
|
assert.equal(recovered?.status, "completed");
|
|
assert.equal(
|
|
setup.executor.requests.filter((request) => request.role === "implement")
|
|
.length,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(crashed.runId)).assignments.find(
|
|
(assignment) => assignment.id === implement.id,
|
|
)?.status,
|
|
"succeeded",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("Git or ownership safety failures open Recovery before remediation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Implementation",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["behavior"],
|
|
});
|
|
const hostCommands: HostCommandExecutor = {
|
|
async run() {
|
|
throw new WorkspaceSafetyError("HEAD changed during verification");
|
|
},
|
|
};
|
|
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-safety-recovery",
|
|
});
|
|
const waiting = await setup.engine("session-1", { hostCommands }).execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: approved.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "context-safety-recovery",
|
|
});
|
|
assert.equal(waiting.status, "waiting");
|
|
assert.equal(waiting.openGate?.kind, "recovery");
|
|
assert.equal(
|
|
waiting.assignments.some((assignment) => assignment.role === "remediate"),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("Recovery retry reruns failed verification after the remediation limit", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
let commandRuns = 0;
|
|
let runId: string | undefined;
|
|
const hostCommands: HostCommandExecutor = {
|
|
async run() {
|
|
commandRuns++;
|
|
if (commandRuns === 2) {
|
|
assert.ok(runId);
|
|
const retrying = await setup.store.loadRun(runId);
|
|
const [command] = retrying.verificationEvidence?.commands ?? [];
|
|
assert.equal(command?.status, "running");
|
|
assert.equal(command?.stdout, undefined);
|
|
assert.equal(command?.stderr, undefined);
|
|
assert.equal(command?.exitCode, undefined);
|
|
assert.equal(command?.error, undefined);
|
|
}
|
|
return {
|
|
result: {
|
|
code: commandRuns === 1 ? 1 : 0,
|
|
stdout: commandRuns === 1 ? "failed" : "passed",
|
|
stderr: "",
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const effectiveConfig = {
|
|
...structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
maxReviewCycles: 1,
|
|
};
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Initial implementation",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["initial test"],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Corrected implementation verified",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
|
|
const planned = await setup.engine("session-1", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
}).execute(startCommand());
|
|
runId = planned.runId;
|
|
const approved = await setup.engine("session-1", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
}).execute({
|
|
action: "decide",
|
|
runId: planned.runId,
|
|
gateId: planned.openGate?.id ?? "missing",
|
|
expectedRevision: planned.revision,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "minor",
|
|
},
|
|
idempotencyKey: "approve-failed-verification-retry",
|
|
});
|
|
const waiting = await setup.engine("session-1", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
}).execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: approved.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "context-failed-verification-retry",
|
|
});
|
|
assert.equal(waiting.openGate?.kind, "recovery");
|
|
assert.equal(commandRuns, 1);
|
|
|
|
const transientRecovery = setup.engine("transient-session", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
processId: 200,
|
|
isProcessAlive: (processId) => processId === 100,
|
|
});
|
|
assert.deepEqual(await transientRecovery.recover(), []);
|
|
const untouched = await setup.store.loadRun(waiting.runId);
|
|
assert.equal(untouched.revision, waiting.revision);
|
|
assert.equal(untouched.lease?.activeParentSessionId, "session-1");
|
|
|
|
const liveStolen = structuredClone(untouched);
|
|
liveStolen.revision++;
|
|
liveStolen.parentSessionIds.push("transient-session");
|
|
liveStolen.verificationEvidence!.commands[0]!.error = "stale host exception";
|
|
const interruptedImplement = liveStolen.assignments.find(
|
|
(assignment) => assignment.role === "implement",
|
|
);
|
|
assert.ok(interruptedImplement);
|
|
interruptedImplement.status = "interrupted";
|
|
liveStolen.lease = {
|
|
processId: 200,
|
|
activeParentSessionId: "transient-session",
|
|
heartbeatAt: 0,
|
|
worktreeIdentity: liveStolen.workspace.root,
|
|
};
|
|
await setup.store.saveRun(liveStolen, untouched.revision);
|
|
|
|
await assert.rejects(
|
|
setup.engine("session-1", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
processId: 300,
|
|
isProcessAlive: (processId) => processId === 200,
|
|
}).execute({
|
|
action: "decide",
|
|
runId: waiting.runId,
|
|
gateId: waiting.openGate?.id ?? "missing",
|
|
expectedRevision: liveStolen.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "live-owner-retry-failed-verification",
|
|
}),
|
|
/Historical parent sessions/,
|
|
);
|
|
|
|
const staleStolen = structuredClone(liveStolen);
|
|
staleStolen.revision++;
|
|
staleStolen.lease!.processId = 201;
|
|
await setup.store.saveRun(staleStolen, liveStolen.revision);
|
|
|
|
const completed = await setup.engine("session-1", {
|
|
hostCommands,
|
|
effectiveConfig,
|
|
processId: 300,
|
|
isProcessAlive: () => false,
|
|
}).execute({
|
|
action: "decide",
|
|
runId: waiting.runId,
|
|
gateId: waiting.openGate?.id ?? "missing",
|
|
expectedRevision: staleStolen.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "retry-failed-verification",
|
|
});
|
|
|
|
assert.equal(completed.status, "completed");
|
|
assert.equal(commandRuns, 2);
|
|
const persisted = await setup.store.loadRun(completed.runId);
|
|
assert.equal(persisted.verificationEvidence?.commands[0]?.status, "succeeded");
|
|
});
|
|
});
|
|
|
|
test("changes requested trigger serialized remediation, verification, and fresh review", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design("minor"));
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Initial implementation",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["initial test"],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Initial verification",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: {
|
|
verdict: "changes_requested",
|
|
findings: [{ severity: "concern", message: "Handle the edge case" }],
|
|
},
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
setup.executor.enqueue("remediate", {
|
|
summary: "Handled the edge case",
|
|
changedPaths: ["src/index.ts"],
|
|
focusedTests: ["edge case test"],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Remediation verified",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
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-remediation",
|
|
});
|
|
const completed = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: approved.runId,
|
|
gateId: approved.openGate?.id ?? "missing",
|
|
expectedRevision: approved.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "context-remediation",
|
|
});
|
|
assert.equal(completed.status, "completed");
|
|
const persisted = await setup.store.loadRun(completed.runId);
|
|
assert.equal(
|
|
persisted.assignments.filter((assignment) => assignment.role === "remediate")
|
|
.length,
|
|
1,
|
|
);
|
|
assert.equal(
|
|
persisted.assignments.filter(
|
|
(assignment) => assignment.role === "review" && assignment.status === "succeeded",
|
|
).length,
|
|
2,
|
|
);
|
|
assert.equal(persisted.reviewCycles, 2);
|
|
assert.equal(persisted.verificationCycles, 0);
|
|
const reviewerNames = persisted.assignments
|
|
.filter((assignment) => assignment.role === "review")
|
|
.map((assignment) => assignment.profileName);
|
|
assert.ok(reviewerNames.every((name) => !persisted.mutatorProfileNames.includes(name)));
|
|
});
|
|
});
|
|
|
|
test("non-trivial UI requires three prototypes, acceptance, and cleanup", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
questions: [],
|
|
candidatePaths: ["src/routes/layout.svelte"],
|
|
languages: ["svelte"],
|
|
tags: ["frontend", "responsive"],
|
|
riskTags: ["accessibility"],
|
|
});
|
|
setup.executor.enqueue("design", design("non-trivial"));
|
|
for (let index = 1; index <= 3; index++) {
|
|
setup.executor.enqueue("prototype", {
|
|
path: `prototypes/candidate-${index}`,
|
|
route: `/prototype?variant=${index}`,
|
|
structuralRationale: `Distinct structure ${index}`,
|
|
responsiveChecks: ["mobile 390px", "desktop 1440px"],
|
|
browserEvidence: [`browser screenshot ${index}`],
|
|
launchInstructions: `npm run dev then open variant ${index}`,
|
|
});
|
|
}
|
|
const planned = await setup.engine().execute(startCommand());
|
|
const prototypes = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: planned.runId,
|
|
gateId: planned.openGate?.id ?? "missing",
|
|
expectedRevision: planned.revision,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "non-trivial",
|
|
},
|
|
idempotencyKey: "approve-prototype-plan",
|
|
});
|
|
assert.equal(prototypes.openGate?.kind, "prototype-acceptance");
|
|
const persistedCandidates = (
|
|
await setup.store.loadRun(prototypes.runId)
|
|
).prototypeCandidates;
|
|
assert.equal(persistedCandidates.length, 3);
|
|
assert.equal(
|
|
new Set(persistedCandidates.map((candidate) => candidate.path)).size,
|
|
3,
|
|
);
|
|
assert.ok(
|
|
persistedCandidates.every((candidate) =>
|
|
candidate.artifactPath.includes("artifacts/prototypes"),
|
|
),
|
|
);
|
|
await assert.rejects(
|
|
setup.engine().execute({
|
|
action: "decide",
|
|
runId: prototypes.runId,
|
|
gateId: prototypes.openGate?.id ?? "missing",
|
|
expectedRevision: prototypes.revision,
|
|
decision: {
|
|
kind: "accept-prototype",
|
|
candidateId: persistedCandidates[0]?.id ?? "missing",
|
|
candidateRevision: (persistedCandidates[0]?.revision ?? 0) + 1,
|
|
browserReviewed: true,
|
|
},
|
|
idempotencyKey: "stale-prototype",
|
|
}),
|
|
/candidate or revision is stale/,
|
|
);
|
|
const acceptedCandidate = persistedCandidates[1]!;
|
|
const accepted = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: prototypes.runId,
|
|
gateId: prototypes.openGate?.id ?? "missing",
|
|
expectedRevision: prototypes.revision,
|
|
decision: {
|
|
kind: "accept-prototype",
|
|
candidateId: acceptedCandidate.id,
|
|
candidateRevision: acceptedCandidate.revision,
|
|
browserReviewed: true,
|
|
feedback: "Use the second navigation structure",
|
|
},
|
|
idempotencyKey: "accept-prototype",
|
|
});
|
|
assert.equal(accepted.openGate?.kind, "implementation-context");
|
|
assert.match(
|
|
await readFile(accepted.planArtifact?.path ?? "missing", "utf8"),
|
|
/Accepted Prototype[\s\S]*second navigation structure/,
|
|
);
|
|
|
|
setup.executor.enqueue("implement", {
|
|
summary: "Implemented accepted direction to production standards",
|
|
changedPaths: ["src/routes/layout.svelte"],
|
|
focusedTests: ["responsive navigation"],
|
|
});
|
|
setup.executor.enqueue("remediate", {
|
|
summary: "Removed prototype-only candidates and routes",
|
|
changedPaths: ["prototypes"],
|
|
focusedTests: [],
|
|
});
|
|
setup.executor.enqueue("verify", {
|
|
succeeded: true,
|
|
focusedBehavioralTests: true,
|
|
summary: "Responsive browser behavior verified",
|
|
});
|
|
setup.executor.enqueue("review", {
|
|
specification: { verdict: "approved", findings: [] },
|
|
standards: { verdict: "approved", findings: [] },
|
|
});
|
|
const completed = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: accepted.runId,
|
|
gateId: accepted.openGate?.id ?? "missing",
|
|
expectedRevision: accepted.revision,
|
|
decision: {
|
|
kind: "select-implementation-context",
|
|
strategy: "current-session",
|
|
},
|
|
idempotencyKey: "prototype-context",
|
|
});
|
|
assert.equal(completed.status, "completed");
|
|
assert.equal(
|
|
(await setup.store.loadRun(completed.runId)).prototypeCleanupCompleted,
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("prototype refinement produces a fresh three-candidate revision", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
questions: [],
|
|
candidatePaths: ["src/routes/layout.svelte"],
|
|
languages: ["svelte"],
|
|
tags: ["frontend", "responsive"],
|
|
riskTags: [],
|
|
});
|
|
setup.executor.enqueue("design", design("non-trivial"));
|
|
for (let index = 1; index <= 3; index++) {
|
|
setup.executor.enqueue("prototype", {
|
|
path: `prototypes/candidate-${index}`,
|
|
route: `/first?variant=${index}`,
|
|
structuralRationale: `Initial structure ${index}`,
|
|
responsiveChecks: ["mobile and desktop"],
|
|
browserEvidence: [`initial browser evidence ${index}`],
|
|
launchInstructions: "npm run dev",
|
|
});
|
|
}
|
|
const planned = await setup.engine().execute(startCommand());
|
|
assert.equal(planned.status, "waiting", planned.error?.message);
|
|
assert.equal(planned.openGate?.kind, "plan-approval");
|
|
const firstGate = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: planned.runId,
|
|
gateId: planned.openGate?.id ?? "missing",
|
|
expectedRevision: planned.revision,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "non-trivial",
|
|
},
|
|
idempotencyKey: "approve-refinement-plan",
|
|
});
|
|
const refinementRevision = firstGate.revision + 1;
|
|
for (let index = 1; index <= 3; index++) {
|
|
setup.executor.enqueue("prototype", {
|
|
path: `prototypes/refinement-${refinementRevision}-candidate-${index}`,
|
|
route: `/refined?variant=${index}`,
|
|
structuralRationale: `Refined structure ${index}`,
|
|
responsiveChecks: ["mobile and desktop"],
|
|
browserEvidence: [`refined browser evidence ${index}`],
|
|
launchInstructions: "npm run dev",
|
|
});
|
|
}
|
|
const refined = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: firstGate.runId,
|
|
gateId: firstGate.openGate?.id ?? "missing",
|
|
expectedRevision: firstGate.revision,
|
|
decision: {
|
|
kind: "refine-prototypes",
|
|
feedback: "Increase information density",
|
|
},
|
|
idempotencyKey: "refine-prototypes",
|
|
});
|
|
assert.equal(refined.openGate?.kind, "prototype-acceptance");
|
|
const persisted = await setup.store.loadRun(refined.runId);
|
|
assert.equal(persisted.prototypeCandidates.length, 6);
|
|
assert.equal(
|
|
persisted.gates.find((gate) => gate.id === firstGate.openGate?.id)?.status,
|
|
"rejected",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("objective UI evidence upgrades a planner downgrade before approval", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
questions: [],
|
|
candidatePaths: ["src/routes/layout.svelte"],
|
|
languages: ["svelte"],
|
|
tags: ["frontend"],
|
|
riskTags: ["accessibility"],
|
|
});
|
|
const downgraded = design("minor");
|
|
downgraded.assignments.push({
|
|
role: "prototype",
|
|
profileName: "ui-prototyper",
|
|
rationale: "Prototype the objective non-trivial UI change",
|
|
mutates: true,
|
|
ownedPaths: ["prototypes"],
|
|
});
|
|
setup.executor.enqueue("design", downgraded);
|
|
const planned = await setup.engine().execute(
|
|
startCommand({ request: "Redesign the responsive navigation" }),
|
|
);
|
|
assert.equal(planned.uiImpact, "non-trivial");
|
|
assert.ok(
|
|
planned.uiImpactEvidence.some((evidence) =>
|
|
evidence.includes("responsive"),
|
|
),
|
|
);
|
|
await assert.rejects(
|
|
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: "reject-ui-downgrade",
|
|
}),
|
|
/Approved UI impact does not match the current plan/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("concurrent Plan Approval cannot cross-link immutable plan content", 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 command = (key: string, comment: string) => ({
|
|
action: "decide" as const,
|
|
runId: planned.runId,
|
|
gateId: planned.openGate?.id ?? "missing",
|
|
expectedRevision: planned.revision,
|
|
decision: {
|
|
kind: "approve-plan" as const,
|
|
approved: true as const,
|
|
acceptedUiImpact: "minor" as const,
|
|
comment,
|
|
},
|
|
idempotencyKey: key,
|
|
});
|
|
const results = await Promise.allSettled([
|
|
setup.engine().execute(command("approve-a", "approval A")),
|
|
setup.engine().execute(command("approve-b", "approval B")),
|
|
]);
|
|
assert.equal(
|
|
results.filter((result) => result.status === "fulfilled").length,
|
|
1,
|
|
);
|
|
const persisted = await setup.store.loadRun(planned.runId);
|
|
assert.ok(persisted.planArtifact);
|
|
const contents = await readFile(persisted.planArtifact.path, "utf8");
|
|
assert.equal(
|
|
createHash("sha256").update(contents).digest("hex"),
|
|
persisted.planArtifact.digest,
|
|
);
|
|
const accepted = persisted.decisions.find(
|
|
(decision) => decision.value.kind === "approve-plan",
|
|
);
|
|
assert.ok(accepted?.value.kind === "approve-plan");
|
|
assert.match(contents, new RegExp(accepted.value.comment ?? "missing"));
|
|
});
|
|
});
|
|
|
|
test("plan revision runs a fresh Design Assignment with persisted feedback", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design());
|
|
setup.executor.enqueue("design", {
|
|
...design(),
|
|
plan: "# Revised plan\n\nApply the requested changes.",
|
|
});
|
|
const initial = await setup.engine().execute(startCommand());
|
|
assert.equal(initial.openGate?.kind, "plan-approval");
|
|
const revised = await setup.engine().execute({
|
|
action: "decide",
|
|
runId: initial.runId,
|
|
gateId: initial.openGate?.id ?? "missing",
|
|
expectedRevision: initial.revision,
|
|
decision: { kind: "revise-plan", feedback: "Keep the API smaller" },
|
|
idempotencyKey: "revise-plan",
|
|
});
|
|
assert.equal(revised.openGate?.kind, "plan-approval");
|
|
assert.notEqual(revised.openGate?.id, initial.openGate?.id);
|
|
assert.equal(
|
|
revised.assignments.filter(
|
|
(assignment) =>
|
|
assignment.role === "design" && assignment.status === "succeeded",
|
|
).length,
|
|
2,
|
|
);
|
|
const latestDesignRequest = setup.executor.requests
|
|
.filter((request) => request.role === "design")
|
|
.at(-1);
|
|
assert.deepEqual(latestDesignRequest?.decisions.at(-1)?.value, {
|
|
kind: "revise-plan",
|
|
feedback: "Keep the API smaller",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("recovery reuses a revised Design result persisted before gate publication", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", design());
|
|
setup.executor.enqueue("design", {
|
|
...design(),
|
|
plan: "# Revised durable plan",
|
|
});
|
|
const initial = await setup.engine().execute(startCommand());
|
|
const command = {
|
|
action: "decide" as const,
|
|
runId: initial.runId,
|
|
gateId: initial.openGate?.id ?? "missing",
|
|
expectedRevision: initial.revision,
|
|
decision: {
|
|
kind: "revise-plan" as const,
|
|
feedback: "Use the smaller design",
|
|
},
|
|
idempotencyKey: "durable-revision",
|
|
};
|
|
await assert.rejects(
|
|
setup.engine("session-1", {
|
|
afterAssignmentPersist(assignment) {
|
|
if (assignment.role === "design") {
|
|
throw new Error("crash before revised gate");
|
|
}
|
|
},
|
|
}).execute(command),
|
|
/crash before revised gate/,
|
|
);
|
|
const resumed = await setup.engine().execute(command);
|
|
assert.equal(resumed.openGate?.kind, "plan-approval");
|
|
assert.equal(
|
|
setup.executor.requests.filter((request) => request.role === "design").length,
|
|
2,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("planner selections are eligibility-checked and persisted as queued Assignments", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const profileFor = (role: "implement" | "verify" | "review") => {
|
|
const profile = setup.profiles.find((candidate) => candidate.roles.includes(role));
|
|
assert.ok(profile);
|
|
return profile;
|
|
};
|
|
const implementer = profileFor("implement");
|
|
const verifier = profileFor("verify");
|
|
const reviewer = profileFor("review");
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", {
|
|
...design(),
|
|
assignments: [
|
|
{
|
|
role: "implement",
|
|
profileName: implementer.name,
|
|
rationale: "Own the implementation diff",
|
|
mutates: true,
|
|
ownedPaths: ["src"],
|
|
},
|
|
{
|
|
role: "verify",
|
|
profileName: verifier.name,
|
|
rationale: "Run focused verification",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
{
|
|
role: "review",
|
|
profileName: reviewer.name,
|
|
rationale: "Review independently",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
const planned = await setup.engine().execute(startCommand());
|
|
assert.equal(planned.openGate?.kind, "plan-approval");
|
|
const queued = planned.assignments.filter((assignment) =>
|
|
["implement", "verify", "review"].includes(assignment.role),
|
|
);
|
|
assert.deepEqual(
|
|
queued.map((assignment) => assignment.role),
|
|
["implement", "verify", "review"],
|
|
);
|
|
assert.ok(
|
|
queued.every((assignment) =>
|
|
assignment.selection.eligibleProfiles.some(
|
|
(candidate) =>
|
|
candidate.profileName === assignment.profileName && candidate.eligible,
|
|
),
|
|
),
|
|
);
|
|
assert.notEqual(
|
|
queued.find((assignment) => assignment.role === "implement")?.profileName,
|
|
queued.find((assignment) => assignment.role === "review")?.profileName,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("planner output fails closed when required Assignments are missing", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", {
|
|
...design(),
|
|
assignments: [],
|
|
});
|
|
const failed = await setup.engine().execute(startCommand());
|
|
assert.equal(failed.status, "failed");
|
|
assert.match(failed.error?.message ?? "", /exactly one implement Assignment/);
|
|
});
|
|
});
|
|
|
|
test("planner output fails closed on an ineligible profile", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const ineligible = setup.profiles.find((profile) =>
|
|
profile.roles.includes("discover"),
|
|
);
|
|
const verifier = setup.profiles.find((profile) => profile.roles.includes("verify"));
|
|
const reviewer = setup.profiles.find((profile) => profile.roles.includes("review"));
|
|
assert.ok(ineligible && verifier && reviewer);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: [],
|
|
});
|
|
setup.executor.enqueue("design", {
|
|
...design(),
|
|
assignments: [
|
|
{
|
|
role: "implement",
|
|
profileName: ineligible.name,
|
|
rationale: "Invalid selection",
|
|
mutates: true,
|
|
ownedPaths: ["src"],
|
|
},
|
|
{
|
|
role: "verify",
|
|
profileName: verifier.name,
|
|
rationale: "Verify",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
{
|
|
role: "review",
|
|
profileName: reviewer.name,
|
|
rationale: "Review",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
},
|
|
],
|
|
});
|
|
const failed = await setup.engine().execute(startCommand());
|
|
assert.equal(failed.status, "failed");
|
|
assert.match(failed.error?.message ?? "", /ineligible implement profile/);
|
|
});
|
|
});
|
|
|
|
test("production execution metadata and transcripts are persisted", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const executor: AssignmentExecutor = {
|
|
async run() {
|
|
return {
|
|
kind: "pi-workflow-assignment-result" as const,
|
|
output: discoveryWithQuestions(),
|
|
metadata: {
|
|
childSessionId: "child-session-1",
|
|
provider: "provider-a",
|
|
model: "model-a",
|
|
effort: "high",
|
|
contextCapacity: 128_000,
|
|
transcript: [{ role: "assistant", text: "Discovery complete" }],
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const started = await setup.engine("session-1", { executor }).execute(startCommand());
|
|
const assignment = started.assignments.find(
|
|
(candidate) => candidate.role === "discover",
|
|
);
|
|
assert.equal(assignment?.childSessionId, "child-session-1");
|
|
assert.equal(assignment?.effectiveProvider, "provider-a");
|
|
assert.equal(assignment?.effectiveModel, "model-a");
|
|
assert.equal(assignment?.effectiveEffort, "high");
|
|
assert.equal(assignment?.contextCapacity, 128_000);
|
|
assert.match(
|
|
await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"),
|
|
/Discovery complete/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("capacity reservation precedes the persisted running transition", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const observed: WorkflowAssignment["status"][] = [];
|
|
const executor: AssignmentExecutor = {
|
|
reservesCapacity: true,
|
|
async run(request) {
|
|
const before = await setup.store.loadRun(request.runId);
|
|
observed.push(
|
|
before.assignments.find((assignment) => assignment.role === request.role)
|
|
?.status ?? "failed",
|
|
);
|
|
await request.onReserved?.();
|
|
const after = await setup.store.loadRun(request.runId);
|
|
observed.push(
|
|
after.assignments.find((assignment) => assignment.role === request.role)
|
|
?.status ?? "failed",
|
|
);
|
|
return discoveryWithQuestions();
|
|
},
|
|
};
|
|
await setup.engine("session-1", { executor }).execute(startCommand());
|
|
assert.deepEqual(observed, ["queued", "running"]);
|
|
});
|
|
});
|
|
|
|
test("failed execution metadata and transcripts are persisted", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const executor: AssignmentExecutor = {
|
|
async run() {
|
|
throw Object.assign(new Error("provider failed"), {
|
|
assignmentMetadata: {
|
|
childSessionId: "child-session-failed",
|
|
provider: "provider-a",
|
|
model: "model-a",
|
|
effort: "low",
|
|
contextCapacity: 32_000,
|
|
transcript: [{ role: "assistant", text: "partial response" }],
|
|
},
|
|
});
|
|
},
|
|
};
|
|
const failed = await setup.engine("session-1", { executor }).execute(startCommand());
|
|
const assignment = failed.assignments.find(
|
|
(candidate) => candidate.role === "discover",
|
|
);
|
|
assert.equal(failed.status, "failed");
|
|
assert.equal(assignment?.status, "failed");
|
|
assert.equal(assignment?.childSessionId, "child-session-failed");
|
|
assert.match(
|
|
await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"),
|
|
/partial response/,
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
setup.executor.enqueue("design", design());
|
|
const engine = setup.engine();
|
|
const command = startCommand();
|
|
const first = await engine.execute(command);
|
|
const repeated = await engine.execute(command);
|
|
assert.deepEqual(repeated, first);
|
|
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,
|
|
runId: first.runId,
|
|
gateId: first.openGate?.id ?? "missing",
|
|
expectedRevision: first.revision,
|
|
decision: {
|
|
kind: "answer-questions" as const,
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "answer-key",
|
|
};
|
|
const decided = await engine.execute(decide);
|
|
assert.deepEqual(await engine.execute(decide), decided);
|
|
assert.equal(
|
|
setup.executor.requests.filter((request) => request.role === "design")
|
|
.length,
|
|
1,
|
|
);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
...decide,
|
|
decision: {
|
|
kind: "answer-questions",
|
|
answers: { "data-source": "Different answer" },
|
|
},
|
|
}),
|
|
/another command/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("concurrent starts share one authoritative run and workspace mutation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const blocking = new BlockingExecutor(discoveryWithQuestions());
|
|
const first = setup.engine("session-1", { executor: blocking });
|
|
const second = setup.engine("session-1", { executor: blocking });
|
|
const firstStart = first.execute(startCommand());
|
|
await blocking.started;
|
|
const secondStart = second.execute(startCommand());
|
|
assert.equal(setup.workspace.prepareCalls, 1);
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
blocking.release();
|
|
const [firstResult, secondResult] = await Promise.all([
|
|
firstStart,
|
|
secondStart,
|
|
]);
|
|
assert.deepEqual(secondResult, firstResult);
|
|
assert.equal((await setup.store.listRuns()).length, 1);
|
|
assert.equal(blocking.requests.length, 1);
|
|
});
|
|
});
|
|
|
|
test("cross-process starts claim the published run before workspace mutation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const state = path.join(directory, "state");
|
|
const stateAlias = path.join(directory, "state-alias");
|
|
await mkdir(state);
|
|
await symlink(state, stateAlias);
|
|
const profiles = (
|
|
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
|
).profiles;
|
|
const workspace = new FakeWorkspace();
|
|
const executor = new BlockingExecutor(discoveryWithQuestions());
|
|
let leftId = 0;
|
|
let rightId = 0;
|
|
const makeEngine = (
|
|
store: WorkflowStore,
|
|
processId: number,
|
|
createId: () => string,
|
|
) =>
|
|
new WorkflowEngine({
|
|
store,
|
|
workspace,
|
|
executor,
|
|
sourceRoot: directory,
|
|
parentSessionId: "session-1",
|
|
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
profiles,
|
|
now: () => 10_000,
|
|
createId,
|
|
processId,
|
|
isProcessAlive: () => true,
|
|
});
|
|
const left = makeEngine(
|
|
new WorkflowStore(state),
|
|
100,
|
|
() => `left-${++leftId}`,
|
|
);
|
|
const right = makeEngine(
|
|
new WorkflowStore(stateAlias),
|
|
200,
|
|
() => `right-${++rightId}`,
|
|
);
|
|
|
|
const leftStart = left.execute(startCommand());
|
|
const rightStart = right.execute(startCommand());
|
|
await executor.started;
|
|
assert.equal(workspace.createCalls, 1);
|
|
executor.release();
|
|
const [leftResult, rightResult] = await Promise.all([
|
|
leftStart,
|
|
rightStart,
|
|
]);
|
|
assert.equal(leftResult.runId, rightResult.runId);
|
|
assert.equal(workspace.createCalls, 1);
|
|
assert.equal(executor.requests.length, 1);
|
|
assert.equal((await new WorkflowStore(state).listRuns()).length, 1);
|
|
});
|
|
});
|
|
|
|
test("live workspace creation cannot be stolen after its heartbeat ages", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const state = path.join(directory, "state");
|
|
const stateAlias = path.join(directory, "state-alias");
|
|
await mkdir(state);
|
|
await symlink(state, stateAlias);
|
|
const profiles = (
|
|
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
|
).profiles;
|
|
const workspace = new FakeWorkspace();
|
|
let creationStarted: (() => void) | undefined;
|
|
let releaseCreation: (() => void) | undefined;
|
|
const startedCreating = new Promise<void>((resolve) => {
|
|
creationStarted = resolve;
|
|
});
|
|
const creationWait = new Promise<void>((resolve) => {
|
|
releaseCreation = resolve;
|
|
});
|
|
workspace.beforeCreate = async () => {
|
|
creationStarted?.();
|
|
await creationWait;
|
|
};
|
|
const executor = new ScriptedExecutor();
|
|
executor.enqueue("discover", discoveryWithQuestions());
|
|
let now = 10_000;
|
|
let leftId = 0;
|
|
let rightId = 0;
|
|
const makeEngine = (
|
|
store: WorkflowStore,
|
|
processId: number,
|
|
createId: () => string,
|
|
) =>
|
|
new WorkflowEngine({
|
|
store,
|
|
workspace,
|
|
executor,
|
|
sourceRoot: directory,
|
|
parentSessionId: "session-1",
|
|
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
profiles,
|
|
now: () => now,
|
|
createId,
|
|
processId,
|
|
isProcessAlive: () => true,
|
|
});
|
|
const owner = makeEngine(
|
|
new WorkflowStore(state),
|
|
100,
|
|
() => `owner-${++leftId}`,
|
|
);
|
|
const contender = makeEngine(
|
|
new WorkflowStore(stateAlias),
|
|
200,
|
|
() => `contender-${++rightId}`,
|
|
);
|
|
|
|
const ownerStart = owner.execute(startCommand());
|
|
await startedCreating;
|
|
now = 100_000;
|
|
const contenderResult = await contender.execute(startCommand());
|
|
assert.equal(contenderResult.workspace.state, "creating");
|
|
assert.equal(workspace.createCalls, 1);
|
|
assert.deepEqual(await contender.recover(), []);
|
|
assert.equal(workspace.reconcileCalls, 0);
|
|
releaseCreation?.();
|
|
const completed = await ownerStart;
|
|
assert.equal(completed.openGate?.kind, "questions");
|
|
assert.equal(workspace.createCalls, 1);
|
|
assert.equal(executor.requests.length, 1);
|
|
const startRecord = await new WorkflowStore(state).getStart("start-key");
|
|
assert.equal(
|
|
(startRecord?.result as { revision?: number } | undefined)?.revision,
|
|
completed.revision,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("a pending start resumes after interrupted initial publication", async () => {
|
|
await withTempDir(async (directory) => {
|
|
let interrupt = true;
|
|
const setup = await fixture(directory, {
|
|
beforeRename(target) {
|
|
if (interrupt && target.endsWith("request.md")) {
|
|
throw new Error("simulated initial publication crash");
|
|
}
|
|
},
|
|
});
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
await assert.rejects(
|
|
setup.engine().execute(startCommand()),
|
|
/simulated initial publication crash/,
|
|
);
|
|
|
|
interrupt = false;
|
|
const resumed = await setup.engine().execute(startCommand());
|
|
assert.equal(resumed.status, "waiting");
|
|
assert.equal(resumed.openGate?.kind, "questions");
|
|
assert.equal(setup.workspace.prepareCalls, 2);
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
assert.equal((await setup.store.listRuns()).length, 1);
|
|
});
|
|
});
|
|
|
|
test("a durable ready revision survives convenience-snapshot interruption", async () => {
|
|
await withTempDir(async (directory) => {
|
|
let workflowWrites = 0;
|
|
let interrupt = true;
|
|
const setup = await fixture(directory, {
|
|
beforeRename(target) {
|
|
if (target.endsWith("workflow.json")) {
|
|
workflowWrites++;
|
|
if (interrupt && workflowWrites === 2) {
|
|
throw new Error("simulated ready snapshot crash");
|
|
}
|
|
}
|
|
},
|
|
});
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
await assert.rejects(
|
|
setup.engine().execute(startCommand()),
|
|
/simulated ready snapshot crash/,
|
|
);
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
|
|
interrupt = false;
|
|
const resumed = await setup.engine().execute(startCommand());
|
|
assert.equal(resumed.workspace.state, "ready");
|
|
assert.equal(resumed.openGate?.kind, "questions");
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
assert.equal(setup.workspace.reconcileCalls, 0);
|
|
});
|
|
});
|
|
|
|
test("real Git persisted readiness precedes retry preparation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const repository = path.join(directory, "repository");
|
|
await mkdir(repository);
|
|
const git = (args: string[], cwd = repository) =>
|
|
execFileAsync("git", args, { cwd, encoding: "utf8" });
|
|
await git(["init", "-b", "main"]);
|
|
await git(["config", "user.email", "pi-workflow@example.invalid"]);
|
|
await git(["config", "user.name", "Pi Workflow Test"]);
|
|
await writeFile(
|
|
path.join(repository, ".gitignore"),
|
|
".worktrees/\n",
|
|
"utf8",
|
|
);
|
|
await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8");
|
|
await git(["add", ".gitignore", "README.md"]);
|
|
await git(["commit", "-m", "initial fixture"]);
|
|
|
|
let workflowWrites = 0;
|
|
let interrupt = true;
|
|
const store = new WorkflowStore(path.join(directory, "state"), {
|
|
beforeRename(target) {
|
|
if (target.endsWith("workflow.json")) {
|
|
workflowWrites++;
|
|
if (interrupt && workflowWrites === 2) {
|
|
throw new Error("simulated real ready snapshot crash");
|
|
}
|
|
}
|
|
},
|
|
});
|
|
const executor = new ScriptedExecutor();
|
|
executor.enqueue("discover", discoveryWithQuestions());
|
|
const profiles = (
|
|
await loadAgentProfiles({ cwd: repository, projectTrusted: false })
|
|
).profiles;
|
|
let id = 0;
|
|
const makeEngine = () =>
|
|
new WorkflowEngine({
|
|
store,
|
|
workspace: new GitWorkspaceManager(),
|
|
executor,
|
|
sourceRoot: repository,
|
|
parentSessionId: "session-1",
|
|
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
profiles,
|
|
now: () => 10_000,
|
|
createId: () => `real-id-${++id}`,
|
|
processId: 100,
|
|
isProcessAlive: () => true,
|
|
});
|
|
|
|
await assert.rejects(
|
|
makeEngine().execute(startCommand()),
|
|
/simulated real ready snapshot crash/,
|
|
);
|
|
interrupt = false;
|
|
const resumed = await makeEngine().execute(startCommand());
|
|
assert.equal(resumed.workspace.state, "ready");
|
|
assert.equal(resumed.openGate?.kind, "questions");
|
|
assert.equal(executor.requests.length, 1);
|
|
const sourceStatus = await git(["status", "--porcelain"]);
|
|
assert.equal(sourceStatus.stdout, "");
|
|
});
|
|
});
|
|
|
|
test("persisted discovery output resumes during recovery without replay", async () => {
|
|
await withTempDir(async (directory) => {
|
|
let interrupt = true;
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const crashing = setup.engine("session-1", {
|
|
afterAssignmentPersist(assignment) {
|
|
if (interrupt && assignment.role === "discover") {
|
|
interrupt = false;
|
|
throw new Error("simulated post-assignment crash");
|
|
}
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
crashing.execute(startCommand()),
|
|
/simulated post-assignment crash/,
|
|
);
|
|
const [persisted] = await setup.store.listRuns();
|
|
assert.equal(persisted.assignments[0]?.status, "succeeded");
|
|
if (persisted.lease) {
|
|
persisted.lease.processId = 999;
|
|
persisted.lease.heartbeatAt = 0;
|
|
}
|
|
persisted.revision++;
|
|
await setup.store.saveRun(persisted);
|
|
|
|
const recovery = setup.engine("session-2", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => false,
|
|
});
|
|
const [resumed] = await recovery.recover();
|
|
assert.equal(resumed?.openGate?.kind, "questions");
|
|
assert.equal(setup.executor.requests.length, 1);
|
|
const replayedStart = await recovery.execute(startCommand());
|
|
assert.equal(replayedStart.openGate?.kind, "questions");
|
|
assert.equal(setup.executor.requests.length, 1);
|
|
});
|
|
});
|
|
|
|
test("pending decision continuation consumes persisted design output once", async () => {
|
|
await withTempDir(async (directory) => {
|
|
let interrupt = true;
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
setup.executor.enqueue("design", design());
|
|
const crashing = setup.engine("session-1", {
|
|
afterAssignmentPersist(assignment) {
|
|
if (interrupt && assignment.role === "design") {
|
|
interrupt = false;
|
|
throw new Error("simulated decision continuation crash");
|
|
}
|
|
},
|
|
});
|
|
const started = await crashing.execute(startCommand());
|
|
const command = {
|
|
action: "decide" as const,
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision,
|
|
decision: {
|
|
kind: "answer-questions" as const,
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "answer-key",
|
|
};
|
|
await assert.rejects(
|
|
crashing.execute(command),
|
|
/simulated decision continuation crash/,
|
|
);
|
|
const interrupted = await setup.store.loadRun(started.runId);
|
|
assert.equal(
|
|
interrupted.commandReceipts.find(
|
|
(receipt) => receipt.key === "answer-key",
|
|
)?.status,
|
|
"pending",
|
|
);
|
|
assert.equal(
|
|
interrupted.assignments.find((assignment) => assignment.role === "design")
|
|
?.status,
|
|
"succeeded",
|
|
);
|
|
|
|
if (interrupted.lease) {
|
|
interrupted.lease.processId = 999;
|
|
interrupted.lease.heartbeatAt = 0;
|
|
}
|
|
interrupted.revision++;
|
|
await setup.store.saveRun(interrupted);
|
|
const recovery = setup.engine("session-2", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => false,
|
|
});
|
|
const [resumed] = await recovery.recover();
|
|
assert.equal(resumed?.openGate?.kind, "plan-approval");
|
|
assert.equal(
|
|
(await recovery.execute(command)).openGate?.kind,
|
|
"plan-approval",
|
|
);
|
|
assert.equal(
|
|
setup.executor.requests.filter((request) => request.role === "design")
|
|
.length,
|
|
1,
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(started.runId)).commandReceipts.find(
|
|
(receipt) => receipt.key === "answer-key",
|
|
)?.status,
|
|
"completed",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("a concurrent duplicate decision cannot complete an active continuation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const started = await setup.engine().execute(startCommand());
|
|
const blocking = new BlockingExecutor(design());
|
|
const engine = setup.engine("session-1", {
|
|
executor: blocking,
|
|
assignmentHeartbeatMs: 2,
|
|
});
|
|
const command = {
|
|
action: "decide" as const,
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision,
|
|
decision: {
|
|
kind: "answer-questions" as const,
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "answer-key",
|
|
};
|
|
const deciding = engine.execute(command);
|
|
await blocking.started;
|
|
const duplicate = await engine.execute(command);
|
|
assert.equal(duplicate.status, "running");
|
|
assert.equal(
|
|
(await setup.store.loadRun(started.runId)).commandReceipts.find(
|
|
(receipt) => receipt.key === "answer-key",
|
|
)?.status,
|
|
"pending",
|
|
);
|
|
blocking.release();
|
|
assert.equal((await deciding).openGate?.kind, "plan-approval");
|
|
});
|
|
});
|
|
|
|
test("active assignment leases heartbeat and block concurrent recovery", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const blocking = new BlockingExecutor(discoveryWithQuestions());
|
|
let now = 10_000;
|
|
const engine = setup.engine("session-1", {
|
|
executor: blocking,
|
|
now: () => now,
|
|
processId: 100,
|
|
assignmentHeartbeatMs: 2,
|
|
});
|
|
const start = engine.execute(startCommand());
|
|
await blocking.started;
|
|
now = 100_000;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
const [running] = await setup.store.listRuns();
|
|
const assignment = running.assignments[0];
|
|
assert.equal(assignment?.status, "running");
|
|
const lease = await setup.store.loadAssignmentLease(
|
|
running.runId,
|
|
assignment?.id ?? "missing",
|
|
);
|
|
assert.equal(lease?.heartbeatAt, now);
|
|
assert.deepEqual(
|
|
await setup
|
|
.engine("session-2", {
|
|
now: () => now,
|
|
processId: 100,
|
|
isProcessAlive: () => false,
|
|
})
|
|
.recover(),
|
|
[],
|
|
);
|
|
blocking.release();
|
|
assert.equal((await start).openGate?.kind, "questions");
|
|
});
|
|
});
|
|
|
|
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());
|
|
let now = 10_000;
|
|
const start = setup
|
|
.engine("session-1", {
|
|
executor: blocking,
|
|
now: () => now,
|
|
processId: 100,
|
|
assignmentHeartbeatMs: 1_000,
|
|
})
|
|
.execute(startCommand());
|
|
await blocking.started;
|
|
now = 100_000;
|
|
const recovery = setup.engine("session-2", {
|
|
now: () => now,
|
|
processId: 200,
|
|
isProcessAlive: () => true,
|
|
});
|
|
assert.deepEqual(await recovery.recover(), []);
|
|
const [running] = await setup.store.listRuns();
|
|
assert.equal(running?.assignments[0]?.status, "running");
|
|
blocking.release();
|
|
assert.equal((await start).openGate?.kind, "questions");
|
|
});
|
|
});
|
|
|
|
test("a live mutating assignment is not reclaimed on heartbeat age alone", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const started = await setup.engine().execute(startCommand());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
const assignment = run.assignments[0];
|
|
assert.ok(assignment);
|
|
assignment.status = "running";
|
|
assignment.mutates = true;
|
|
assignment.executionToken = "live-mutating-token";
|
|
for (const gate of run.gates) gate.status = "satisfied";
|
|
run.status = "running";
|
|
if (run.lease) {
|
|
run.lease.processId = 100;
|
|
run.lease.heartbeatAt = 0;
|
|
}
|
|
run.revision++;
|
|
await setup.store.saveRun(run);
|
|
await setup.store.createAssignmentLease(run.runId, assignment.id, {
|
|
token: assignment.executionToken,
|
|
processId: 100,
|
|
heartbeatAt: 0,
|
|
});
|
|
|
|
const recovery = setup.engine("session-2", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => true,
|
|
});
|
|
assert.deepEqual(await recovery.recover(), []);
|
|
assert.equal(
|
|
(await setup.store.loadRun(run.runId)).assignments[0]?.status,
|
|
"running",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("an executor cannot persist after losing its assignment fence", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const blocking = new BlockingExecutor(discoveryWithQuestions());
|
|
const start = setup
|
|
.engine("session-1", {
|
|
executor: blocking,
|
|
assignmentHeartbeatMs: 1_000,
|
|
})
|
|
.execute(startCommand());
|
|
await blocking.started;
|
|
const [running] = await setup.store.listRuns();
|
|
const assignment = running.assignments[0];
|
|
assert.ok(assignment?.executionToken);
|
|
await setup.store.releaseAssignmentLease(
|
|
running.runId,
|
|
assignment.id,
|
|
assignment.executionToken,
|
|
);
|
|
blocking.release();
|
|
await assert.rejects(start, /lost its execution lease/);
|
|
assert.equal(
|
|
(await setup.store.loadRun(running.runId)).assignments[0]?.status,
|
|
"running",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("runtime command schemas reject malformed and open shapes", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const engine = setup.engine();
|
|
const malformed: unknown[] = [
|
|
{
|
|
...startCommand(),
|
|
unexpected: true,
|
|
},
|
|
{
|
|
...startCommand(),
|
|
background: "yes",
|
|
},
|
|
{
|
|
action: "inspect",
|
|
runId: "run-1",
|
|
extra: true,
|
|
},
|
|
{
|
|
action: "decide",
|
|
runId: "run-1",
|
|
gateId: "gate-1",
|
|
expectedRevision: 0,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: false,
|
|
acceptedUiImpact: "none",
|
|
},
|
|
idempotencyKey: "decision-key",
|
|
},
|
|
{
|
|
action: "decide",
|
|
runId: "run-1",
|
|
gateId: "gate-1",
|
|
expectedRevision: 0,
|
|
decision: { kind: "recover", resolution: "continue" },
|
|
idempotencyKey: "decision-key",
|
|
},
|
|
];
|
|
for (const command of malformed) {
|
|
await assert.rejects(engine.execute(command as WorkflowCommand));
|
|
}
|
|
assert.equal(setup.workspace.prepareCalls, 0);
|
|
});
|
|
});
|
|
|
|
test("decisions require the single active Gate and its waiting stage", 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());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
run.gates.push({
|
|
id: "second-open-gate",
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: run.revision,
|
|
openedAt: 10_000,
|
|
});
|
|
run.revision++;
|
|
await setup.store.saveRun(run);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: run.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: run.revision,
|
|
decision: {
|
|
kind: "answer-questions",
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "multiple-gates-key",
|
|
}),
|
|
/single active Gate/,
|
|
);
|
|
});
|
|
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const engine = setup.engine();
|
|
const started = await engine.execute(startCommand());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
const discover = run.stages.find(
|
|
(candidate) => candidate.kind === "discover",
|
|
);
|
|
assert.ok(discover);
|
|
discover.status = "completed";
|
|
run.revision++;
|
|
await setup.store.saveRun(run);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: run.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: run.revision,
|
|
decision: {
|
|
kind: "answer-questions",
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
idempotencyKey: "wrong-stage-key",
|
|
}),
|
|
/waiting Discover stage/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("required profile eligibility fails before workspace mutation", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const profiles = structuredClone(setup.profiles);
|
|
for (const profile of profiles) {
|
|
if (profile.roles.includes("implement")) {
|
|
profile.selection.allTags = ["not-present"];
|
|
}
|
|
}
|
|
await assert.rejects(
|
|
setup.engine("session-1", { profiles }).execute(startCommand()),
|
|
/No eligible implement Agent Profile/,
|
|
);
|
|
assert.equal(setup.workspace.prepareCalls, 0);
|
|
assert.deepEqual(await setup.store.listRuns(), []);
|
|
});
|
|
});
|
|
|
|
test("stale and illegal decisions fail without changing the 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 assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision - 1,
|
|
decision: { kind: "answer-questions", answers: {} },
|
|
idempotencyKey: "stale-key",
|
|
}),
|
|
/Stale workflow revision/,
|
|
);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision,
|
|
decision: { kind: "answer-questions", answers: {} },
|
|
idempotencyKey: "missing-answer-key",
|
|
}),
|
|
/requires an answer/,
|
|
);
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: started.runId,
|
|
gateId: started.openGate?.id ?? "missing",
|
|
expectedRevision: started.revision,
|
|
decision: {
|
|
kind: "approve-plan",
|
|
approved: true,
|
|
acceptedUiImpact: "none",
|
|
},
|
|
idempotencyKey: "illegal-key",
|
|
}),
|
|
/not legal for questions/,
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(started.runId)).revision,
|
|
started.revision,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("the closed Gate Decision matrix is exhaustive", () => {
|
|
const gateKinds: GateKind[] = [
|
|
"questions",
|
|
"plan-approval",
|
|
"prototype-acceptance",
|
|
"implementation-context",
|
|
"recovery",
|
|
];
|
|
const decisionKinds: GateDecision["kind"][] = [
|
|
"answer-questions",
|
|
"approve-plan",
|
|
"revise-plan",
|
|
"accept-prototype",
|
|
"refine-prototypes",
|
|
"select-implementation-context",
|
|
"report-context-preparation",
|
|
"recover",
|
|
];
|
|
const expected: Record<GateKind, GateDecision["kind"][]> = {
|
|
questions: ["answer-questions"],
|
|
"plan-approval": ["approve-plan", "revise-plan"],
|
|
"prototype-acceptance": ["accept-prototype", "refine-prototypes"],
|
|
"implementation-context": [
|
|
"select-implementation-context",
|
|
"report-context-preparation",
|
|
],
|
|
recovery: ["recover"],
|
|
};
|
|
for (const gateKind of gateKinds) {
|
|
for (const decisionKind of decisionKinds) {
|
|
assert.equal(
|
|
decisionIsLegal(gateKind, decisionKind),
|
|
expected[gateKind].includes(decisionKind),
|
|
`${decisionKind} at ${gateKind}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("every illegal Gate Decision pair is rejected through execute", 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());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
const gate = run.gates.find(
|
|
(candidate) => candidate.id === started.openGate?.id,
|
|
);
|
|
assert.ok(gate);
|
|
const decisions: GateDecision[] = [
|
|
{
|
|
kind: "answer-questions",
|
|
answers: { "data-source": "Repository API" },
|
|
},
|
|
{ kind: "approve-plan", approved: true, acceptedUiImpact: "none" },
|
|
{ kind: "revise-plan", feedback: "Revise it" },
|
|
{
|
|
kind: "accept-prototype",
|
|
candidateId: "candidate-1",
|
|
candidateRevision: 1,
|
|
browserReviewed: true,
|
|
},
|
|
{ kind: "refine-prototypes", feedback: "Refine it" },
|
|
{ kind: "select-implementation-context", strategy: "current-session" },
|
|
{
|
|
kind: "report-context-preparation",
|
|
preparationId: "preparation-1",
|
|
planRevision: 1,
|
|
method: "smart_compact",
|
|
outcome: "unavailable",
|
|
},
|
|
{ kind: "recover", resolution: "retry" },
|
|
];
|
|
const gateKinds: GateKind[] = [
|
|
"questions",
|
|
"plan-approval",
|
|
"prototype-acceptance",
|
|
"implementation-context",
|
|
"recovery",
|
|
];
|
|
for (const gateKind of gateKinds) {
|
|
gate.kind = gateKind;
|
|
gate.payload =
|
|
gateKind === "questions"
|
|
? { questions: [{ id: "data-source", prompt: "Which data source?" }] }
|
|
: undefined;
|
|
const discover = run.stages.find(
|
|
(candidate) => candidate.kind === "discover",
|
|
);
|
|
const designStage = run.stages.find(
|
|
(candidate) => candidate.kind === "design",
|
|
);
|
|
assert.ok(discover && designStage);
|
|
discover.status = gateKind === "questions" ? "waiting" : "completed";
|
|
designStage.status = gateKind === "plan-approval" ? "waiting" : "pending";
|
|
run.revision++;
|
|
gate.revision = run.revision;
|
|
await setup.store.saveRun(run);
|
|
for (const decision of decisions) {
|
|
if (decisionIsLegal(gateKind, decision.kind)) continue;
|
|
await assert.rejects(
|
|
engine.execute({
|
|
action: "decide",
|
|
runId: run.runId,
|
|
gateId: gate.id,
|
|
expectedRevision: run.revision,
|
|
decision,
|
|
idempotencyKey: `matrix-${gateKind}-${decision.kind}`,
|
|
}),
|
|
new RegExp(`not legal for ${gateKind}`),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("abort cancels an active reserved Assignment before persisting terminal state", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
let resolveStarted = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
resolveStarted = resolve;
|
|
});
|
|
let cancelled = false;
|
|
const executor: AssignmentExecutor = {
|
|
reservesCapacity: true,
|
|
async run(request) {
|
|
await request.onReserved?.();
|
|
resolveStarted();
|
|
return new Promise((resolve, reject) => {
|
|
request.signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
cancelled = true;
|
|
const error = Object.assign(new Error("cancelled"), {
|
|
assignmentMetadata: {
|
|
childSessionId: "child-aborted",
|
|
provider: "provider-a",
|
|
model: "model-a",
|
|
effort: "medium",
|
|
contextCapacity: 64_000,
|
|
transcript: [{ role: "assistant", text: "cancelled child" }],
|
|
},
|
|
});
|
|
reject(error);
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
},
|
|
};
|
|
const startPromise = setup
|
|
.engine("session-1", { executor })
|
|
.execute(startCommand());
|
|
await started;
|
|
const [active] = await setup.store.listRuns();
|
|
assert.ok(active);
|
|
const aborted = await setup.engine("session-1", { executor }).execute({
|
|
action: "abort",
|
|
runId: active.runId,
|
|
expectedRevision: active.revision,
|
|
idempotencyKey: "abort-active",
|
|
});
|
|
const startResult = await startPromise;
|
|
assert.equal(cancelled, true);
|
|
assert.equal(startResult.status, "aborted");
|
|
assert.equal(aborted.status, "aborted");
|
|
const persisted = await setup.store.loadRun(active.runId);
|
|
const assignment = persisted.assignments.find(
|
|
(candidate) => candidate.role === "discover",
|
|
);
|
|
assert.equal(persisted.status, "aborted");
|
|
assert.equal(assignment?.status, "interrupted");
|
|
assert.equal(assignment?.childSessionId, "child-aborted");
|
|
assert.match(
|
|
await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"),
|
|
/cancelled child/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("abort times out without persisting terminal state when an executor will not stop", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
let resolveStarted = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
resolveStarted = resolve;
|
|
});
|
|
let rejectRun = (_error: Error) => {};
|
|
const executor: AssignmentExecutor = {
|
|
reservesCapacity: true,
|
|
async run(request) {
|
|
await request.onReserved?.();
|
|
resolveStarted();
|
|
return new Promise((_resolve, reject) => {
|
|
rejectRun = reject;
|
|
});
|
|
},
|
|
};
|
|
const startPromise = setup.engine("session-1", { executor }).execute(startCommand());
|
|
await started;
|
|
const [active] = await setup.store.listRuns();
|
|
assert.ok(active);
|
|
await assert.rejects(
|
|
setup.engine("session-1", {
|
|
executor,
|
|
assignmentAbortTimeoutMs: 5,
|
|
}).execute({
|
|
action: "abort",
|
|
runId: active.runId,
|
|
expectedRevision: active.revision,
|
|
idempotencyKey: "abort-timeout",
|
|
}),
|
|
/Timed out stopping active Assignments/,
|
|
);
|
|
assert.notEqual((await setup.store.loadRun(active.runId)).status, "aborted");
|
|
rejectRun(new Error("executor finally stopped"));
|
|
await startPromise;
|
|
const stopped = await setup.store.loadRun(active.runId);
|
|
const retried = await setup.engine("session-1", { executor }).execute({
|
|
action: "abort",
|
|
runId: stopped.runId,
|
|
expectedRevision: stopped.revision,
|
|
idempotencyKey: "abort-after-timeout",
|
|
});
|
|
assert.equal(retried.status, "aborted");
|
|
assert.equal(
|
|
retried.assignments.find((assignment) => assignment.role === "discover")
|
|
?.status,
|
|
"interrupted",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("abort fails closed for an Assignment owned by another live process", 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: "external-assignment",
|
|
stageId: `${current.runId}:implement`,
|
|
role: "implement",
|
|
profileName: profile.name,
|
|
profileDigest: profile.digest,
|
|
selection: {
|
|
eligibleProfiles: structuredClone(evidence.candidates),
|
|
selectedProfileName: profile.name,
|
|
rationale: "External owner fixture",
|
|
},
|
|
status: "running",
|
|
mutates: true,
|
|
ownedPaths: ["src"],
|
|
executionToken: "external-token",
|
|
});
|
|
current.status = "running";
|
|
current.revision++;
|
|
await setup.store.saveRun(current, current.revision - 1);
|
|
await setup.store.createAssignmentLease(
|
|
current.runId,
|
|
"external-assignment",
|
|
{
|
|
token: "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-external",
|
|
}),
|
|
/Cannot safely abort Assignment external-assignment owned by live process 200/,
|
|
);
|
|
assert.equal((await setup.store.loadRun(current.runId)).status, "running");
|
|
});
|
|
});
|
|
|
|
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);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const engine = setup.engine();
|
|
const started = await engine.execute(startCommand());
|
|
const abort = {
|
|
action: "abort" as const,
|
|
runId: started.runId,
|
|
expectedRevision: started.revision,
|
|
idempotencyKey: "abort-key",
|
|
};
|
|
const aborted = await engine.execute(abort);
|
|
assert.equal(aborted.status, "aborted");
|
|
assert.deepEqual(await engine.execute(abort), aborted);
|
|
await assert.rejects(
|
|
engine.execute({ ...abort, idempotencyKey: "other-key" }),
|
|
/Stale workflow revision/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("stale running assignments become interrupted once and require Recovery", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const started = await setup.engine().execute(startCommand());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
for (const gate of run.gates) gate.status = "satisfied";
|
|
const completed = structuredClone(run.assignments[0]);
|
|
assert.ok(completed);
|
|
completed.id = "completed-assignment";
|
|
completed.status = "succeeded";
|
|
const running: WorkflowAssignment = {
|
|
...structuredClone(completed),
|
|
id: "mutating-assignment",
|
|
role: "implement",
|
|
status: "running",
|
|
mutates: true,
|
|
};
|
|
run.assignments = [completed, running];
|
|
run.status = "running";
|
|
run.revision++;
|
|
if (run.lease) {
|
|
run.lease.processId = 999;
|
|
run.lease.heartbeatAt = 0;
|
|
}
|
|
await setup.store.saveRun(run);
|
|
|
|
const recoveryEngine = setup.engine("session-2", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => false,
|
|
});
|
|
const [recovered] = await recoveryEngine.recover();
|
|
assert.equal(recovered?.openGate?.kind, "recovery");
|
|
const recoveredRun = await setup.store.loadRun(run.runId);
|
|
assert.equal(
|
|
recoveredRun.assignments.find((item) => item.id === "mutating-assignment")
|
|
?.status,
|
|
"interrupted",
|
|
);
|
|
assert.equal(
|
|
recoveredRun.assignments.find(
|
|
(item) => item.id === "completed-assignment",
|
|
)?.status,
|
|
"succeeded",
|
|
);
|
|
assert.deepEqual(await recoveryEngine.recover(), []);
|
|
|
|
await assert.rejects(
|
|
setup.engine("session-1").execute({
|
|
action: "abort",
|
|
runId: run.runId,
|
|
expectedRevision: recoveredRun.revision,
|
|
idempotencyKey: "old-session-abort",
|
|
}),
|
|
/Historical parent sessions/,
|
|
);
|
|
const inspected = await recoveryEngine.execute({
|
|
action: "decide",
|
|
runId: run.runId,
|
|
gateId: recovered?.openGate?.id ?? "missing",
|
|
expectedRevision: recovered?.revision ?? -1,
|
|
decision: { kind: "recover", resolution: "inspect" },
|
|
idempotencyKey: "inspect-recovery-key",
|
|
});
|
|
assert.equal(inspected.openGate?.kind, "recovery");
|
|
const retry = await recoveryEngine.execute({
|
|
action: "decide",
|
|
runId: run.runId,
|
|
gateId: inspected.openGate?.id ?? "missing",
|
|
expectedRevision: inspected.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "recovery-key",
|
|
});
|
|
assert.equal(
|
|
retry.assignments.find((item) => item.id === "mutating-assignment")
|
|
?.status,
|
|
"interrupted",
|
|
);
|
|
assert.equal(retry.openGate?.kind, "recovery");
|
|
});
|
|
});
|
|
|
|
test("workspace creation failures persist recoverable intent", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
setup.workspace.createError = new Error("git worktree failed");
|
|
const engine = setup.engine();
|
|
const failed = await engine.execute(startCommand());
|
|
assert.equal(failed.status, "waiting");
|
|
assert.equal(failed.workspace.state, "creating");
|
|
assert.equal(failed.openGate?.kind, "recovery");
|
|
assert.match(failed.error?.message ?? "", /git worktree failed/);
|
|
assert.deepEqual(await engine.execute(startCommand()), failed);
|
|
assert.equal(setup.workspace.createCalls, 1);
|
|
await assert.rejects(
|
|
setup
|
|
.engine("session-1", {
|
|
processId: 200,
|
|
isProcessAlive: () => true,
|
|
})
|
|
.execute({
|
|
action: "decide",
|
|
runId: failed.runId,
|
|
gateId: failed.openGate?.id ?? "missing",
|
|
expectedRevision: failed.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "live-owner-retry-key",
|
|
}),
|
|
/still owned by a live process/,
|
|
);
|
|
assert.equal(
|
|
(await setup.store.loadRun(failed.runId)).gates.find(
|
|
(gate) => gate.id === failed.openGate?.id,
|
|
)?.status,
|
|
"open",
|
|
);
|
|
|
|
setup.workspace.createError = undefined;
|
|
const recovered = await engine.execute({
|
|
action: "decide",
|
|
runId: failed.runId,
|
|
gateId: failed.openGate?.id ?? "missing",
|
|
expectedRevision: failed.revision,
|
|
decision: { kind: "recover", resolution: "retry" },
|
|
idempotencyKey: "workspace-retry-key",
|
|
});
|
|
assert.equal(recovered.workspace.state, "ready");
|
|
assert.equal(recovered.openGate?.kind, "questions");
|
|
assert.equal(setup.workspace.reconcileCalls, 1);
|
|
});
|
|
});
|
|
|
|
test("post-creation discovery failures retain the ready worktree", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", { invalid: true });
|
|
const failed = await setup.engine().execute(startCommand());
|
|
assert.equal(failed.status, "failed");
|
|
assert.equal(failed.workspace.state, "ready");
|
|
assert.equal(failed.error?.code, "invalid-discovery-output");
|
|
});
|
|
});
|
|
|
|
test("discovery question bounds preserve complete model-facing Gate payloads", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", {
|
|
...discoveryWithQuestions(),
|
|
questions: Array.from({ length: 21 }, (_, index) => ({
|
|
id: `question-${index}`,
|
|
prompt: `Question ${index}?`,
|
|
})),
|
|
});
|
|
const failed = await setup.engine().execute(
|
|
startCommand({ idempotencyKey: "too-many-questions" }),
|
|
);
|
|
assert.equal(failed.status, "failed");
|
|
assert.match(failed.error?.message ?? "", /at most 20 questions/);
|
|
});
|
|
});
|
|
|
|
test("startup reconciliation resolves persisted workspace creation intent", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
const started = await setup.engine().execute(startCommand());
|
|
const run = await setup.store.loadRun(started.runId);
|
|
for (const gate of run.gates) gate.status = "satisfied";
|
|
run.workspace.state = "creating";
|
|
run.status = "running";
|
|
run.revision++;
|
|
if (run.lease) run.lease.heartbeatAt = 0;
|
|
await setup.store.saveRun(run);
|
|
|
|
const recovery = setup.engine("session-2", {
|
|
now: () => 100_000,
|
|
processId: 200,
|
|
isProcessAlive: () => false,
|
|
});
|
|
const [recovered] = await recovery.recover();
|
|
assert.equal(setup.workspace.reconcileCalls, 1);
|
|
assert.equal(recovered?.workspace.state, "ready");
|
|
assert.equal(recovered?.openGate?.kind, "recovery");
|
|
});
|
|
});
|
|
|
|
test("feature names and branch collisions fail with actionable guidance", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const setup = await fixture(directory);
|
|
const engine = setup.engine();
|
|
await assert.rejects(
|
|
engine.execute(startCommand({ featureName: "Bad Feature" })),
|
|
/lowercase kebab case/,
|
|
);
|
|
assert.equal(setup.workspace.prepareCalls, 0);
|
|
|
|
setup.workspace.branchExists = true;
|
|
await assert.rejects(
|
|
engine.execute(startCommand({ idempotencyKey: "collision-key" })),
|
|
/choose another descriptive feature name/,
|
|
);
|
|
|
|
setup.workspace.branchExists = false;
|
|
setup.executor.enqueue("discover", discoveryWithQuestions());
|
|
await engine.execute(startCommand({ idempotencyKey: "owner-key" }));
|
|
setup.workspace.branchExists = true;
|
|
const [owner] = await setup.store.listRuns();
|
|
await assert.rejects(
|
|
engine.execute(startCommand({ idempotencyKey: "second-owner-key" })),
|
|
(error: unknown) => {
|
|
assert.match((error as Error).message, /inspect or decide/);
|
|
assert.ok((error as Error).message.includes(owner!.runId));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|