4973 lines
166 KiB
TypeScript
4973 lines
166 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import * as path from "node:path";
|
|
import type {
|
|
AgentProfile,
|
|
AssignmentGitState,
|
|
AssignmentSelection,
|
|
CommandContinuation,
|
|
CommandReceipt,
|
|
FeatureScriptInsertionPoint,
|
|
GateDecision,
|
|
ProfileRole,
|
|
StageEligibilityEvidence,
|
|
UiImpact,
|
|
VerificationCommand,
|
|
VerificationEvidence,
|
|
WorkflowAssignment,
|
|
WorkflowConfig,
|
|
WorkflowFacts,
|
|
WorkflowGate,
|
|
WorkflowRun,
|
|
WorkflowStage,
|
|
} from "./domain.ts";
|
|
import { eligibleProfiles, selectInitialDiscoveryProfile } from "./profiles.ts";
|
|
import { enforceUiImpact } from "./ui-policy.ts";
|
|
import {
|
|
readVerifiedPlanArtifact,
|
|
type StartRecord,
|
|
type WorkflowStore,
|
|
} from "./store.ts";
|
|
import {
|
|
WorkspaceSafetyError,
|
|
type PrepareWorkspaceOptions,
|
|
type WorkspacePlan,
|
|
} from "./workspace.ts";
|
|
|
|
const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
const MAX_FEATURE_NAME_LENGTH = 80;
|
|
const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_000;
|
|
const DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS = 15_000;
|
|
const DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS = 5 * 60 * 1_000;
|
|
const ACTIVE_ASSIGNMENT_TOKENS = new Set<string>();
|
|
interface ActiveAssignmentExecution {
|
|
runId: string;
|
|
assignmentId: string;
|
|
controller: AbortController;
|
|
settled: Promise<void>;
|
|
resolveSettled: () => void;
|
|
resumeAfterAbort: Promise<void>;
|
|
resolveResumeAfterAbort: () => void;
|
|
metadata?: AssignmentExecutionMetadata;
|
|
transcriptArtifact?: WorkflowAssignment["transcriptArtifact"];
|
|
}
|
|
const ACTIVE_ASSIGNMENT_EXECUTIONS = new Map<string, ActiveAssignmentExecution>();
|
|
const ACTIVE_SCRIPT_EXECUTIONS = new Map<
|
|
string,
|
|
{ controller: AbortController; settled: Promise<void>; resolve: () => void }
|
|
>();
|
|
const ACTIVE_STARTS = new Map<
|
|
string,
|
|
{
|
|
fingerprint: string;
|
|
controller: AbortController;
|
|
operation: Promise<WorkflowView>;
|
|
published: Promise<WorkflowView>;
|
|
}
|
|
>();
|
|
|
|
export interface FeatureScriptInput {
|
|
source: string;
|
|
args?: unknown;
|
|
insertionPoint: FeatureScriptInsertionPoint;
|
|
activeToolNames: string[];
|
|
}
|
|
|
|
export interface StartWorkflowCommand {
|
|
action: "start";
|
|
request: string;
|
|
featureName: string;
|
|
idempotencyKey: string;
|
|
concurrency?: number;
|
|
background?: boolean;
|
|
allowDirtySource?: boolean;
|
|
script?: FeatureScriptInput;
|
|
}
|
|
|
|
export interface StartAnalysisWorkflowCommand {
|
|
action: "start-analysis";
|
|
request: string;
|
|
source: string;
|
|
args?: unknown;
|
|
activeToolNames: string[];
|
|
idempotencyKey: string;
|
|
}
|
|
|
|
export interface DecideWorkflowCommand {
|
|
action: "decide";
|
|
runId: string;
|
|
gateId: string;
|
|
expectedRevision: number;
|
|
decision: GateDecision;
|
|
idempotencyKey: string;
|
|
activeToolNames?: string[];
|
|
}
|
|
|
|
export interface InspectWorkflowCommand {
|
|
action: "inspect";
|
|
runId: string;
|
|
}
|
|
|
|
export interface AbortWorkflowCommand {
|
|
action: "abort";
|
|
runId: string;
|
|
expectedRevision: number;
|
|
idempotencyKey: string;
|
|
}
|
|
|
|
interface ContextCommandBase {
|
|
runId: string;
|
|
gateId: string;
|
|
preparationId: string;
|
|
planRevision: number;
|
|
contextRevision: number;
|
|
expectedRevision: number;
|
|
}
|
|
|
|
export interface ContinueContextCommand extends ContextCommandBase {
|
|
action: "continue-context";
|
|
sourceSessionFile?: string;
|
|
}
|
|
|
|
export interface ClaimContextHandoffCommand extends ContextCommandBase {
|
|
action: "claim-context-handoff";
|
|
sourceSessionFile: string;
|
|
targetSessionFile: string;
|
|
reason: "new";
|
|
}
|
|
|
|
export interface ConsumeContextHandoffCommand extends ContextCommandBase {
|
|
action: "consume-context-handoff";
|
|
targetSessionFile: string;
|
|
previousSessionFile: string;
|
|
}
|
|
|
|
export interface CompleteNormalCompactionCommand extends ContextCommandBase {
|
|
action: "complete-normal-compaction";
|
|
outcome: "succeeded" | "no-op" | "failed";
|
|
error?: string;
|
|
}
|
|
|
|
export interface RecoverContextIntentCommand extends ContextCommandBase {
|
|
action: "recover-context-intent";
|
|
reason:
|
|
| "startup"
|
|
| "reload"
|
|
| "resume"
|
|
| "fork"
|
|
| "expired"
|
|
| "cancelled";
|
|
}
|
|
|
|
export type WorkflowCommand =
|
|
| StartWorkflowCommand
|
|
| StartAnalysisWorkflowCommand
|
|
| DecideWorkflowCommand
|
|
| InspectWorkflowCommand
|
|
| AbortWorkflowCommand
|
|
| ContinueContextCommand
|
|
| ClaimContextHandoffCommand
|
|
| ConsumeContextHandoffCommand
|
|
| CompleteNormalCompactionCommand
|
|
| RecoverContextIntentCommand;
|
|
|
|
export interface WorkflowView {
|
|
runId: string;
|
|
featureName?: string;
|
|
request?: string;
|
|
revision: number;
|
|
status: WorkflowRun["status"];
|
|
background: boolean;
|
|
workspace: WorkflowRun["workspace"];
|
|
openGate?: WorkflowGate;
|
|
uiImpact?: UiImpact;
|
|
uiImpactEvidence: string[];
|
|
affectedUiSurfaces: string[];
|
|
planArtifact?: WorkflowRun["planArtifact"];
|
|
reportArtifact?: WorkflowRun["reportArtifact"];
|
|
implementationContext?: WorkflowRun["implementationContext"];
|
|
stages: WorkflowStage[];
|
|
assignments: WorkflowAssignment[];
|
|
mutatorProfileNames: string[];
|
|
reviewCycles: number;
|
|
verificationCycles: number;
|
|
verificationEvidence?: WorkflowRun["verificationEvidence"];
|
|
reviewVerdict?: WorkflowRun["reviewVerdict"];
|
|
error?: WorkflowRun["error"];
|
|
}
|
|
|
|
export interface AssignmentExecutionMetadata {
|
|
childSessionId?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
effort?: string;
|
|
contextCapacity?: number;
|
|
transcript: unknown[];
|
|
gitBefore?: AssignmentGitState;
|
|
gitAfter?: AssignmentGitState;
|
|
}
|
|
|
|
export interface AssignmentExecutionResult {
|
|
kind: "workflow-assignment-result";
|
|
output: unknown;
|
|
metadata: AssignmentExecutionMetadata;
|
|
}
|
|
|
|
export interface AssignmentExecutionError extends Error {
|
|
assignmentMetadata: AssignmentExecutionMetadata;
|
|
}
|
|
|
|
export interface AssignmentRequest {
|
|
runId: string;
|
|
role: ProfileRole | "remediate";
|
|
request: string;
|
|
profile: AgentProfile;
|
|
facts: WorkflowFacts;
|
|
decisions: WorkflowRun["decisions"];
|
|
plannerCandidates?: StageEligibilityEvidence[];
|
|
cwd: string;
|
|
signal: AbortSignal;
|
|
mutates?: boolean;
|
|
ownedPaths?: string[];
|
|
captureGitState?: boolean;
|
|
allowedReadTools?: string[];
|
|
runConcurrency?: number;
|
|
approvedPlan?: string;
|
|
verificationEvidence?: VerificationEvidence;
|
|
reviewDiff?: string;
|
|
onReserved?: () => Promise<void>;
|
|
}
|
|
|
|
export interface AssignmentExecutor {
|
|
readonly reservesCapacity?: boolean;
|
|
run(request: AssignmentRequest): Promise<unknown>;
|
|
}
|
|
|
|
export interface DiscoveryOutput {
|
|
questions: Array<{ id: string; prompt: string }>;
|
|
candidatePaths: string[];
|
|
languages: string[];
|
|
tags: string[];
|
|
riskTags: string[];
|
|
}
|
|
|
|
export interface PlannedAssignmentOutput {
|
|
role: "prototype" | "implement" | "verify" | "review";
|
|
profileName: string;
|
|
rationale: string;
|
|
mutates: boolean;
|
|
ownedPaths: string[];
|
|
}
|
|
|
|
export interface PlannedVerificationCommand {
|
|
executable: string;
|
|
args: string[];
|
|
ownedPaths: string[];
|
|
kind: "test" | "typecheck" | "lint" | "build" | "browser" | "generation";
|
|
}
|
|
|
|
export interface DesignOutput {
|
|
plan: string;
|
|
uiImpact: UiImpact;
|
|
uiImpactEvidence: string[];
|
|
affectedUiSurfaces: string[];
|
|
assignments: PlannedAssignmentOutput[];
|
|
acceptanceCriteria: string[];
|
|
verificationCommands: PlannedVerificationCommand[];
|
|
}
|
|
|
|
export interface PrototypeOutput {
|
|
path: string;
|
|
route: string;
|
|
structuralRationale: string;
|
|
responsiveChecks: string[];
|
|
browserEvidence: string[];
|
|
launchInstructions: string;
|
|
}
|
|
|
|
export interface ImplementationOutput {
|
|
summary: string;
|
|
changedPaths: string[];
|
|
focusedTests: string[];
|
|
}
|
|
|
|
export interface VerificationOutput {
|
|
succeeded: boolean;
|
|
focusedBehavioralTests: boolean;
|
|
summary: string;
|
|
}
|
|
|
|
export interface ReviewOutput {
|
|
specification: {
|
|
verdict: "approved" | "changes_requested";
|
|
findings: Array<{ severity: string; message: string; path?: string }>;
|
|
};
|
|
standards: {
|
|
verdict: "approved" | "changes_requested";
|
|
findings: Array<{ severity: string; message: string; path?: string }>;
|
|
};
|
|
}
|
|
|
|
export interface WorkspaceManager {
|
|
prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan>;
|
|
create(plan: WorkspacePlan): Promise<WorkflowRun["workspace"]>;
|
|
reconcile(
|
|
workspace: WorkflowRun["workspace"],
|
|
expectedWorktreeIdentity?: string,
|
|
): Promise<WorkflowRun["workspace"]>;
|
|
}
|
|
|
|
export interface ReviewDiffProvider {
|
|
read(worktreeRoot: string, baseHead?: string): Promise<string>;
|
|
}
|
|
|
|
export interface HostCommandExecutor {
|
|
run(options: {
|
|
worktreeRoot: string;
|
|
ownedPaths: string[];
|
|
executable: string;
|
|
args: string[];
|
|
}): Promise<{
|
|
result: { code: number; stdout: string; stderr: string };
|
|
}>;
|
|
}
|
|
|
|
export interface PrototypePolicyValidator {
|
|
validateCandidate(options: {
|
|
worktreeRoot: string;
|
|
assignment: WorkflowAssignment;
|
|
output: PrototypeOutput;
|
|
}): Promise<{ artifactFiles: string[]; visualDigest: string }>;
|
|
validateCleanup(options: {
|
|
worktreeRoot: string;
|
|
candidates: WorkflowRun["prototypeCandidates"];
|
|
}): Promise<void>;
|
|
}
|
|
|
|
export interface ScriptRuntime {
|
|
run(options: {
|
|
source: string;
|
|
args: unknown;
|
|
cwd: string;
|
|
signal: AbortSignal;
|
|
onAssign: (
|
|
role: string,
|
|
prompt: string,
|
|
signal: AbortSignal,
|
|
requestId: number,
|
|
) => Promise<{ ok: boolean; output: string; structured?: unknown; error?: string }>;
|
|
onStage: (title: string) => void;
|
|
}): Promise<unknown>;
|
|
}
|
|
|
|
export interface WorkflowEngineOptions {
|
|
store: WorkflowStore;
|
|
workspace: WorkspaceManager;
|
|
executor: AssignmentExecutor;
|
|
hostCommands?: HostCommandExecutor;
|
|
reviewDiff?: ReviewDiffProvider;
|
|
scriptRuntime?: ScriptRuntime;
|
|
prototypeValidator?: PrototypePolicyValidator;
|
|
sourceRoot: string;
|
|
parentSessionId: string;
|
|
effectiveConfig: WorkflowConfig;
|
|
profiles: AgentProfile[];
|
|
now?: () => number;
|
|
createId?: () => string;
|
|
processId?: number;
|
|
isProcessAlive?: (processId: number) => boolean;
|
|
assignmentHeartbeatMs?: number;
|
|
assignmentAbortTimeoutMs?: number;
|
|
assignmentExecutionTimeoutMs?: number;
|
|
afterAssignmentPersist?: (
|
|
assignment: WorkflowAssignment,
|
|
run: WorkflowRun,
|
|
) => void | Promise<void>;
|
|
afterRunPersist?: (run: WorkflowRun) => void | Promise<void>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isAssignmentExecutionResult(
|
|
value: unknown,
|
|
): value is AssignmentExecutionResult {
|
|
return (
|
|
isRecord(value) &&
|
|
value.kind === "workflow-assignment-result" &&
|
|
isRecord(value.metadata) &&
|
|
Array.isArray(value.metadata.transcript)
|
|
);
|
|
}
|
|
|
|
function isAssignmentExecutionError(
|
|
value: unknown,
|
|
): value is AssignmentExecutionError {
|
|
return (
|
|
value instanceof Error &&
|
|
"assignmentMetadata" in value &&
|
|
isRecord(value.assignmentMetadata) &&
|
|
Array.isArray(value.assignmentMetadata.transcript)
|
|
);
|
|
}
|
|
|
|
async function waitForAssignmentStops(
|
|
executions: ActiveAssignmentExecution[],
|
|
timeoutMs = DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS,
|
|
) {
|
|
if (executions.length === 0) return;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
await Promise.race([
|
|
Promise.allSettled(executions.map((execution) => execution.settled)),
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
"Timed out stopping active Assignments; Workflow Run was not aborted",
|
|
),
|
|
),
|
|
timeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function applyExecutionMetadata(
|
|
assignment: WorkflowAssignment,
|
|
metadata: AssignmentExecutionMetadata | undefined,
|
|
transcriptArtifact: WorkflowAssignment["transcriptArtifact"],
|
|
) {
|
|
if (!metadata) return;
|
|
assignment.childSessionId = metadata.childSessionId;
|
|
assignment.effectiveProvider = metadata.provider;
|
|
assignment.effectiveModel = metadata.model;
|
|
assignment.effectiveEffort = metadata.effort;
|
|
assignment.contextCapacity = metadata.contextCapacity;
|
|
assignment.transcriptArtifact = transcriptArtifact;
|
|
assignment.gitBefore = metadata.gitBefore;
|
|
assignment.gitAfter = metadata.gitAfter;
|
|
}
|
|
|
|
function assertKeys(
|
|
value: Record<string, unknown>,
|
|
required: string[],
|
|
optional: string[] = [],
|
|
) {
|
|
const allowed = new Set([...required, ...optional]);
|
|
for (const key of Object.keys(value)) {
|
|
if (!allowed.has(key)) throw new Error(`Unexpected field ${key}`);
|
|
}
|
|
for (const key of required) {
|
|
if (!(key in value)) throw new Error(`Missing required field ${key}`);
|
|
}
|
|
}
|
|
|
|
function nonEmptyString(value: unknown, label: string) {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`${label} must be a non-empty string`);
|
|
}
|
|
}
|
|
|
|
function nonNegativeInteger(value: unknown, label: string) {
|
|
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
throw new Error(`${label} must be a non-negative integer`);
|
|
}
|
|
}
|
|
|
|
function validateDecisionShape(value: unknown): asserts value is GateDecision {
|
|
if (!isRecord(value) || typeof value.kind !== "string") {
|
|
throw new Error("decision must be an object with a kind");
|
|
}
|
|
if (value.kind === "answer-questions") {
|
|
assertKeys(value, ["kind", "answers"]);
|
|
if (!isRecord(value.answers)) throw new Error("answers must be an object");
|
|
for (const [id, answer] of Object.entries(value.answers)) {
|
|
nonEmptyString(id, "question ID");
|
|
nonEmptyString(answer, `answer for ${id}`);
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind === "approve-plan") {
|
|
assertKeys(value, ["kind", "approved", "acceptedUiImpact"], ["comment"]);
|
|
if (value.approved !== true)
|
|
throw new Error("approve-plan requires approved=true");
|
|
if (
|
|
value.acceptedUiImpact !== "none" &&
|
|
value.acceptedUiImpact !== "minor" &&
|
|
value.acceptedUiImpact !== "non-trivial"
|
|
) {
|
|
throw new Error("acceptedUiImpact is invalid");
|
|
}
|
|
if (value.comment !== undefined && typeof value.comment !== "string") {
|
|
throw new Error("comment must be a string");
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind === "revise-plan" || value.kind === "refine-prototypes") {
|
|
assertKeys(value, ["kind", "feedback"]);
|
|
nonEmptyString(value.feedback, "feedback");
|
|
return;
|
|
}
|
|
if (value.kind === "accept-prototype") {
|
|
assertKeys(
|
|
value,
|
|
["kind", "candidateId", "candidateRevision", "browserReviewed"],
|
|
["feedback"],
|
|
);
|
|
nonEmptyString(value.candidateId, "candidateId");
|
|
nonNegativeInteger(value.candidateRevision, "candidateRevision");
|
|
if (value.browserReviewed !== true) {
|
|
throw new Error("accept-prototype requires browserReviewed=true");
|
|
}
|
|
if (value.feedback !== undefined && typeof value.feedback !== "string") {
|
|
throw new Error("feedback must be a string");
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind === "select-implementation-context") {
|
|
assertKeys(value, ["kind", "strategy"]);
|
|
if (
|
|
value.strategy !== "fresh-session" &&
|
|
value.strategy !== "smart-compact" &&
|
|
value.strategy !== "current-session"
|
|
) {
|
|
throw new Error("implementation context strategy is invalid");
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind === "report-context-preparation") {
|
|
assertKeys(
|
|
value,
|
|
["kind", "preparationId", "planRevision", "method", "outcome"],
|
|
["error"],
|
|
);
|
|
nonEmptyString(value.preparationId, "preparationId");
|
|
nonNegativeInteger(value.planRevision, "planRevision");
|
|
if (value.method !== "smart_compact") throw new Error("method is invalid");
|
|
if (
|
|
value.outcome !== "unavailable" &&
|
|
value.outcome !== "succeeded" &&
|
|
value.outcome !== "no-op" &&
|
|
value.outcome !== "failed"
|
|
) {
|
|
throw new Error("context preparation outcome is invalid");
|
|
}
|
|
if (value.outcome === "failed") {
|
|
nonEmptyString(value.error, "context preparation error");
|
|
} else if (value.error !== undefined) {
|
|
throw new Error("context preparation error is only valid for failure");
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind === "recover") {
|
|
assertKeys(value, ["kind", "resolution"]);
|
|
if (
|
|
value.resolution !== "retry" &&
|
|
value.resolution !== "inspect" &&
|
|
value.resolution !== "abort"
|
|
) {
|
|
throw new Error("recovery resolution is invalid");
|
|
}
|
|
return;
|
|
}
|
|
throw new Error(`Unknown decision kind ${value.kind}`);
|
|
}
|
|
|
|
function validateActiveToolNames(value: unknown) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.some((name) => typeof name !== "string" || name.trim().length === 0)
|
|
) {
|
|
throw new Error("activeToolNames must be an array of non-empty strings");
|
|
}
|
|
}
|
|
|
|
function validateFeatureScript(value: unknown) {
|
|
if (!isRecord(value)) throw new Error("script must be an object");
|
|
assertKeys(
|
|
value,
|
|
["source", "insertionPoint", "activeToolNames"],
|
|
["args"],
|
|
);
|
|
nonEmptyString(value.source, "script source");
|
|
if (
|
|
value.insertionPoint !== "before-design" &&
|
|
value.insertionPoint !== "after-design" &&
|
|
value.insertionPoint !== "before-review"
|
|
) {
|
|
throw new Error("script insertionPoint is invalid");
|
|
}
|
|
validateActiveToolNames(value.activeToolNames);
|
|
}
|
|
|
|
function validateWorkflowCommand(
|
|
value: unknown,
|
|
): asserts value is WorkflowCommand {
|
|
if (!isRecord(value) || typeof value.action !== "string") {
|
|
throw new Error("workflow command must be an object with an action");
|
|
}
|
|
if (value.action === "start") {
|
|
assertKeys(
|
|
value,
|
|
["action", "request", "featureName", "idempotencyKey"],
|
|
["allowDirtySource", "background", "concurrency", "script"],
|
|
);
|
|
nonEmptyString(value.request, "request");
|
|
nonEmptyString(value.featureName, "featureName");
|
|
nonEmptyString(value.idempotencyKey, "idempotencyKey");
|
|
if (
|
|
value.concurrency !== undefined &&
|
|
(typeof value.concurrency !== "number" ||
|
|
!Number.isInteger(value.concurrency) ||
|
|
value.concurrency < 1)
|
|
) {
|
|
throw new Error("concurrency must be a positive integer");
|
|
}
|
|
if (
|
|
value.background !== undefined &&
|
|
typeof value.background !== "boolean"
|
|
) {
|
|
throw new Error("background must be a boolean");
|
|
}
|
|
if (
|
|
value.allowDirtySource !== undefined &&
|
|
typeof value.allowDirtySource !== "boolean"
|
|
) {
|
|
throw new Error("allowDirtySource must be a boolean");
|
|
}
|
|
if (value.script !== undefined) validateFeatureScript(value.script);
|
|
return;
|
|
}
|
|
if (value.action === "start-analysis") {
|
|
assertKeys(
|
|
value,
|
|
["action", "request", "source", "activeToolNames", "idempotencyKey"],
|
|
["args"],
|
|
);
|
|
nonEmptyString(value.request, "request");
|
|
nonEmptyString(value.source, "source");
|
|
nonEmptyString(value.idempotencyKey, "idempotencyKey");
|
|
validateActiveToolNames(value.activeToolNames);
|
|
return;
|
|
}
|
|
if (value.action === "inspect") {
|
|
assertKeys(value, ["action", "runId"]);
|
|
nonEmptyString(value.runId, "runId");
|
|
return;
|
|
}
|
|
if (value.action === "abort") {
|
|
assertKeys(value, [
|
|
"action",
|
|
"runId",
|
|
"expectedRevision",
|
|
"idempotencyKey",
|
|
]);
|
|
nonEmptyString(value.runId, "runId");
|
|
nonNegativeInteger(value.expectedRevision, "expectedRevision");
|
|
nonEmptyString(value.idempotencyKey, "idempotencyKey");
|
|
return;
|
|
}
|
|
if (
|
|
value.action === "continue-context" ||
|
|
value.action === "claim-context-handoff" ||
|
|
value.action === "consume-context-handoff" ||
|
|
value.action === "complete-normal-compaction" ||
|
|
value.action === "recover-context-intent"
|
|
) {
|
|
const actionFields: Record<string, { required: string[]; optional?: string[] }> = {
|
|
"continue-context": { required: [], optional: ["sourceSessionFile"] },
|
|
"claim-context-handoff": {
|
|
required: ["sourceSessionFile", "targetSessionFile", "reason"],
|
|
},
|
|
"consume-context-handoff": {
|
|
required: ["targetSessionFile", "previousSessionFile"],
|
|
},
|
|
"complete-normal-compaction": {
|
|
required: ["outcome"],
|
|
optional: ["error"],
|
|
},
|
|
"recover-context-intent": { required: ["reason"] },
|
|
};
|
|
const fields = actionFields[value.action];
|
|
if (!fields) throw new Error("Unknown context command");
|
|
assertKeys(
|
|
value,
|
|
[
|
|
"action",
|
|
"runId",
|
|
"gateId",
|
|
"preparationId",
|
|
"planRevision",
|
|
"contextRevision",
|
|
"expectedRevision",
|
|
...fields.required,
|
|
],
|
|
fields.optional,
|
|
);
|
|
nonEmptyString(value.runId, "runId");
|
|
nonEmptyString(value.gateId, "gateId");
|
|
nonEmptyString(value.preparationId, "preparationId");
|
|
nonNegativeInteger(value.planRevision, "planRevision");
|
|
nonNegativeInteger(value.contextRevision, "contextRevision");
|
|
nonNegativeInteger(value.expectedRevision, "expectedRevision");
|
|
if ("sourceSessionFile" in value && value.sourceSessionFile !== undefined) {
|
|
nonEmptyString(value.sourceSessionFile, "sourceSessionFile");
|
|
}
|
|
if ("targetSessionFile" in value) {
|
|
nonEmptyString(value.targetSessionFile, "targetSessionFile");
|
|
}
|
|
if ("previousSessionFile" in value) {
|
|
nonEmptyString(value.previousSessionFile, "previousSessionFile");
|
|
}
|
|
if (value.action === "claim-context-handoff" && value.reason !== "new") {
|
|
throw new Error("handoff claim reason must be new");
|
|
}
|
|
if (value.action === "recover-context-intent") {
|
|
if (
|
|
value.reason !== "startup" &&
|
|
value.reason !== "reload" &&
|
|
value.reason !== "resume" &&
|
|
value.reason !== "fork" &&
|
|
value.reason !== "expired" &&
|
|
value.reason !== "cancelled"
|
|
) {
|
|
throw new Error("context recovery reason is invalid");
|
|
}
|
|
}
|
|
if (value.action === "complete-normal-compaction") {
|
|
if (
|
|
value.outcome !== "succeeded" &&
|
|
value.outcome !== "no-op" &&
|
|
value.outcome !== "failed"
|
|
) {
|
|
throw new Error("normal compaction outcome is invalid");
|
|
}
|
|
if (value.outcome === "failed") {
|
|
nonEmptyString(value.error, "normal compaction error");
|
|
} else if (value.error !== undefined) {
|
|
throw new Error("normal compaction error is only valid for failure");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (value.action === "decide") {
|
|
assertKeys(value, [
|
|
"action",
|
|
"runId",
|
|
"gateId",
|
|
"expectedRevision",
|
|
"decision",
|
|
"idempotencyKey",
|
|
], ["activeToolNames"]);
|
|
nonEmptyString(value.runId, "runId");
|
|
nonEmptyString(value.gateId, "gateId");
|
|
nonNegativeInteger(value.expectedRevision, "expectedRevision");
|
|
nonEmptyString(value.idempotencyKey, "idempotencyKey");
|
|
if (
|
|
value.activeToolNames !== undefined &&
|
|
(!Array.isArray(value.activeToolNames) ||
|
|
value.activeToolNames.some((name) => typeof name !== "string"))
|
|
) {
|
|
throw new Error("activeToolNames must be an array of strings");
|
|
}
|
|
validateDecisionShape(value.decision);
|
|
return;
|
|
}
|
|
throw new Error(`Unknown workflow action ${value.action}`);
|
|
}
|
|
|
|
function stringList(value: unknown, label: string): string[] {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.some((entry) => typeof entry !== "string")
|
|
) {
|
|
throw new Error(`${label} must be an array of strings`);
|
|
}
|
|
return value.map((entry) => entry.trim()).filter(Boolean);
|
|
}
|
|
|
|
function discoveryOutput(value: unknown): DiscoveryOutput {
|
|
if (!isRecord(value) || !Array.isArray(value.questions)) {
|
|
throw new Error("Discovery Assignment returned an invalid result");
|
|
}
|
|
if (value.questions.length > 20) {
|
|
throw new Error("Discovery may return at most 20 questions");
|
|
}
|
|
const questions = value.questions.map((question, index) => {
|
|
if (
|
|
!isRecord(question) ||
|
|
typeof question.id !== "string" ||
|
|
question.id.trim().length === 0 ||
|
|
question.id.length > 160 ||
|
|
typeof question.prompt !== "string" ||
|
|
question.prompt.trim().length === 0 ||
|
|
question.prompt.length > 2_000
|
|
) {
|
|
throw new Error(`Discovery question ${index} is invalid`);
|
|
}
|
|
return { id: question.id.trim(), prompt: question.prompt.trim() };
|
|
});
|
|
if (
|
|
new Set(questions.map((question) => question.id)).size !== questions.length
|
|
) {
|
|
throw new Error("Discovery question IDs must be unique");
|
|
}
|
|
return {
|
|
questions,
|
|
candidatePaths: stringList(value.candidatePaths ?? [], "candidatePaths"),
|
|
languages: stringList(value.languages ?? [], "languages"),
|
|
tags: stringList(value.tags ?? [], "tags").map((entry) =>
|
|
entry.toLowerCase(),
|
|
),
|
|
riskTags: stringList(value.riskTags ?? [], "riskTags").map((entry) =>
|
|
entry.toLowerCase(),
|
|
),
|
|
};
|
|
}
|
|
|
|
function designOutput(value: unknown): DesignOutput {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.plan !== "string" ||
|
|
value.plan.trim().length === 0 ||
|
|
(value.uiImpact !== "none" &&
|
|
value.uiImpact !== "minor" &&
|
|
value.uiImpact !== "non-trivial")
|
|
) {
|
|
throw new Error("Design Assignment returned an invalid result");
|
|
}
|
|
const rawAssignments = value.assignments ?? [];
|
|
if (!Array.isArray(rawAssignments)) {
|
|
throw new Error("Design assignments must be an array");
|
|
}
|
|
const assignments = rawAssignments.map((assignment, index) => {
|
|
if (
|
|
!isRecord(assignment) ||
|
|
(assignment.role !== "prototype" &&
|
|
assignment.role !== "implement" &&
|
|
assignment.role !== "verify" &&
|
|
assignment.role !== "review") ||
|
|
typeof assignment.profileName !== "string" ||
|
|
assignment.profileName.trim().length === 0 ||
|
|
typeof assignment.rationale !== "string" ||
|
|
assignment.rationale.trim().length === 0 ||
|
|
typeof assignment.mutates !== "boolean"
|
|
) {
|
|
throw new Error(`Design assignment ${index} is invalid`);
|
|
}
|
|
const role = assignment.role as PlannedAssignmentOutput["role"];
|
|
return {
|
|
role,
|
|
profileName: assignment.profileName.trim(),
|
|
rationale: assignment.rationale.trim(),
|
|
mutates: assignment.mutates,
|
|
ownedPaths: stringList(
|
|
assignment.ownedPaths,
|
|
`Design assignment ${index} ownedPaths`,
|
|
),
|
|
};
|
|
});
|
|
const acceptanceCriteria = stringList(
|
|
value.acceptanceCriteria ?? [],
|
|
"acceptanceCriteria",
|
|
);
|
|
const rawCommands = value.verificationCommands ?? [];
|
|
if (!Array.isArray(rawCommands)) {
|
|
throw new Error("verificationCommands must be an array");
|
|
}
|
|
const verificationCommands = rawCommands.map((command, index) => {
|
|
if (
|
|
!isRecord(command) ||
|
|
typeof command.executable !== "string" ||
|
|
command.executable.trim().length === 0 ||
|
|
(command.kind !== "test" &&
|
|
command.kind !== "typecheck" &&
|
|
command.kind !== "lint" &&
|
|
command.kind !== "build" &&
|
|
command.kind !== "browser" &&
|
|
command.kind !== "generation")
|
|
) {
|
|
throw new Error(`Verification command ${index} is invalid`);
|
|
}
|
|
return {
|
|
executable: command.executable.trim(),
|
|
args: stringList(command.args, `Verification command ${index} args`),
|
|
ownedPaths: stringList(
|
|
command.ownedPaths,
|
|
`Verification command ${index} ownedPaths`,
|
|
),
|
|
kind: command.kind as PlannedVerificationCommand["kind"],
|
|
};
|
|
});
|
|
if (acceptanceCriteria.length === 0) {
|
|
throw new Error("Design requires at least one acceptance criterion");
|
|
}
|
|
if (!verificationCommands.some((command) => command.kind === "test")) {
|
|
throw new Error("Design requires at least one focused behavioral test command");
|
|
}
|
|
return {
|
|
plan: value.plan.trim(),
|
|
uiImpact: value.uiImpact,
|
|
uiImpactEvidence: stringList(
|
|
value.uiImpactEvidence ?? [],
|
|
"uiImpactEvidence",
|
|
),
|
|
affectedUiSurfaces: stringList(
|
|
value.affectedUiSurfaces ?? [],
|
|
"affectedUiSurfaces",
|
|
),
|
|
assignments,
|
|
acceptanceCriteria,
|
|
verificationCommands,
|
|
};
|
|
}
|
|
|
|
function prototypeOutput(value: unknown): PrototypeOutput {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.path !== "string" ||
|
|
value.path.trim().length === 0 ||
|
|
typeof value.route !== "string" ||
|
|
value.route.trim().length === 0 ||
|
|
typeof value.structuralRationale !== "string" ||
|
|
value.structuralRationale.trim().length === 0 ||
|
|
typeof value.launchInstructions !== "string" ||
|
|
value.launchInstructions.trim().length === 0
|
|
) {
|
|
throw new Error("Prototype Assignment returned an invalid result");
|
|
}
|
|
const responsiveChecks = stringList(
|
|
value.responsiveChecks,
|
|
"prototype responsiveChecks",
|
|
);
|
|
const browserEvidence = stringList(
|
|
value.browserEvidence,
|
|
"prototype browserEvidence",
|
|
);
|
|
if (responsiveChecks.length === 0 || browserEvidence.length === 0) {
|
|
throw new Error("Prototype requires responsive checks and browser evidence");
|
|
}
|
|
return {
|
|
path: value.path.trim(),
|
|
route: value.route.trim(),
|
|
structuralRationale: value.structuralRationale.trim(),
|
|
responsiveChecks,
|
|
browserEvidence,
|
|
launchInstructions: value.launchInstructions.trim(),
|
|
};
|
|
}
|
|
|
|
function implementationOutput(value: unknown): ImplementationOutput {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.summary !== "string" ||
|
|
value.summary.trim().length === 0
|
|
) {
|
|
throw new Error("Implementation Assignment returned an invalid result");
|
|
}
|
|
return {
|
|
summary: value.summary.trim(),
|
|
changedPaths: stringList(value.changedPaths ?? [], "changedPaths"),
|
|
focusedTests: stringList(value.focusedTests ?? [], "focusedTests"),
|
|
};
|
|
}
|
|
|
|
function verificationOutput(value: unknown): VerificationOutput {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.succeeded !== "boolean" ||
|
|
typeof value.focusedBehavioralTests !== "boolean" ||
|
|
typeof value.summary !== "string" ||
|
|
value.summary.trim().length === 0
|
|
) {
|
|
throw new Error("Verification Assignment returned an invalid result");
|
|
}
|
|
return {
|
|
succeeded: value.succeeded,
|
|
focusedBehavioralTests: value.focusedBehavioralTests,
|
|
summary: value.summary.trim(),
|
|
};
|
|
}
|
|
|
|
function reviewAxis(value: unknown, label: string): ReviewOutput["specification"] {
|
|
if (
|
|
!isRecord(value) ||
|
|
(value.verdict !== "approved" && value.verdict !== "changes_requested") ||
|
|
!Array.isArray(value.findings)
|
|
) {
|
|
throw new Error(`${label} review axis is invalid`);
|
|
}
|
|
return {
|
|
verdict: value.verdict,
|
|
findings: value.findings.map((finding, index) => {
|
|
if (
|
|
!isRecord(finding) ||
|
|
typeof finding.severity !== "string" ||
|
|
typeof finding.message !== "string" ||
|
|
finding.message.trim().length === 0 ||
|
|
(finding.path !== undefined && typeof finding.path !== "string")
|
|
) {
|
|
throw new Error(`${label} review finding ${index} is invalid`);
|
|
}
|
|
return {
|
|
severity: finding.severity,
|
|
message: finding.message.trim(),
|
|
...(finding.path ? { path: finding.path } : {}),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function reviewOutput(value: unknown): ReviewOutput {
|
|
if (!isRecord(value)) throw new Error("Review Assignment returned an invalid result");
|
|
return {
|
|
specification: reviewAxis(value.specification, "Specification"),
|
|
standards: reviewAxis(value.standards, "Standards"),
|
|
};
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
if (isRecord(value)) {
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function fingerprint(value: unknown) {
|
|
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
}
|
|
|
|
function requestTerms(request: string) {
|
|
return [
|
|
...new Set(
|
|
request
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9-]+/)
|
|
.map((term) => term.trim())
|
|
.filter(Boolean),
|
|
),
|
|
];
|
|
}
|
|
|
|
function activeGate(run: WorkflowRun) {
|
|
for (let index = run.gates.length - 1; index >= 0; index--) {
|
|
const gate = run.gates[index];
|
|
if (gate?.status === "open") return gate;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isTerminalStatus(status: WorkflowRun["status"]) {
|
|
return status === "completed" || status === "failed" || status === "aborted";
|
|
}
|
|
|
|
function openGate(run: WorkflowRun, gate: WorkflowGate) {
|
|
run.gates.push(gate);
|
|
run.deliveries.push({
|
|
key: `gate:${run.runId}:${gate.id}:${gate.revision}`,
|
|
state: "pending",
|
|
createdAt: gate.openedAt,
|
|
});
|
|
}
|
|
|
|
function view(run: WorkflowRun): WorkflowView {
|
|
return {
|
|
runId: run.runId,
|
|
...(run.featureName ? { featureName: run.featureName } : {}),
|
|
request: run.request,
|
|
revision: run.revision,
|
|
status: run.status,
|
|
background: run.background,
|
|
workspace: structuredClone(run.workspace),
|
|
...(activeGate(run) ? { openGate: structuredClone(activeGate(run)) } : {}),
|
|
...(run.uiImpact ? { uiImpact: run.uiImpact } : {}),
|
|
uiImpactEvidence: [...run.uiImpactEvidence],
|
|
affectedUiSurfaces: [...run.affectedUiSurfaces],
|
|
...(run.planArtifact ? { planArtifact: { ...run.planArtifact } } : {}),
|
|
...(run.reportArtifact ? { reportArtifact: { ...run.reportArtifact } } : {}),
|
|
...(run.implementationContext
|
|
? { implementationContext: structuredClone(run.implementationContext) }
|
|
: {}),
|
|
stages: structuredClone(run.stages),
|
|
assignments: structuredClone(run.assignments),
|
|
mutatorProfileNames: [...run.mutatorProfileNames],
|
|
reviewCycles: run.reviewCycles,
|
|
verificationCycles: run.verificationCycles,
|
|
...(run.verificationEvidence
|
|
? { verificationEvidence: structuredClone(run.verificationEvidence) }
|
|
: {}),
|
|
...(run.reviewVerdict
|
|
? { reviewVerdict: structuredClone(run.reviewVerdict) }
|
|
: {}),
|
|
...(run.error ? { error: { ...run.error } } : {}),
|
|
};
|
|
}
|
|
|
|
function selectionEvidence(
|
|
profiles: AgentProfile[],
|
|
facts: WorkflowFacts,
|
|
role: ProfileRole,
|
|
): { profile: AgentProfile; selection: AssignmentSelection } {
|
|
const evaluations = eligibleProfiles(profiles, facts, role);
|
|
const selected = evaluations.find((evaluation) => evaluation.eligible);
|
|
if (!selected)
|
|
throw new Error(`No eligible ${role} Agent Profile is available`);
|
|
return {
|
|
profile: selected.profile,
|
|
selection: {
|
|
eligibleProfiles: evaluations.map((evaluation) => ({
|
|
profileName: evaluation.profile.name,
|
|
profileDigest: evaluation.profile.digest,
|
|
eligible: evaluation.eligible,
|
|
reasons: [...evaluation.reasons],
|
|
specificity: evaluation.specificity,
|
|
})),
|
|
selectedProfileName: selected.profile.name,
|
|
rationale: `Selected the highest-specificity eligible ${role} profile with a code-point name tie-breaker.`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function stage(run: WorkflowRun, kind: WorkflowStage["kind"]) {
|
|
const found = run.stages.find((candidate) => candidate.kind === kind);
|
|
if (!found) throw new Error(`Workflow stage ${kind} is missing`);
|
|
return found;
|
|
}
|
|
|
|
export function decisionIsLegal(
|
|
gateKind: WorkflowGate["kind"],
|
|
decisionKind: GateDecision["kind"],
|
|
) {
|
|
return (
|
|
(gateKind === "questions" && decisionKind === "answer-questions") ||
|
|
(gateKind === "plan-approval" &&
|
|
(decisionKind === "approve-plan" || decisionKind === "revise-plan")) ||
|
|
(gateKind === "prototype-acceptance" &&
|
|
(decisionKind === "accept-prototype" ||
|
|
decisionKind === "refine-prototypes")) ||
|
|
(gateKind === "implementation-context" &&
|
|
(decisionKind === "select-implementation-context" ||
|
|
decisionKind === "report-context-preparation")) ||
|
|
(gateKind === "recovery" && decisionKind === "recover")
|
|
);
|
|
}
|
|
|
|
function decisionContinuation(
|
|
gateKind: WorkflowGate["kind"],
|
|
decision: GateDecision,
|
|
): CommandContinuation {
|
|
if (gateKind === "questions") return "design";
|
|
if (gateKind === "plan-approval" && decision.kind === "revise-plan") {
|
|
return "design";
|
|
}
|
|
if (gateKind === "plan-approval" && decision.kind === "approve-plan") {
|
|
return "prototype";
|
|
}
|
|
if (gateKind === "prototype-acceptance") return "prototype";
|
|
if (
|
|
gateKind === "implementation-context" &&
|
|
((decision.kind === "select-implementation-context" &&
|
|
decision.strategy === "current-session") ||
|
|
(decision.kind === "report-context-preparation" &&
|
|
(decision.outcome === "succeeded" || decision.outcome === "no-op")))
|
|
) {
|
|
return "implementation";
|
|
}
|
|
if (gateKind === "recovery") return "recovery";
|
|
return "none";
|
|
}
|
|
|
|
function legalDecision(gate: WorkflowGate, decision: GateDecision) {
|
|
if (!decisionIsLegal(gate.kind, decision.kind)) {
|
|
throw new Error(`Decision ${decision.kind} is not legal for ${gate.kind}`);
|
|
}
|
|
}
|
|
|
|
function validateDecisionPayload(gate: WorkflowGate, decision: GateDecision) {
|
|
if (decision.kind === "answer-questions") {
|
|
if (!isRecord(gate.payload) || !Array.isArray(gate.payload.questions)) {
|
|
throw new Error("Questions Gate payload is invalid");
|
|
}
|
|
for (const question of gate.payload.questions) {
|
|
if (!isRecord(question) || typeof question.id !== "string") {
|
|
throw new Error("Questions Gate payload is invalid");
|
|
}
|
|
const answer = decision.answers[question.id];
|
|
if (typeof answer !== "string" || answer.trim().length === 0) {
|
|
throw new Error(`Question ${question.id} requires an answer`);
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
(decision.kind === "revise-plan" ||
|
|
decision.kind === "refine-prototypes") &&
|
|
decision.feedback.trim().length === 0
|
|
) {
|
|
throw new Error(`${decision.kind} feedback is required`);
|
|
}
|
|
if (decision.kind === "accept-prototype") {
|
|
if (!isRecord(gate.payload) || !Array.isArray(gate.payload.candidates)) {
|
|
throw new Error("Prototype Acceptance Gate payload is invalid");
|
|
}
|
|
const candidate = gate.payload.candidates.find(
|
|
(entry) =>
|
|
isRecord(entry) &&
|
|
entry.id === decision.candidateId &&
|
|
entry.revision === decision.candidateRevision,
|
|
);
|
|
if (!candidate) {
|
|
throw new Error("Accepted prototype candidate or revision is stale");
|
|
}
|
|
}
|
|
if (
|
|
decision.kind === "report-context-preparation" &&
|
|
decision.outcome === "failed" &&
|
|
decision.error.trim().length === 0
|
|
) {
|
|
throw new Error("Failed context preparation requires an actionable error");
|
|
}
|
|
}
|
|
|
|
function processAlive(processId: number) {
|
|
try {
|
|
process.kill(processId, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export class WorkflowEngine {
|
|
private readonly options: WorkflowEngineOptions;
|
|
private readonly now: () => number;
|
|
private readonly createId: () => string;
|
|
private readonly processId: number;
|
|
private readonly isProcessAlive: (processId: number) => boolean;
|
|
private readonly assignmentHeartbeatMs: number;
|
|
private readonly assignmentAbortTimeoutMs: number;
|
|
private readonly assignmentExecutionTimeoutMs: number;
|
|
|
|
constructor(options: WorkflowEngineOptions) {
|
|
this.options = options;
|
|
this.now = options.now ?? Date.now;
|
|
this.createId = options.createId ?? randomUUID;
|
|
this.processId = options.processId ?? process.pid;
|
|
this.isProcessAlive = options.isProcessAlive ?? processAlive;
|
|
this.assignmentHeartbeatMs =
|
|
options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS;
|
|
this.assignmentAbortTimeoutMs =
|
|
options.assignmentAbortTimeoutMs ?? DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS;
|
|
this.assignmentExecutionTimeoutMs =
|
|
options.assignmentExecutionTimeoutMs ?? DEFAULT_ASSIGNMENT_EXECUTION_TIMEOUT_MS;
|
|
if (this.assignmentExecutionTimeoutMs < 1) {
|
|
throw new Error("Assignment execution timeout must be positive");
|
|
}
|
|
}
|
|
|
|
async cancelStart(
|
|
idempotencyKey: string,
|
|
abortIdempotencyKey: string,
|
|
reason: Error,
|
|
): Promise<void> {
|
|
const activeKey = `${this.options.store.root}\0${idempotencyKey}`;
|
|
const active = ACTIVE_STARTS.get(activeKey);
|
|
if (!active) {
|
|
const start = await this.options.store.getStart(idempotencyKey);
|
|
if (!start) return;
|
|
const terminal = await this.abortCancelledStart(
|
|
start.runId,
|
|
abortIdempotencyKey,
|
|
);
|
|
if (terminal) {
|
|
await this.options.store.completeStart(idempotencyKey, terminal);
|
|
}
|
|
return;
|
|
}
|
|
|
|
active.controller.abort(reason);
|
|
const published = await Promise.race([
|
|
active.published.then(
|
|
(result) => result,
|
|
() => undefined,
|
|
),
|
|
active.operation.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
),
|
|
]);
|
|
let terminal = published
|
|
? await this.abortCancelledStart(published.runId, abortIdempotencyKey)
|
|
: undefined;
|
|
await active.operation.catch(() => {});
|
|
|
|
if (!terminal) {
|
|
const start = await this.options.store.getStart(idempotencyKey);
|
|
if (start) {
|
|
terminal = await this.abortCancelledStart(
|
|
start.runId,
|
|
abortIdempotencyKey,
|
|
);
|
|
}
|
|
}
|
|
if (terminal) {
|
|
await this.options.store.completeStart(idempotencyKey, terminal);
|
|
}
|
|
}
|
|
|
|
async execute(command: WorkflowCommand): Promise<WorkflowView> {
|
|
validateWorkflowCommand(command);
|
|
if (command.action === "start") return this.start(command);
|
|
if (command.action === "start-analysis") return this.startAnalysis(command);
|
|
let run = await this.options.store.loadRun(command.runId);
|
|
if (command.action === "inspect") return view(run);
|
|
if (command.action === "consume-context-handoff") {
|
|
return this.consumeContextHandoff(run, command);
|
|
}
|
|
if (command.action === "recover-context-intent") {
|
|
return this.recoverContextIntent(run, command);
|
|
}
|
|
run = this.reclaimStaleRecoveryLease(run, command);
|
|
this.assertActiveParent(run);
|
|
if (command.action === "abort") return this.abort(run, command);
|
|
if (command.action === "decide") return this.decide(run, command);
|
|
if (command.action === "continue-context") {
|
|
return this.continueContext(run, command);
|
|
}
|
|
if (command.action === "claim-context-handoff") {
|
|
return this.claimContextHandoff(run, command);
|
|
}
|
|
return this.completeNormalCompaction(run, command);
|
|
}
|
|
|
|
async recover() {
|
|
const recovered: WorkflowView[] = [];
|
|
for (const current of await this.options.store.listRuns()) {
|
|
if (
|
|
current.status === "completed" ||
|
|
current.status === "failed" ||
|
|
current.status === "aborted"
|
|
) {
|
|
continue;
|
|
}
|
|
let run = current;
|
|
if (
|
|
run.workspace.state === "creating" &&
|
|
run.lease &&
|
|
this.isProcessAlive(run.lease.processId)
|
|
) {
|
|
continue;
|
|
}
|
|
if (run.workspace.state === "creating") {
|
|
const workspace = await this.options.workspace.reconcile(
|
|
run.workspace,
|
|
run.lease?.worktreeIdentity,
|
|
);
|
|
run = await this.persistWorkspaceOutcome(run, workspace);
|
|
if (run.workspace.state !== "ready") {
|
|
recovered.push(view(run));
|
|
continue;
|
|
}
|
|
}
|
|
if (
|
|
run.implementationContext?.status === "normal-compact-running" &&
|
|
this.ownershipIsRecoverable(run)
|
|
) {
|
|
run = await this.transition(run, (next) => {
|
|
this.rotateOwnership(next);
|
|
const context = next.implementationContext;
|
|
if (!context) throw new Error("Implementation Context state is missing");
|
|
context.status = "failed";
|
|
context.outcome = "failed";
|
|
context.error =
|
|
"Normal compaction was interrupted before its callback completed";
|
|
context.runRevision = next.revision + 1;
|
|
next.status = "waiting";
|
|
});
|
|
recovered.push(view(run));
|
|
continue;
|
|
}
|
|
const running = run.assignments.filter(
|
|
(assignment) =>
|
|
assignment.status === "running" ||
|
|
((assignment.status === "queued" || assignment.status === "interrupted") &&
|
|
Boolean(assignment.executionToken)),
|
|
);
|
|
const alreadyWaiting = activeGate(run)?.kind === "recovery";
|
|
if (
|
|
running.length === 0 &&
|
|
alreadyWaiting &&
|
|
!this.ownershipIsRecoverable(run)
|
|
) {
|
|
continue;
|
|
}
|
|
if (await this.hasActiveAssignmentLease(run.runId, running)) continue;
|
|
if (!this.ownershipIsRecoverable(run)) continue;
|
|
run = await this.transition(run, (next) => {
|
|
this.rotateOwnership(next);
|
|
});
|
|
if (running.length === 0) {
|
|
const claimedRevision = run.revision;
|
|
const resumed = await this.resumePersistedContinuation(run);
|
|
if (resumed.revision !== claimedRevision) {
|
|
recovered.push(view(resumed));
|
|
continue;
|
|
}
|
|
}
|
|
for (const assignment of running) {
|
|
if (assignment.executionToken) {
|
|
await this.options.store.releaseAssignmentLease(
|
|
run.runId,
|
|
assignment.id,
|
|
assignment.executionToken,
|
|
);
|
|
}
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const interruptedIds = new Set(running.map((assignment) => assignment.id));
|
|
for (const assignment of next.assignments) {
|
|
if (interruptedIds.has(assignment.id)) assignment.status = "interrupted";
|
|
}
|
|
this.rotateOwnership(next);
|
|
next.status = "waiting";
|
|
if (!activeGate(next)) {
|
|
const gate: WorkflowGate = {
|
|
id: this.createId(),
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
interruptedAssignmentIds: running.map(
|
|
(assignment) => assignment.id,
|
|
),
|
|
potentiallyMutating: running.some(
|
|
(assignment) => assignment.mutates,
|
|
),
|
|
},
|
|
};
|
|
openGate(next, gate);
|
|
}
|
|
});
|
|
recovered.push(view(run));
|
|
}
|
|
return recovered;
|
|
}
|
|
|
|
private start(command: StartWorkflowCommand) {
|
|
const commandFingerprint = fingerprint(command);
|
|
const activeKey = `${this.options.store.root}\0${command.idempotencyKey}`;
|
|
const active = ACTIVE_STARTS.get(activeKey);
|
|
if (active) {
|
|
if (active.fingerprint !== commandFingerprint) {
|
|
return Promise.reject(
|
|
new Error("idempotency key is active for another start command"),
|
|
);
|
|
}
|
|
return command.background
|
|
? this.latestBackgroundView(active.published)
|
|
: active.operation;
|
|
}
|
|
let resolvePublished = (_view: WorkflowView) => {};
|
|
let rejectPublished = (_error: unknown) => {};
|
|
const published = new Promise<WorkflowView>((resolve, reject) => {
|
|
resolvePublished = resolve;
|
|
rejectPublished = reject;
|
|
});
|
|
const controller = new AbortController();
|
|
const operation = this.startOwned(command, controller.signal, resolvePublished);
|
|
void operation.catch(rejectPublished);
|
|
void published.catch(() => {});
|
|
ACTIVE_STARTS.set(activeKey, {
|
|
fingerprint: commandFingerprint,
|
|
controller,
|
|
operation,
|
|
published,
|
|
});
|
|
void operation
|
|
.finally(() => {
|
|
if (ACTIVE_STARTS.get(activeKey)?.operation === operation) {
|
|
ACTIVE_STARTS.delete(activeKey);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
return command.background ? published : operation;
|
|
}
|
|
|
|
private async latestBackgroundView(published: Promise<WorkflowView>) {
|
|
const initial = await published;
|
|
try {
|
|
return view(await this.options.store.loadRun(initial.runId));
|
|
} catch {
|
|
return initial;
|
|
}
|
|
}
|
|
|
|
private startAnalysis(command: StartAnalysisWorkflowCommand) {
|
|
const commandFingerprint = fingerprint(command);
|
|
const activeKey = `${this.options.store.root}\0${command.idempotencyKey}`;
|
|
const active = ACTIVE_STARTS.get(activeKey);
|
|
if (active) {
|
|
if (active.fingerprint !== commandFingerprint) {
|
|
return Promise.reject(
|
|
new Error("idempotency key is active for another start command"),
|
|
);
|
|
}
|
|
return active.operation;
|
|
}
|
|
let resolvePublished = (_view: WorkflowView) => {};
|
|
let rejectPublished = (_error: unknown) => {};
|
|
const published = new Promise<WorkflowView>((resolve, reject) => {
|
|
resolvePublished = resolve;
|
|
rejectPublished = reject;
|
|
});
|
|
const controller = new AbortController();
|
|
const operation = this.startAnalysisOwned(
|
|
command,
|
|
controller.signal,
|
|
resolvePublished,
|
|
);
|
|
void operation.catch(rejectPublished);
|
|
void published.catch(() => {});
|
|
ACTIVE_STARTS.set(activeKey, {
|
|
fingerprint: commandFingerprint,
|
|
controller,
|
|
operation,
|
|
published,
|
|
});
|
|
void operation
|
|
.finally(() => {
|
|
if (ACTIVE_STARTS.get(activeKey)?.operation === operation) {
|
|
ACTIVE_STARTS.delete(activeKey);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
return operation;
|
|
}
|
|
|
|
private async startAnalysisOwned(
|
|
command: StartAnalysisWorkflowCommand,
|
|
cancellationSignal: AbortSignal,
|
|
onPublished?: (view: WorkflowView) => void,
|
|
) {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (!this.options.scriptRuntime) {
|
|
throw new Error("Analysis scripts require the restricted script runtime");
|
|
}
|
|
const commandFingerprint = fingerprint(command);
|
|
const existing = await this.options.store.getStart(command.idempotencyKey);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (existing) {
|
|
this.validateExistingStart(existing, commandFingerprint);
|
|
if (existing.status === "completed") {
|
|
const result = existing.result as unknown as WorkflowView;
|
|
onPublished?.(result);
|
|
return result;
|
|
}
|
|
}
|
|
const runId = existing?.runId ?? this.createId();
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const reservation = await this.options.store.reserveStart(
|
|
command.idempotencyKey,
|
|
commandFingerprint,
|
|
runId,
|
|
);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
this.validateExistingStart(reservation.record, commandFingerprint);
|
|
let run: WorkflowRun;
|
|
try {
|
|
run = await this.options.store.loadRun(reservation.record.runId);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = this.initialAnalysisRun(reservation.record.runId, command);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = (await this.options.store.createRun(run)).run;
|
|
}
|
|
onPublished?.(view(run));
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (run.status !== "completed" && run.status !== "failed") {
|
|
run = await this.runWorkflowScript(run);
|
|
}
|
|
const result = view(run);
|
|
await this.options.store.completeStart(command.idempotencyKey, result);
|
|
return result;
|
|
}
|
|
|
|
private async startOwned(
|
|
command: StartWorkflowCommand,
|
|
cancellationSignal: AbortSignal,
|
|
onPublished?: (view: WorkflowView) => void,
|
|
) {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
this.validateStart(command);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const commandFingerprint = fingerprint(command);
|
|
const existing = await this.options.store.getStart(command.idempotencyKey);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (existing) {
|
|
this.validateExistingStart(existing, commandFingerprint);
|
|
if (existing.status === "completed") {
|
|
// SAFETY: completeStart only persists WorkflowView results produced by this engine.
|
|
const result = existing.result as unknown as WorkflowView;
|
|
onPublished?.(result);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const initialFacts: WorkflowFacts = {
|
|
stage: "discover",
|
|
taskKind: "feature",
|
|
requestTerms: requestTerms(command.request),
|
|
candidatePaths: [],
|
|
languages: [],
|
|
tags: [],
|
|
riskTags: [],
|
|
};
|
|
const preflightEligibility = this.preflightEligibility(initialFacts);
|
|
selectInitialDiscoveryProfile(this.options.profiles, initialFacts);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
|
|
let persistedRun: WorkflowRun | undefined;
|
|
if (existing?.status === "pending") {
|
|
try {
|
|
persistedRun = await this.options.store.loadRun(existing.runId);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
let runId = existing?.runId ?? this.createId();
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
let workspacePlan = persistedRun
|
|
? this.workspacePlanForRun(persistedRun)
|
|
: await this.options.workspace.prepare({
|
|
sourceRoot: this.options.sourceRoot,
|
|
configuredRoot: this.options.effectiveConfig.worktrees.root,
|
|
featureName: command.featureName,
|
|
runId,
|
|
allowDirtySource: command.allowDirtySource,
|
|
});
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (workspacePlan.branchExists) {
|
|
const owner = await this.options.store.findRunByBranch(
|
|
workspacePlan.branch,
|
|
);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const resumable =
|
|
existing?.status === "pending" && owner?.runId === runId;
|
|
const uncertainPending = existing?.status === "pending" && !owner;
|
|
if (!resumable && !uncertainPending) {
|
|
if (owner) {
|
|
throw new Error(
|
|
`Branch ${workspacePlan.branch} already belongs to Workflow Run ${owner.runId}; continue it with inspect or decide`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`Branch ${workspacePlan.branch} already exists; choose another descriptive feature name`,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const reservation = await this.options.store.reserveStart(
|
|
command.idempotencyKey,
|
|
commandFingerprint,
|
|
runId,
|
|
);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
this.validateExistingStart(reservation.record, commandFingerprint);
|
|
if (reservation.record.runId !== runId) {
|
|
runId = reservation.record.runId;
|
|
try {
|
|
persistedRun = await this.options.store.loadRun(runId);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
persistedRun = undefined;
|
|
}
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
workspacePlan = persistedRun
|
|
? this.workspacePlanForRun(persistedRun)
|
|
: await this.options.workspace.prepare({
|
|
sourceRoot: this.options.sourceRoot,
|
|
configuredRoot: this.options.effectiveConfig.worktrees.root,
|
|
featureName: command.featureName,
|
|
runId,
|
|
allowDirtySource: command.allowDirtySource,
|
|
});
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
}
|
|
|
|
{
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
let run: WorkflowRun;
|
|
let persistedBeforeThisAttempt = true;
|
|
try {
|
|
run = persistedRun ?? (await this.options.store.loadRun(runId));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = this.initialRun(
|
|
runId,
|
|
command,
|
|
workspacePlan,
|
|
preflightEligibility,
|
|
);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const publication = await this.options.store.createRun(run);
|
|
persistedBeforeThisAttempt = !publication.created;
|
|
run = publication.run;
|
|
}
|
|
|
|
onPublished?.(view(run));
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
|
|
if (persistedBeforeThisAttempt) {
|
|
const claimed = await this.claimPersistedStartRun(run);
|
|
if (!claimed) return view(run);
|
|
run = claimed;
|
|
}
|
|
|
|
if (run.workspace.state === "creating" && persistedBeforeThisAttempt) {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
const reconciled = await this.options.workspace.reconcile(
|
|
run.workspace,
|
|
run.lease?.worktreeIdentity,
|
|
);
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
if (reconciled.state !== "creating") {
|
|
run = await this.persistWorkspaceOutcome(run, reconciled);
|
|
}
|
|
}
|
|
|
|
if (run.workspace.state === "creating") {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
let workspace: WorkflowRun["workspace"];
|
|
try {
|
|
workspace = await this.options.workspace.create(workspacePlan);
|
|
} catch (error) {
|
|
workspace = {
|
|
...run.workspace,
|
|
state: "creating",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = await this.persistWorkspaceOutcome(run, workspace);
|
|
}
|
|
|
|
if (run.workspace.state === "ready" && run.status !== "failed") {
|
|
const discoverStatus = stage(run, "discover").status;
|
|
const designStatus = stage(run, "design").status;
|
|
if (discoverStatus === "pending" || discoverStatus === "running") {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = await this.runDiscovery(run, cancellationSignal);
|
|
} else if (
|
|
discoverStatus === "completed" &&
|
|
(designStatus === "pending" || designStatus === "running")
|
|
) {
|
|
this.throwIfStartCancelled(cancellationSignal);
|
|
run = await this.runDesign(run, false, cancellationSignal);
|
|
}
|
|
}
|
|
|
|
const result = view(run);
|
|
await this.options.store.completeStart(command.idempotencyKey, result);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private async claimPersistedStartRun(run: WorkflowRun) {
|
|
if (
|
|
run.lease?.processId === this.processId &&
|
|
run.lease.activeParentSessionId === this.options.parentSessionId
|
|
) {
|
|
return run;
|
|
}
|
|
if (
|
|
run.workspace.state === "creating" &&
|
|
run.lease &&
|
|
run.lease.processId !== this.processId &&
|
|
this.isProcessAlive(run.lease.processId)
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (!this.ownershipIsRecoverable(run)) return undefined;
|
|
try {
|
|
return await this.transition(run, (next) => {
|
|
this.rotateOwnership(next);
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
/Stale workflow revision/.test(error.message)
|
|
) {
|
|
return undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async persistWorkspaceOutcome(
|
|
run: WorkflowRun,
|
|
workspace: WorkflowRun["workspace"],
|
|
) {
|
|
return this.transition(run, (next) => {
|
|
next.workspace = workspace;
|
|
const initialize = stage(next, "initialize");
|
|
if (workspace.state === "ready") {
|
|
initialize.status = "completed";
|
|
initialize.finishedAt = this.now();
|
|
next.status = "running";
|
|
next.error = undefined;
|
|
return;
|
|
}
|
|
initialize.status = workspace.state === "failed" ? "failed" : "waiting";
|
|
next.status = "waiting";
|
|
next.error = {
|
|
code: "workspace-recovery-required",
|
|
message:
|
|
workspace.error ?? "Workspace creation requires reconciliation",
|
|
stageId: initialize.id,
|
|
};
|
|
if (!activeGate(next)) {
|
|
openGate(next, {
|
|
id: this.createId(),
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
workspaceState: workspace.state,
|
|
workspaceError: workspace.error,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private validateExistingStart(
|
|
record: StartRecord,
|
|
commandFingerprint: string,
|
|
) {
|
|
if (record.fingerprint !== commandFingerprint) {
|
|
throw new Error(
|
|
"idempotency key was already used with another start command",
|
|
);
|
|
}
|
|
if (record.status === "completed" && !record.result) {
|
|
throw new Error("Completed start record is missing its result");
|
|
}
|
|
}
|
|
|
|
private initialAnalysisRun(
|
|
runId: string,
|
|
command: StartAnalysisWorkflowCommand,
|
|
): WorkflowRun {
|
|
const now = this.now();
|
|
const run = this.initialRun(
|
|
runId,
|
|
{
|
|
action: "start",
|
|
request: command.request,
|
|
featureName: "analysis-script",
|
|
idempotencyKey: command.idempotencyKey,
|
|
},
|
|
{
|
|
sourceRoot: this.options.sourceRoot,
|
|
worktreeRoot: this.options.sourceRoot,
|
|
worktreePath: this.options.sourceRoot,
|
|
branch: "",
|
|
baseHead: "",
|
|
branchExists: false,
|
|
sourceChanges: [],
|
|
sourceChangesOmitted: 0,
|
|
dirtySourceAllowed: false,
|
|
},
|
|
[],
|
|
);
|
|
run.mode = "analysis";
|
|
run.featureName = undefined;
|
|
run.workspace = {
|
|
state: "not-required",
|
|
sourceRoot: this.options.sourceRoot,
|
|
root: this.options.sourceRoot,
|
|
};
|
|
run.script = {
|
|
source: command.source,
|
|
...(command.args !== undefined
|
|
? { args: structuredClone(command.args) }
|
|
: {}),
|
|
activeToolNames: [...command.activeToolNames],
|
|
status: "pending",
|
|
stages: [],
|
|
executed: false,
|
|
};
|
|
run.facts = {
|
|
...run.facts,
|
|
taskKind: "analysis",
|
|
stage: "discover",
|
|
};
|
|
for (const workflowStage of run.stages) {
|
|
workflowStage.status = workflowStage.kind === "discover" ? "running" : "pending";
|
|
if (workflowStage.kind === "discover") workflowStage.startedAt = now;
|
|
else delete workflowStage.startedAt;
|
|
delete workflowStage.finishedAt;
|
|
}
|
|
return run;
|
|
}
|
|
|
|
private initialRun(
|
|
runId: string,
|
|
command: StartWorkflowCommand,
|
|
workspacePlan: WorkspacePlan,
|
|
preflightEligibility: StageEligibilityEvidence[],
|
|
): WorkflowRun {
|
|
const now = this.now();
|
|
const stages: WorkflowStage[] = [
|
|
"initialize",
|
|
"discover",
|
|
"design",
|
|
"prototype",
|
|
"implement",
|
|
"remediate",
|
|
"verify",
|
|
"review",
|
|
"complete",
|
|
].map((kind) => ({
|
|
id: `${runId}:${kind}`,
|
|
kind: kind as WorkflowStage["kind"],
|
|
status: kind === "initialize" ? "running" : "pending",
|
|
...(kind === "initialize" ? { startedAt: now } : {}),
|
|
}));
|
|
return {
|
|
runId,
|
|
revision: 0,
|
|
mode: "feature",
|
|
...(command.script
|
|
? {
|
|
script: {
|
|
source: command.script.source,
|
|
...(command.script.args !== undefined
|
|
? { args: structuredClone(command.script.args) }
|
|
: {}),
|
|
insertionPoint: command.script.insertionPoint,
|
|
activeToolNames: [...command.script.activeToolNames],
|
|
status: "pending" as const,
|
|
stages: [],
|
|
executed: false,
|
|
},
|
|
}
|
|
: {}),
|
|
status: "running",
|
|
background: command.background ?? false,
|
|
request: command.request.trim(),
|
|
featureName: command.featureName,
|
|
parentSessionIds: [this.options.parentSessionId],
|
|
lease: {
|
|
processId: this.processId,
|
|
activeParentSessionId: this.options.parentSessionId,
|
|
heartbeatAt: now,
|
|
worktreeIdentity: workspacePlan.worktreePath,
|
|
},
|
|
workspace: {
|
|
state: "creating",
|
|
sourceRoot: workspacePlan.sourceRoot,
|
|
root: workspacePlan.worktreePath,
|
|
branch: workspacePlan.branch,
|
|
baseHead: workspacePlan.baseHead,
|
|
sourceChanges: structuredClone(workspacePlan.sourceChanges),
|
|
sourceChangesOmitted: workspacePlan.sourceChangesOmitted,
|
|
dirtySourceAllowed: workspacePlan.dirtySourceAllowed,
|
|
},
|
|
effectiveConfig: {
|
|
...structuredClone(this.options.effectiveConfig),
|
|
concurrency: {
|
|
...structuredClone(this.options.effectiveConfig.concurrency),
|
|
default:
|
|
command.concurrency ??
|
|
this.options.effectiveConfig.concurrency.default,
|
|
},
|
|
},
|
|
pinnedProfiles: structuredClone(this.options.profiles),
|
|
preflightEligibility: structuredClone(preflightEligibility),
|
|
facts: {
|
|
stage: "discover",
|
|
taskKind: "feature",
|
|
requestTerms: requestTerms(command.request),
|
|
candidatePaths: [],
|
|
languages: [],
|
|
tags: [],
|
|
riskTags: [],
|
|
},
|
|
stages,
|
|
gates: [],
|
|
decisions: [],
|
|
assignments: [],
|
|
prototypeCandidates: [],
|
|
prototypeCleanupCompleted: false,
|
|
deliveries: [],
|
|
commandReceipts: [],
|
|
error: undefined,
|
|
planArtifact: undefined,
|
|
implementationContext: undefined,
|
|
uiImpact: undefined,
|
|
uiImpactEvidence: [],
|
|
affectedUiSurfaces: [],
|
|
mutatorProfileNames: [],
|
|
reviewCycles: 0,
|
|
verificationCycles: 0,
|
|
verificationEvidence: undefined,
|
|
reviewVerdict: undefined,
|
|
};
|
|
}
|
|
|
|
private async saveApprovedPlan(
|
|
run: WorkflowRun,
|
|
decision: Extract<GateDecision, { kind: "approve-plan" }>,
|
|
) {
|
|
if (!run.planArtifact) throw new Error("Plan Approval requires a plan artifact");
|
|
const currentPlan = await readVerifiedPlanArtifact(run.planArtifact);
|
|
const acceptedDecisions = [
|
|
...run.decisions.map((entry) => entry.value),
|
|
decision,
|
|
];
|
|
const contents = [
|
|
currentPlan.trimEnd(),
|
|
"## Accepted Decisions",
|
|
"```json",
|
|
JSON.stringify(acceptedDecisions, null, 2),
|
|
"```",
|
|
"## Worktree",
|
|
`- Source: ${run.workspace.sourceRoot}`,
|
|
`- Path: ${run.workspace.root ?? "not ready"}`,
|
|
`- Branch: ${run.workspace.branch ?? "not ready"}`,
|
|
`- Base HEAD: ${run.workspace.baseHead ?? "unknown"}`,
|
|
"## UI Impact Evidence",
|
|
`- Accepted classification: ${decision.acceptedUiImpact}`,
|
|
`- Affected surfaces: ${run.affectedUiSurfaces.join(", ") || "none"}`,
|
|
...run.uiImpactEvidence.map((evidence) => `- ${evidence}`),
|
|
`- Candidate paths: ${run.facts.candidatePaths.join(", ") || "none"}`,
|
|
`- Tags: ${run.facts.tags.join(", ") || "none"}`,
|
|
`- Risk tags: ${run.facts.riskTags.join(", ") || "none"}`,
|
|
].join("\n\n");
|
|
return this.options.store.savePlan(
|
|
run.runId,
|
|
run.planArtifact.revision + 1,
|
|
contents,
|
|
);
|
|
}
|
|
|
|
private async saveAcceptedPrototypePlan(
|
|
run: WorkflowRun,
|
|
decision: Extract<GateDecision, { kind: "accept-prototype" }>,
|
|
) {
|
|
if (!run.planArtifact) throw new Error("Prototype Acceptance requires a plan");
|
|
const candidate = run.prototypeCandidates.find(
|
|
(entry) =>
|
|
entry.id === decision.candidateId &&
|
|
entry.revision === decision.candidateRevision,
|
|
);
|
|
if (!candidate) throw new Error("Accepted prototype candidate is missing");
|
|
const plan = await readVerifiedPlanArtifact(run.planArtifact);
|
|
const contents = [
|
|
plan.trimEnd(),
|
|
"## Accepted Prototype",
|
|
`- Candidate: ${candidate.id} revision ${candidate.revision}`,
|
|
`- Route: ${candidate.route}`,
|
|
`- Structural direction: ${candidate.structuralRationale}`,
|
|
`- Evidence artifact: ${candidate.artifactPath}`,
|
|
`- Feedback: ${decision.feedback?.trim() || "none"}`,
|
|
].join("\n\n");
|
|
return this.options.store.savePlan(
|
|
run.runId,
|
|
run.planArtifact.revision + 1,
|
|
contents,
|
|
);
|
|
}
|
|
|
|
private prototypeCleanupAssignment(run: WorkflowRun): WorkflowAssignment {
|
|
const implementer = run.assignments.find(
|
|
(assignment) => assignment.role === "implement",
|
|
);
|
|
if (!implementer) throw new Error("Prototype cleanup requires an implementer");
|
|
const ownedPaths = [
|
|
...new Set(
|
|
run.assignments
|
|
.filter((assignment) => assignment.role === "prototype")
|
|
.flatMap((assignment) => assignment.ownedPaths),
|
|
),
|
|
];
|
|
return {
|
|
id: this.createId(),
|
|
stageId: stage(run, "remediate").id,
|
|
role: "remediate",
|
|
profileName: implementer.profileName,
|
|
profileDigest: implementer.profileDigest,
|
|
selection: {
|
|
...structuredClone(implementer.selection),
|
|
rationale:
|
|
"Remove prototype-only routes, variants, and switchers while preserving persisted prototype evidence.",
|
|
},
|
|
status: "queued",
|
|
mutates: true,
|
|
ownedPaths,
|
|
};
|
|
}
|
|
|
|
private refinementAssignments(run: WorkflowRun) {
|
|
const prototype = run.assignments.find(
|
|
(assignment) => assignment.role === "prototype",
|
|
);
|
|
if (!prototype) throw new Error("Prototype refinement requires a prototype profile");
|
|
const roots = [
|
|
...new Set(
|
|
run.assignments
|
|
.filter((assignment) => assignment.role === "prototype")
|
|
.flatMap((assignment) => assignment.ownedPaths)
|
|
.map((ownedPath) => ownedPath.replace(/[/\\]candidate-\d+$/, "")),
|
|
),
|
|
];
|
|
return Array.from({ length: 3 }, (_, index): WorkflowAssignment => ({
|
|
...structuredClone(prototype),
|
|
id: this.createId(),
|
|
status: "queued",
|
|
executionToken: undefined,
|
|
result: undefined,
|
|
error: undefined,
|
|
selection: {
|
|
...structuredClone(prototype.selection),
|
|
rationale: `Refined candidate ${index + 1} must address accepted feedback and remain structurally distinct.`,
|
|
},
|
|
ownedPaths: roots.map((root) =>
|
|
path.join(root, `refinement-${run.revision + 1}-candidate-${index + 1}`),
|
|
),
|
|
}));
|
|
}
|
|
|
|
private openImplementationContext(run: WorkflowRun) {
|
|
if (!run.planArtifact) {
|
|
throw new Error("Implementation Context requires a persisted plan");
|
|
}
|
|
if (run.implementationContext?.status === "waiting") return;
|
|
const gate: WorkflowGate = {
|
|
id: this.createId(),
|
|
kind: "implementation-context",
|
|
status: "open",
|
|
revision: run.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
planRevision: run.planArtifact.revision,
|
|
planDigest: run.planArtifact.digest,
|
|
strategies: ["fresh-session", "smart-compact", "current-session"],
|
|
},
|
|
};
|
|
run.implementationContext = {
|
|
gateId: gate.id,
|
|
runRevision: run.revision + 1,
|
|
planRevision: run.planArtifact.revision,
|
|
preparationId: this.createId(),
|
|
activeParentSessionId: this.options.parentSessionId,
|
|
sourceParentSessionId: this.options.parentSessionId,
|
|
status: "waiting",
|
|
};
|
|
openGate(run, gate);
|
|
run.status = "waiting";
|
|
}
|
|
|
|
private async runWorkflowScript(
|
|
initialRun: WorkflowRun,
|
|
insertionPoint?: FeatureScriptInsertionPoint,
|
|
) {
|
|
let run = initialRun;
|
|
const script = run.script;
|
|
if (
|
|
!script ||
|
|
script.executed ||
|
|
(run.mode === "feature" && script.insertionPoint !== insertionPoint)
|
|
) {
|
|
return run;
|
|
}
|
|
if (!this.options.scriptRuntime) {
|
|
return this.failRun(
|
|
run,
|
|
"script-runtime-unavailable",
|
|
new Error("Custom scripts require the restricted script runtime"),
|
|
);
|
|
}
|
|
|
|
let stateQueue: Promise<void> = Promise.resolve();
|
|
const update = (mutate: (next: WorkflowRun) => void) => {
|
|
const operation = stateQueue.then(async () => {
|
|
run = await this.transition(run, mutate);
|
|
});
|
|
stateQueue = operation.catch(() => {});
|
|
return operation;
|
|
};
|
|
const allowedReadTools = run.effectiveConfig.analysisTools.filter((tool) =>
|
|
script.activeToolNames.includes(tool),
|
|
);
|
|
const stageKind: WorkflowStage["kind"] =
|
|
run.mode === "analysis"
|
|
? "discover"
|
|
: insertionPoint === "before-review"
|
|
? "review"
|
|
: "design";
|
|
const controller = new AbortController();
|
|
let resolveSettled = () => {};
|
|
const activeScript = {
|
|
controller,
|
|
settled: new Promise<void>((resolve) => {
|
|
resolveSettled = resolve;
|
|
}),
|
|
resolve: () => resolveSettled(),
|
|
};
|
|
ACTIVE_SCRIPT_EXECUTIONS.set(run.runId, activeScript);
|
|
const activeScriptAssignments = new Set<Promise<void>>();
|
|
|
|
try {
|
|
await update((next) => {
|
|
if (!next.script) throw new Error("Script state is missing");
|
|
next.script.status = "running";
|
|
});
|
|
const stageUpdates: Promise<void>[] = [];
|
|
const result = await this.options.scriptRuntime.run({
|
|
source: script.source,
|
|
args: script.args,
|
|
cwd: run.workspace.root ?? run.workspace.sourceRoot,
|
|
signal: controller.signal,
|
|
onStage: (title) => {
|
|
stageUpdates.push(
|
|
update((next) => {
|
|
if (!next.script) throw new Error("Script state is missing");
|
|
next.script.stages.push(title);
|
|
}),
|
|
);
|
|
},
|
|
onAssign: (requestedRole, prompt, signal, requestId) => {
|
|
const operation = (async () => {
|
|
if (
|
|
requestedRole !== "discover" &&
|
|
requestedRole !== "design" &&
|
|
requestedRole !== "verify" &&
|
|
requestedRole !== "review"
|
|
) {
|
|
throw new Error(
|
|
`Script Assignment role ${requestedRole} is not read-only or supported`,
|
|
);
|
|
}
|
|
const role = requestedRole as ProfileRole;
|
|
const requestFingerprint = fingerprint({ role, prompt });
|
|
await stateQueue;
|
|
const reusable = run.assignments.find(
|
|
(assignment) =>
|
|
assignment.scriptRequestId === requestId &&
|
|
assignment.scriptRequestFingerprint === requestFingerprint &&
|
|
assignment.status === "succeeded",
|
|
);
|
|
if (reusable) {
|
|
return {
|
|
ok: true,
|
|
output:
|
|
typeof reusable.result === "string"
|
|
? reusable.result
|
|
: JSON.stringify(reusable.result),
|
|
structured: structuredClone(reusable.result),
|
|
};
|
|
}
|
|
const selected = selectionEvidence(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: role },
|
|
role,
|
|
);
|
|
const assignmentId = this.createId();
|
|
await update((next) => {
|
|
if (
|
|
next.assignments.length >= next.effectiveConfig.maxAssignmentsPerRun
|
|
) {
|
|
throw new Error("Workflow Run exceeded its Assignment limit");
|
|
}
|
|
next.assignments.push({
|
|
id: assignmentId,
|
|
stageId: stage(next, stageKind).id,
|
|
role,
|
|
profileName: selected.profile.name,
|
|
profileDigest: selected.profile.digest,
|
|
selection: {
|
|
...structuredClone(selected.selection),
|
|
rationale: `Custom read-only script Assignment: ${prompt.slice(0, 160)}`,
|
|
},
|
|
status: "queued",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
scriptRequestId: requestId,
|
|
scriptRequestFingerprint: requestFingerprint,
|
|
});
|
|
});
|
|
const markRunning = () =>
|
|
update((next) => {
|
|
const assignment = next.assignments.find(
|
|
(candidate) => candidate.id === assignmentId,
|
|
);
|
|
if (!assignment || assignment.status !== "queued") {
|
|
throw new Error(`Script Assignment ${assignmentId} is not queued`);
|
|
}
|
|
assignment.status = "running";
|
|
});
|
|
if (!this.options.executor.reservesCapacity) await markRunning();
|
|
try {
|
|
const executionResult = await this.options.executor.run({
|
|
runId: run.runId,
|
|
role,
|
|
request: prompt,
|
|
profile: selected.profile,
|
|
facts: { ...structuredClone(run.facts), stage: role },
|
|
decisions: structuredClone(run.decisions),
|
|
cwd: run.workspace.root ?? run.workspace.sourceRoot,
|
|
signal,
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
captureGitState: true,
|
|
allowedReadTools,
|
|
runConcurrency: run.effectiveConfig.concurrency.default,
|
|
...(this.options.executor.reservesCapacity
|
|
? { onReserved: markRunning }
|
|
: {}),
|
|
});
|
|
const metadata = isAssignmentExecutionResult(executionResult)
|
|
? executionResult.metadata
|
|
: undefined;
|
|
const output = isAssignmentExecutionResult(executionResult)
|
|
? executionResult.output
|
|
: executionResult;
|
|
const transcriptArtifact = metadata
|
|
? await this.options.store.saveAssignmentTranscript(
|
|
run.runId,
|
|
assignmentId,
|
|
metadata.transcript,
|
|
)
|
|
: undefined;
|
|
await update((next) => {
|
|
const assignment = next.assignments.find(
|
|
(candidate) => candidate.id === assignmentId,
|
|
);
|
|
if (!assignment || assignment.status !== "running") {
|
|
throw new Error(`Script Assignment ${assignmentId} lost its fence`);
|
|
}
|
|
assignment.status = "succeeded";
|
|
assignment.result = structuredClone(output);
|
|
if (metadata) {
|
|
assignment.childSessionId = metadata.childSessionId;
|
|
assignment.effectiveProvider = metadata.provider;
|
|
assignment.effectiveModel = metadata.model;
|
|
assignment.effectiveEffort = metadata.effort;
|
|
assignment.contextCapacity = metadata.contextCapacity;
|
|
assignment.gitBefore = metadata.gitBefore;
|
|
assignment.gitAfter = metadata.gitAfter;
|
|
}
|
|
if (transcriptArtifact) {
|
|
assignment.transcriptArtifact = transcriptArtifact;
|
|
}
|
|
});
|
|
return {
|
|
ok: true,
|
|
output:
|
|
typeof output === "string" ? output : JSON.stringify(output),
|
|
structured: output,
|
|
};
|
|
} catch (error) {
|
|
await update((next) => {
|
|
const assignment = next.assignments.find(
|
|
(candidate) => candidate.id === assignmentId,
|
|
);
|
|
if (assignment) {
|
|
assignment.status = "failed";
|
|
assignment.error =
|
|
error instanceof Error ? error.message : String(error);
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
})();
|
|
activeScriptAssignments.add(
|
|
operation.then(
|
|
() => {},
|
|
() => {},
|
|
),
|
|
);
|
|
return operation;
|
|
},
|
|
});
|
|
await Promise.allSettled([...activeScriptAssignments]);
|
|
await Promise.all(stageUpdates);
|
|
await update((next) => {
|
|
if (!next.script) throw new Error("Script state is missing");
|
|
next.script.status = "completed";
|
|
next.script.executed = true;
|
|
next.script.result = structuredClone(result);
|
|
if (next.mode === "analysis") {
|
|
next.status = "completed";
|
|
const discover = stage(next, "discover");
|
|
discover.status = "completed";
|
|
discover.finishedAt = this.now();
|
|
const complete = stage(next, "complete");
|
|
complete.status = "completed";
|
|
complete.startedAt ??= this.now();
|
|
complete.finishedAt = this.now();
|
|
}
|
|
});
|
|
return run;
|
|
} catch (error) {
|
|
await Promise.allSettled([...activeScriptAssignments]);
|
|
await update((next) => {
|
|
if (next.script) {
|
|
next.script.status = "failed";
|
|
next.script.error = error instanceof Error ? error.message : String(error);
|
|
}
|
|
next.status = "failed";
|
|
next.error = {
|
|
code: "script-execution-failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}).catch(() => {});
|
|
return run;
|
|
} finally {
|
|
if (ACTIVE_SCRIPT_EXECUTIONS.get(run.runId) === activeScript) {
|
|
ACTIVE_SCRIPT_EXECUTIONS.delete(run.runId);
|
|
}
|
|
activeScript.resolve();
|
|
}
|
|
}
|
|
|
|
private async runDiscovery(
|
|
run: WorkflowRun,
|
|
cancellationSignal?: AbortSignal,
|
|
) {
|
|
const selected = selectionEvidence(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: "discover" },
|
|
"discover",
|
|
);
|
|
const execution = await this.executeOrReuseAssignment(
|
|
run,
|
|
"discover",
|
|
selected,
|
|
false,
|
|
cancellationSignal,
|
|
);
|
|
run = execution.run;
|
|
if (!execution.output) return run;
|
|
let output: DiscoveryOutput;
|
|
try {
|
|
output = discoveryOutput(execution.output);
|
|
} catch (error) {
|
|
return this.failRun(run, "invalid-discovery-output", error);
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
next.facts = {
|
|
...next.facts,
|
|
stage: "design",
|
|
candidatePaths: output.candidatePaths,
|
|
languages: output.languages,
|
|
tags: output.tags,
|
|
riskTags: output.riskTags,
|
|
};
|
|
const discover = stage(next, "discover");
|
|
discover.output = output;
|
|
if (output.questions.length > 0) {
|
|
discover.status = "waiting";
|
|
next.status = "waiting";
|
|
const gate: WorkflowGate = {
|
|
id: this.createId(),
|
|
kind: "questions",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: { questions: output.questions },
|
|
};
|
|
openGate(next, gate);
|
|
} else {
|
|
discover.status = "completed";
|
|
discover.finishedAt = this.now();
|
|
}
|
|
});
|
|
if (output.questions.length > 0) return run;
|
|
run = await this.runWorkflowScript(run, "before-design");
|
|
return run.status === "failed"
|
|
? run
|
|
: this.runDesign(run, false, cancellationSignal);
|
|
}
|
|
|
|
private plannerCandidates(run: WorkflowRun) {
|
|
const roles: ProfileRole[] = ["prototype", "implement", "verify", "review"];
|
|
return roles.map((role): StageEligibilityEvidence => {
|
|
const candidates = eligibleProfiles(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: role },
|
|
role,
|
|
).map((evaluation) => ({
|
|
profileName: evaluation.profile.name,
|
|
profileDigest: evaluation.profile.digest,
|
|
eligible: evaluation.eligible,
|
|
reasons: [...evaluation.reasons],
|
|
specificity: evaluation.specificity,
|
|
}));
|
|
return { role, candidates };
|
|
});
|
|
}
|
|
|
|
private buildPlannedAssignments(run: WorkflowRun, output: DesignOutput) {
|
|
const candidates = this.plannerCandidates(run);
|
|
const required: PlannedAssignmentOutput["role"][] = [
|
|
"implement",
|
|
"verify",
|
|
"review",
|
|
...(output.uiImpact === "non-trivial" ? (["prototype"] as const) : []),
|
|
];
|
|
for (const role of required) {
|
|
if (output.assignments.filter((assignment) => assignment.role === role).length !== 1) {
|
|
throw new Error(`Design must select exactly one ${role} Assignment`);
|
|
}
|
|
}
|
|
if (new Set(output.assignments.map((assignment) => assignment.role)).size !== output.assignments.length) {
|
|
throw new Error("Design Assignment roles must be unique");
|
|
}
|
|
if (
|
|
output.uiImpact !== "non-trivial" &&
|
|
output.assignments.some((assignment) => assignment.role === "prototype")
|
|
) {
|
|
throw new Error("Prototype Assignment requires non-trivial UI impact");
|
|
}
|
|
|
|
const planned = output.assignments.flatMap((selection): WorkflowAssignment[] => {
|
|
const evidence = candidates.find((entry) => entry.role === selection.role);
|
|
const selected = evidence?.candidates.find(
|
|
(candidate) =>
|
|
candidate.eligible && candidate.profileName === selection.profileName,
|
|
);
|
|
if (!evidence || !selected) {
|
|
throw new Error(
|
|
`Planner selected ineligible ${selection.role} profile ${selection.profileName}`,
|
|
);
|
|
}
|
|
const profile = run.pinnedProfiles.find(
|
|
(candidate) =>
|
|
candidate.name === selected.profileName &&
|
|
candidate.digest === selected.profileDigest,
|
|
);
|
|
if (!profile) throw new Error(`Pinned profile ${selected.profileName} is missing`);
|
|
const shouldMutate =
|
|
selection.role === "prototype" || selection.role === "implement";
|
|
if (selection.mutates !== shouldMutate) {
|
|
throw new Error(`${selection.role} Assignment mutation policy is invalid`);
|
|
}
|
|
if (selection.mutates && selection.ownedPaths.length === 0) {
|
|
throw new Error(`${selection.role} Assignment requires owned paths`);
|
|
}
|
|
const candidateCount = selection.role === "prototype" ? 3 : 1;
|
|
return Array.from({ length: candidateCount }, (_, index) => ({
|
|
id: this.createId(),
|
|
stageId: stage(run, selection.role).id,
|
|
role: selection.role,
|
|
profileName: profile.name,
|
|
profileDigest: profile.digest,
|
|
selection: {
|
|
eligibleProfiles: structuredClone(evidence.candidates),
|
|
selectedProfileName: profile.name,
|
|
rationale:
|
|
selection.role === "prototype"
|
|
? `${selection.rationale} Candidate ${index + 1} must be structurally distinct, responsive, and browser-validated.`
|
|
: selection.rationale,
|
|
},
|
|
status: "queued" as const,
|
|
mutates: selection.mutates,
|
|
ownedPaths:
|
|
selection.role === "prototype"
|
|
? selection.ownedPaths.map((ownedPath) =>
|
|
path.join(ownedPath, `candidate-${index + 1}`),
|
|
)
|
|
: [...selection.ownedPaths],
|
|
}));
|
|
});
|
|
const mutators = new Set(
|
|
planned.filter((assignment) => assignment.mutates).map((assignment) => assignment.profileName),
|
|
);
|
|
const reviewer = planned.find((assignment) => assignment.role === "review");
|
|
if (!reviewer || mutators.has(reviewer.profileName)) {
|
|
throw new Error("Planner must select an independent review profile");
|
|
}
|
|
return planned;
|
|
}
|
|
|
|
private async runDesign(
|
|
run: WorkflowRun,
|
|
forceNewAssignment = false,
|
|
cancellationSignal?: AbortSignal,
|
|
) {
|
|
const selected = selectionEvidence(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: "design" },
|
|
"design",
|
|
);
|
|
const execution = await this.executeOrReuseAssignment(
|
|
run,
|
|
"design",
|
|
selected,
|
|
forceNewAssignment,
|
|
cancellationSignal,
|
|
);
|
|
run = execution.run;
|
|
if (!execution.output) return run;
|
|
let output: DesignOutput;
|
|
let plannedAssignments: WorkflowAssignment[];
|
|
let planArtifact: NonNullable<WorkflowRun["planArtifact"]>;
|
|
try {
|
|
output = designOutput(execution.output);
|
|
const uiPolicy = enforceUiImpact({
|
|
plannerImpact: output.uiImpact,
|
|
plannerEvidence: output.uiImpactEvidence,
|
|
affectedSurfaces: output.affectedUiSurfaces,
|
|
facts: run.facts,
|
|
});
|
|
output = {
|
|
...output,
|
|
uiImpact: uiPolicy.impact,
|
|
uiImpactEvidence: uiPolicy.evidence,
|
|
affectedUiSurfaces: uiPolicy.affectedSurfaces,
|
|
};
|
|
plannedAssignments = this.buildPlannedAssignments(run, output);
|
|
const planDocument = [
|
|
output.plan,
|
|
"## Acceptance Criteria",
|
|
...output.acceptanceCriteria.map((criterion) => `- ${criterion}`),
|
|
"## UI Impact",
|
|
`- Classification: ${output.uiImpact}`,
|
|
`- Affected surfaces: ${output.affectedUiSurfaces.join(", ") || "none"}`,
|
|
...output.uiImpactEvidence.map((evidence) => `- ${evidence}`),
|
|
"## Planned Assignments",
|
|
...output.assignments.map(
|
|
(assignment) =>
|
|
`- ${assignment.role}: ${assignment.profileName} owns ${assignment.ownedPaths.join(", ") || "no mutable paths"}`,
|
|
),
|
|
"## Verification Commands",
|
|
...output.verificationCommands.map(
|
|
(command) =>
|
|
`- ${command.executable} ${command.args.join(" ")} (${command.kind})`,
|
|
),
|
|
].join("\n\n");
|
|
planArtifact = await this.options.store.savePlan(
|
|
run.runId,
|
|
run.revision + 1,
|
|
planDocument,
|
|
);
|
|
} catch (error) {
|
|
return this.failRun(run, "invalid-design-output", error);
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
next.planArtifact = planArtifact;
|
|
next.uiImpact = output.uiImpact;
|
|
next.uiImpactEvidence = [...output.uiImpactEvidence];
|
|
next.affectedUiSurfaces = [...output.affectedUiSurfaces];
|
|
next.verificationEvidence = {
|
|
commands: output.verificationCommands.map((command) => ({
|
|
id: this.createId(),
|
|
...structuredClone(command),
|
|
status: "pending",
|
|
})),
|
|
focusedBehavioralTests: false,
|
|
verifierSucceeded: false,
|
|
};
|
|
next.status = "waiting";
|
|
const design = stage(next, "design");
|
|
design.status = "waiting";
|
|
design.output = output;
|
|
const plannedRoles: ProfileRole[] = [
|
|
"prototype",
|
|
"implement",
|
|
"verify",
|
|
"review",
|
|
];
|
|
next.assignments = next.assignments.filter(
|
|
(assignment) =>
|
|
assignment.role === "remediate" ||
|
|
!plannedRoles.includes(assignment.role) ||
|
|
assignment.status !== "queued",
|
|
);
|
|
next.assignments.push(...structuredClone(plannedAssignments));
|
|
const gate: WorkflowGate = {
|
|
id: this.createId(),
|
|
kind: "plan-approval",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
planArtifact,
|
|
uiImpact: output.uiImpact,
|
|
uiImpactEvidence: output.uiImpactEvidence,
|
|
affectedUiSurfaces: output.affectedUiSurfaces,
|
|
},
|
|
};
|
|
openGate(next, gate);
|
|
});
|
|
return this.runWorkflowScript(run, "after-design");
|
|
}
|
|
|
|
private async executeOrReuseAssignment(
|
|
run: WorkflowRun,
|
|
role: "discover" | "design",
|
|
selected: ReturnType<typeof selectionEvidence>,
|
|
forceNewAssignment = false,
|
|
cancellationSignal?: AbortSignal,
|
|
) {
|
|
const stageId = stage(run, role).id;
|
|
const existing = [...run.assignments]
|
|
.reverse()
|
|
.find(
|
|
(assignment) =>
|
|
assignment.stageId === stageId && assignment.role === role,
|
|
);
|
|
if (existing && !forceNewAssignment) {
|
|
return {
|
|
run,
|
|
output: existing.status === "succeeded" ? existing.result : undefined,
|
|
};
|
|
}
|
|
if (cancellationSignal) this.throwIfStartCancelled(cancellationSignal);
|
|
return this.runAssignment(run, role, selected);
|
|
}
|
|
|
|
private async assignmentEvidence(
|
|
run: WorkflowRun,
|
|
role: ProfileRole | "remediate",
|
|
) {
|
|
if (role !== "verify" && role !== "review") return {};
|
|
if (!run.planArtifact) throw new Error(`${role} requires an approved plan`);
|
|
const approvedPlan = await readVerifiedPlanArtifact(run.planArtifact);
|
|
if (role === "verify") {
|
|
return {
|
|
approvedPlan,
|
|
verificationEvidence: structuredClone(run.verificationEvidence),
|
|
};
|
|
}
|
|
if (!this.options.reviewDiff) {
|
|
throw new Error("Review requires the read-only Git diff provider");
|
|
}
|
|
return {
|
|
approvedPlan,
|
|
verificationEvidence: structuredClone(run.verificationEvidence),
|
|
reviewDiff: await this.options.reviewDiff.read(
|
|
run.workspace.root ?? run.workspace.sourceRoot,
|
|
run.workspace.baseHead,
|
|
),
|
|
};
|
|
}
|
|
|
|
private async runAssignment(
|
|
run: WorkflowRun,
|
|
role: ProfileRole | "remediate",
|
|
selected: ReturnType<typeof selectionEvidence>,
|
|
planned?: WorkflowAssignment,
|
|
) {
|
|
const assignmentId = planned?.id ?? this.createId();
|
|
const executionToken = this.createId();
|
|
const abortController = new AbortController();
|
|
let executionTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
let executorExecution: Promise<unknown> | undefined;
|
|
let executionTimedOut = false;
|
|
let retainExecutionFence = false;
|
|
let cleanupStarted = false;
|
|
let resolveSettled = () => {};
|
|
let resolveResumeAfterAbort = () => {};
|
|
const activeExecution: ActiveAssignmentExecution = {
|
|
runId: run.runId,
|
|
assignmentId,
|
|
controller: abortController,
|
|
settled: new Promise<void>((resolve) => {
|
|
resolveSettled = resolve;
|
|
}),
|
|
resolveSettled: () => resolveSettled(),
|
|
resumeAfterAbort: new Promise<void>((resolve) => {
|
|
resolveResumeAfterAbort = resolve;
|
|
}),
|
|
resolveResumeAfterAbort: () => resolveResumeAfterAbort(),
|
|
};
|
|
ACTIVE_ASSIGNMENT_EXECUTIONS.set(executionToken, activeExecution);
|
|
try {
|
|
await this.options.store.createAssignmentLease(run.runId, assignmentId, {
|
|
token: executionToken,
|
|
processId: this.processId,
|
|
heartbeatAt: this.now(),
|
|
});
|
|
run = await this.transition(run, (next) => {
|
|
next.status = "running";
|
|
const targetStage = stage(next, role);
|
|
targetStage.status = "running";
|
|
targetStage.startedAt ??= this.now();
|
|
if (planned) {
|
|
const assignment = next.assignments.find(
|
|
(candidate) => candidate.id === assignmentId,
|
|
);
|
|
if (!assignment || assignment.status !== "queued") {
|
|
throw new Error(`Planned Assignment ${assignmentId} is not queued`);
|
|
}
|
|
assignment.executionToken = executionToken;
|
|
if (assignment.mutates) {
|
|
next.mutatorProfileNames = [
|
|
...new Set([...next.mutatorProfileNames, assignment.profileName]),
|
|
];
|
|
}
|
|
} else {
|
|
next.assignments.push({
|
|
id: assignmentId,
|
|
stageId: targetStage.id,
|
|
role,
|
|
profileName: selected.profile.name,
|
|
profileDigest: selected.profile.digest,
|
|
selection: selected.selection,
|
|
status: "queued",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
executionToken,
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
await this.options.store
|
|
.releaseAssignmentLease(run.runId, assignmentId, executionToken)
|
|
.catch(() => {});
|
|
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken);
|
|
activeExecution.resolveSettled();
|
|
activeExecution.resolveResumeAfterAbort();
|
|
throw error;
|
|
}
|
|
|
|
ACTIVE_ASSIGNMENT_TOKENS.add(executionToken);
|
|
const stopHeartbeat = this.startAssignmentHeartbeat(
|
|
run.runId,
|
|
assignmentId,
|
|
executionToken,
|
|
);
|
|
const markRunning = async () => {
|
|
run = await this.transition(run, (next) => {
|
|
const assignment = next.assignments.find(
|
|
(item) => item.id === assignmentId,
|
|
);
|
|
if (!assignment) {
|
|
throw new Error(`Assignment ${assignmentId} is missing`);
|
|
}
|
|
assignment.status = "running";
|
|
});
|
|
};
|
|
const cleanupExecutionFence = async () => {
|
|
if (cleanupStarted) return;
|
|
cleanupStarted = true;
|
|
await stopHeartbeat().catch(() => {});
|
|
ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken);
|
|
ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken);
|
|
await this.options.store
|
|
.releaseAssignmentLease(run.runId, assignmentId, executionToken)
|
|
.catch(() => {});
|
|
activeExecution.resolveSettled();
|
|
};
|
|
if (!this.options.executor.reservesCapacity) await markRunning();
|
|
|
|
try {
|
|
let output: unknown;
|
|
let metadata: AssignmentExecutionMetadata | undefined;
|
|
let transcriptArtifact: WorkflowAssignment["transcriptArtifact"];
|
|
try {
|
|
const evidence = await this.assignmentEvidence(run, role);
|
|
executorExecution = this.options.executor.run({
|
|
runId: run.runId,
|
|
role,
|
|
request: planned
|
|
? `${run.request}\n\nAssignment instructions: ${planned.selection.rationale}\nOwned paths: ${planned.ownedPaths.join(", ")}`
|
|
: run.request,
|
|
profile: selected.profile,
|
|
facts: structuredClone(run.facts),
|
|
decisions: structuredClone(run.decisions),
|
|
...(role === "design"
|
|
? { plannerCandidates: this.plannerCandidates(run) }
|
|
: {}),
|
|
cwd: run.workspace.root ?? run.workspace.sourceRoot,
|
|
signal: abortController.signal,
|
|
mutates: planned?.mutates ?? false,
|
|
ownedPaths: planned?.ownedPaths ?? [],
|
|
captureGitState: role === "review",
|
|
runConcurrency: run.effectiveConfig.concurrency.default,
|
|
...evidence,
|
|
...(this.options.executor.reservesCapacity
|
|
? { onReserved: markRunning }
|
|
: {}),
|
|
});
|
|
void executorExecution.catch(() => {});
|
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
executionTimeout = setTimeout(() => {
|
|
executionTimedOut = true;
|
|
const timeoutError = new Error(
|
|
`Assignment ${role} timed out after ${this.assignmentExecutionTimeoutMs}ms`,
|
|
);
|
|
abortController.abort(timeoutError);
|
|
reject(timeoutError);
|
|
}, this.assignmentExecutionTimeoutMs);
|
|
});
|
|
const settledExecution = executorExecution.then(
|
|
(result) => {
|
|
if (executionTimeout) clearTimeout(executionTimeout);
|
|
return result;
|
|
},
|
|
(error: unknown) => {
|
|
if (executionTimeout) clearTimeout(executionTimeout);
|
|
throw error;
|
|
},
|
|
);
|
|
const executionResult = await Promise.race([
|
|
settledExecution,
|
|
timeout,
|
|
]);
|
|
metadata = isAssignmentExecutionResult(executionResult)
|
|
? executionResult.metadata
|
|
: undefined;
|
|
output = isAssignmentExecutionResult(executionResult)
|
|
? executionResult.output
|
|
: executionResult;
|
|
transcriptArtifact = metadata
|
|
? await this.options.store.saveAssignmentTranscript(
|
|
run.runId,
|
|
assignmentId,
|
|
metadata.transcript,
|
|
)
|
|
: undefined;
|
|
activeExecution.metadata = metadata;
|
|
activeExecution.transcriptArtifact = transcriptArtifact;
|
|
} catch (error) {
|
|
metadata = isAssignmentExecutionError(error)
|
|
? error.assignmentMetadata
|
|
: undefined;
|
|
transcriptArtifact = metadata
|
|
? await this.options.store.saveAssignmentTranscript(
|
|
run.runId,
|
|
assignmentId,
|
|
metadata.transcript,
|
|
)
|
|
: undefined;
|
|
activeExecution.metadata = metadata;
|
|
activeExecution.transcriptArtifact = transcriptArtifact;
|
|
if (executionTimedOut && executorExecution) {
|
|
let abortGrace: ReturnType<typeof setTimeout> | undefined;
|
|
const settledDuringGrace = await Promise.race([
|
|
executorExecution.then(
|
|
() => true,
|
|
() => true,
|
|
),
|
|
new Promise<false>((resolve) => {
|
|
abortGrace = setTimeout(
|
|
() => resolve(false),
|
|
this.assignmentAbortTimeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
if (abortGrace) clearTimeout(abortGrace);
|
|
if (!settledDuringGrace) retainExecutionFence = true;
|
|
}
|
|
if (!retainExecutionFence) await stopHeartbeat();
|
|
await this.assertAssignmentLease(
|
|
run.runId,
|
|
assignmentId,
|
|
executionToken,
|
|
);
|
|
if (abortController.signal.aborted && !executionTimedOut) {
|
|
activeExecution.resolveSettled();
|
|
await activeExecution.resumeAfterAbort;
|
|
run = await this.options.store.loadRun(run.runId);
|
|
return { run, output: undefined };
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const assignment = next.assignments.find(
|
|
(item) => item.id === assignmentId,
|
|
);
|
|
if (assignment) {
|
|
assignment.status = executionTimedOut ? "interrupted" : "failed";
|
|
assignment.error =
|
|
error instanceof Error ? error.message : String(error);
|
|
applyExecutionMetadata(assignment, metadata, transcriptArtifact);
|
|
}
|
|
const targetStage = stage(next, role);
|
|
targetStage.status = "failed";
|
|
targetStage.finishedAt = this.now();
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (planned?.mutates || role === "review") {
|
|
next.status = "waiting";
|
|
next.error = {
|
|
code: "mutation-recovery-required",
|
|
message,
|
|
stageId: targetStage.id,
|
|
assignmentId,
|
|
};
|
|
if (!activeGate(next)) {
|
|
openGate(next, {
|
|
id: this.createId(),
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
interruptedAssignmentIds: [assignmentId],
|
|
potentiallyMutating: true,
|
|
},
|
|
});
|
|
}
|
|
} else {
|
|
next.status = "failed";
|
|
next.error = {
|
|
code: executionTimedOut ? "assignment-timeout" : "assignment-failed",
|
|
message,
|
|
stageId: targetStage.id,
|
|
assignmentId,
|
|
};
|
|
}
|
|
});
|
|
return { run, output: undefined };
|
|
}
|
|
|
|
await stopHeartbeat();
|
|
await this.assertAssignmentLease(run.runId, assignmentId, executionToken);
|
|
run = await this.transition(run, (next) => {
|
|
const assignment = next.assignments.find(
|
|
(item) => item.id === assignmentId,
|
|
);
|
|
if (!assignment)
|
|
throw new Error(`Assignment ${assignmentId} is missing`);
|
|
assignment.status = "succeeded";
|
|
assignment.result = output;
|
|
applyExecutionMetadata(assignment, metadata, transcriptArtifact);
|
|
});
|
|
const assignment = run.assignments.find(
|
|
(item) => item.id === assignmentId,
|
|
);
|
|
if (!assignment) throw new Error(`Assignment ${assignmentId} is missing`);
|
|
await this.options.afterAssignmentPersist?.(
|
|
structuredClone(assignment),
|
|
run,
|
|
);
|
|
return { run, output };
|
|
} finally {
|
|
if (executionTimeout) clearTimeout(executionTimeout);
|
|
abortController.abort();
|
|
if (retainExecutionFence && executorExecution) {
|
|
void executorExecution.then(
|
|
() => cleanupExecutionFence(),
|
|
() => cleanupExecutionFence(),
|
|
);
|
|
} else {
|
|
await cleanupExecutionFence();
|
|
}
|
|
}
|
|
}
|
|
|
|
private startAssignmentHeartbeat(
|
|
runId: string,
|
|
assignmentId: string,
|
|
executionToken: string,
|
|
) {
|
|
let stopped = false;
|
|
let heartbeatError: unknown;
|
|
let pending = Promise.resolve();
|
|
const timer = setInterval(() => {
|
|
pending = pending.then(async () => {
|
|
const owned = await this.options.store.heartbeatAssignmentLease(
|
|
runId,
|
|
assignmentId,
|
|
executionToken,
|
|
this.now(),
|
|
);
|
|
if (!owned)
|
|
throw new Error(`Assignment ${assignmentId} lost its lease`);
|
|
});
|
|
pending.catch((error) => {
|
|
heartbeatError ??= error;
|
|
});
|
|
}, this.assignmentHeartbeatMs);
|
|
timer.unref?.();
|
|
|
|
return async () => {
|
|
if (!stopped) {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
}
|
|
await pending.catch(() => {});
|
|
if (heartbeatError) throw heartbeatError;
|
|
};
|
|
}
|
|
|
|
private async assertAssignmentLease(
|
|
runId: string,
|
|
assignmentId: string,
|
|
executionToken: string,
|
|
) {
|
|
const lease = await this.options.store.loadAssignmentLease(
|
|
runId,
|
|
assignmentId,
|
|
);
|
|
if (!lease || lease.token !== executionToken) {
|
|
throw new Error(`Assignment ${assignmentId} lost its execution lease`);
|
|
}
|
|
}
|
|
|
|
private async decide(run: WorkflowRun, command: DecideWorkflowCommand) {
|
|
const duplicate = this.commandReceipt(run, command);
|
|
if (duplicate) {
|
|
if (duplicate.status === "completed" && duplicate.result) {
|
|
return duplicate.result as WorkflowView;
|
|
}
|
|
return this.resumePendingDecision(run, duplicate);
|
|
}
|
|
if (command.expectedRevision !== run.revision) {
|
|
throw new Error(
|
|
`Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`,
|
|
);
|
|
}
|
|
if (run.status !== "waiting") {
|
|
throw new Error(`Workflow Run must be waiting to decide a Gate`);
|
|
}
|
|
const openGates = run.gates.filter(
|
|
(candidate) => candidate.status === "open",
|
|
);
|
|
if (openGates.length !== 1 || openGates[0]?.id !== command.gateId) {
|
|
throw new Error("Decision must target the single active Gate");
|
|
}
|
|
const gate = openGates[0];
|
|
if (gate.revision > run.revision)
|
|
throw new Error("Gate revision is invalid");
|
|
legalDecision(gate, command.decision);
|
|
if (
|
|
gate.kind === "questions" &&
|
|
stage(run, "discover").status !== "waiting"
|
|
) {
|
|
throw new Error("Questions Gate requires a waiting Discover stage");
|
|
}
|
|
if (
|
|
gate.kind === "plan-approval" &&
|
|
stage(run, "design").status !== "waiting"
|
|
) {
|
|
throw new Error("Plan Approval Gate requires a waiting Design stage");
|
|
}
|
|
if (gate.kind === "implementation-context") {
|
|
const context = run.implementationContext;
|
|
if (!context || context.gateId !== gate.id) {
|
|
throw new Error("Implementation Context state does not match the Gate");
|
|
}
|
|
if (!run.planArtifact || context.planRevision !== run.planArtifact.revision) {
|
|
throw new Error("Implementation Context plan revision is stale");
|
|
}
|
|
if (
|
|
run.assignments.some(
|
|
(assignment) =>
|
|
assignment.status === "running" ||
|
|
(assignment.status === "queued" && Boolean(assignment.executionToken)),
|
|
)
|
|
) {
|
|
throw new Error("Implementation Context cannot change while an Assignment is active");
|
|
}
|
|
}
|
|
validateDecisionPayload(gate, command.decision);
|
|
if (
|
|
command.decision.kind === "recover" &&
|
|
command.decision.resolution !== "inspect"
|
|
) {
|
|
const fencedAssignments = run.assignments.filter(
|
|
(assignment) =>
|
|
Boolean(assignment.executionToken) &&
|
|
(assignment.status === "queued" ||
|
|
assignment.status === "running" ||
|
|
assignment.status === "interrupted"),
|
|
);
|
|
if (await this.hasActiveAssignmentLease(run.runId, fencedAssignments)) {
|
|
throw new Error(
|
|
"Recovery is blocked while an interrupted Assignment is still active",
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
command.decision.kind === "recover" &&
|
|
command.decision.resolution === "retry" &&
|
|
run.workspace.state !== "ready" &&
|
|
run.lease &&
|
|
run.lease.processId !== this.processId &&
|
|
this.isProcessAlive(run.lease.processId)
|
|
) {
|
|
throw new Error("Workspace creation is still owned by a live process");
|
|
}
|
|
if (
|
|
command.decision.kind === "approve-plan" &&
|
|
command.decision.acceptedUiImpact !== run.uiImpact
|
|
) {
|
|
throw new Error("Approved UI impact does not match the current plan");
|
|
}
|
|
if (command.decision.kind === "report-context-preparation") {
|
|
const context = run.implementationContext;
|
|
if (
|
|
!context ||
|
|
context.gateId !== command.gateId ||
|
|
context.preparationId !== command.decision.preparationId ||
|
|
context.planRevision !== command.decision.planRevision ||
|
|
context.strategy !== "smart-compact" ||
|
|
context.status !== "smart-compact-pending"
|
|
) {
|
|
throw new Error("Context preparation report does not match the pending intent");
|
|
}
|
|
if (command.decision.outcome === "unavailable") {
|
|
if (!command.activeToolNames) {
|
|
throw new Error("activeToolNames are required for an unavailable report");
|
|
}
|
|
if (command.activeToolNames.includes("smart_compact")) {
|
|
throw new Error("smart_compact is active and cannot be reported unavailable");
|
|
}
|
|
}
|
|
}
|
|
|
|
const approvedPlanArtifact =
|
|
command.decision.kind === "approve-plan"
|
|
? await this.saveApprovedPlan(run, command.decision)
|
|
: undefined;
|
|
const acceptedPrototypePlan =
|
|
command.decision.kind === "accept-prototype"
|
|
? await this.saveAcceptedPrototypePlan(run, command.decision)
|
|
: undefined;
|
|
const cleanupAssignment =
|
|
command.decision.kind === "accept-prototype"
|
|
? this.prototypeCleanupAssignment(run)
|
|
: undefined;
|
|
const refinedAssignments =
|
|
command.decision.kind === "refine-prototypes"
|
|
? this.refinementAssignments(run)
|
|
: undefined;
|
|
const commandFingerprint = fingerprint(command);
|
|
run = await this.transition(run, (next) => {
|
|
const nextGate = next.gates.find(
|
|
(candidate) => candidate.id === command.gateId,
|
|
);
|
|
if (!nextGate || nextGate.status !== "open")
|
|
throw new Error("Gate is not open");
|
|
const keepsGateOpen =
|
|
(command.decision.kind === "recover" &&
|
|
command.decision.resolution === "inspect") ||
|
|
(command.decision.kind === "select-implementation-context" &&
|
|
command.decision.strategy !== "current-session") ||
|
|
(command.decision.kind === "report-context-preparation" &&
|
|
(command.decision.outcome === "unavailable" ||
|
|
command.decision.outcome === "failed"));
|
|
if (!keepsGateOpen) {
|
|
nextGate.status =
|
|
command.decision.kind === "revise-plan" ||
|
|
command.decision.kind === "refine-prototypes"
|
|
? "rejected"
|
|
: "satisfied";
|
|
}
|
|
next.decisions.push({
|
|
gateId: command.gateId,
|
|
revision: next.revision + 1,
|
|
idempotencyKey: command.idempotencyKey,
|
|
decidedAt: this.now(),
|
|
value: structuredClone(command.decision),
|
|
});
|
|
next.commandReceipts.push({
|
|
key: command.idempotencyKey,
|
|
action: "decide",
|
|
fingerprint: commandFingerprint,
|
|
status: "pending",
|
|
acceptedRevision: next.revision + 1,
|
|
continuation: decisionContinuation(gate.kind, command.decision),
|
|
});
|
|
next.status = "running";
|
|
if (gate.kind === "questions") {
|
|
const discover = stage(next, "discover");
|
|
discover.status = "completed";
|
|
discover.finishedAt = this.now();
|
|
} else if (
|
|
gate.kind === "plan-approval" &&
|
|
command.decision.kind === "approve-plan"
|
|
) {
|
|
const design = stage(next, "design");
|
|
design.status = "completed";
|
|
design.finishedAt = this.now();
|
|
if (!approvedPlanArtifact) {
|
|
throw new Error("Approved plan artifact is missing");
|
|
}
|
|
next.planArtifact = approvedPlanArtifact;
|
|
if (next.uiImpact === "non-trivial") {
|
|
const prototype = stage(next, "prototype");
|
|
prototype.status = "pending";
|
|
prototype.startedAt = undefined;
|
|
prototype.finishedAt = undefined;
|
|
next.status = "running";
|
|
} else {
|
|
this.openImplementationContext(next);
|
|
}
|
|
} else if (
|
|
gate.kind === "prototype-acceptance" &&
|
|
command.decision.kind === "accept-prototype"
|
|
) {
|
|
if (!acceptedPrototypePlan || !cleanupAssignment) {
|
|
throw new Error("Accepted prototype continuation is incomplete");
|
|
}
|
|
next.planArtifact = acceptedPrototypePlan;
|
|
next.assignments.push(structuredClone(cleanupAssignment));
|
|
const prototype = stage(next, "prototype");
|
|
prototype.status = "completed";
|
|
prototype.finishedAt = this.now();
|
|
this.openImplementationContext(next);
|
|
} else if (
|
|
gate.kind === "prototype-acceptance" &&
|
|
command.decision.kind === "refine-prototypes"
|
|
) {
|
|
if (!refinedAssignments) {
|
|
throw new Error("Prototype refinement Assignments are missing");
|
|
}
|
|
next.assignments.push(...structuredClone(refinedAssignments));
|
|
const prototype = stage(next, "prototype");
|
|
prototype.status = "pending";
|
|
prototype.startedAt = undefined;
|
|
prototype.finishedAt = undefined;
|
|
next.status = "running";
|
|
} else if (
|
|
gate.kind === "implementation-context" &&
|
|
command.decision.kind === "select-implementation-context"
|
|
) {
|
|
const context = next.implementationContext;
|
|
if (!context) throw new Error("Implementation Context state is missing");
|
|
context.strategy = command.decision.strategy;
|
|
context.runRevision = next.revision + 1;
|
|
context.activeParentSessionId = this.options.parentSessionId;
|
|
context.sourceParentSessionId = this.options.parentSessionId;
|
|
context.error = undefined;
|
|
context.outcome = undefined;
|
|
if (command.decision.strategy === "fresh-session") {
|
|
context.status = "handoff-pending";
|
|
context.method = "fresh-session";
|
|
next.status = "waiting";
|
|
} else if (command.decision.strategy === "smart-compact") {
|
|
context.status = "smart-compact-pending";
|
|
context.method = "smart_compact";
|
|
next.status = "waiting";
|
|
} else {
|
|
context.status = "ready";
|
|
context.method = "unchanged";
|
|
context.outcome = "no-op";
|
|
next.status = "running";
|
|
}
|
|
} else if (
|
|
gate.kind === "implementation-context" &&
|
|
command.decision.kind === "report-context-preparation"
|
|
) {
|
|
const context = next.implementationContext;
|
|
if (!context) throw new Error("Implementation Context state is missing");
|
|
context.runRevision = next.revision + 1;
|
|
context.outcome = command.decision.outcome;
|
|
if (command.decision.outcome === "unavailable") {
|
|
context.status = "normal-compact-pending";
|
|
next.status = "waiting";
|
|
} else if (command.decision.outcome === "failed") {
|
|
context.status = "failed";
|
|
context.error = command.decision.error;
|
|
next.status = "waiting";
|
|
} else {
|
|
context.status = "ready";
|
|
context.error = undefined;
|
|
next.status = "running";
|
|
}
|
|
}
|
|
});
|
|
|
|
const receipt = run.commandReceipts.find(
|
|
(candidate) => candidate.key === command.idempotencyKey,
|
|
);
|
|
if (!receipt) throw new Error("Persisted command receipt is missing");
|
|
return this.resumePendingDecision(run, receipt);
|
|
}
|
|
|
|
private profileForAssignment(run: WorkflowRun, assignment: WorkflowAssignment) {
|
|
const profile = run.pinnedProfiles.find(
|
|
(candidate) =>
|
|
candidate.name === assignment.profileName &&
|
|
candidate.digest === assignment.profileDigest,
|
|
);
|
|
if (!profile) {
|
|
throw new Error(`Pinned profile ${assignment.profileName} is missing`);
|
|
}
|
|
return { profile, selection: assignment.selection };
|
|
}
|
|
|
|
private queuedAssignment(run: WorkflowRun, role: WorkflowAssignment["role"]) {
|
|
return run.assignments.find(
|
|
(assignment) => assignment.role === role && assignment.status === "queued",
|
|
);
|
|
}
|
|
|
|
private succeededAssignment(
|
|
run: WorkflowRun,
|
|
role: WorkflowAssignment["role"],
|
|
) {
|
|
return [...run.assignments]
|
|
.reverse()
|
|
.find(
|
|
(assignment) =>
|
|
assignment.role === role &&
|
|
assignment.status === "succeeded" &&
|
|
assignment.result !== undefined,
|
|
);
|
|
}
|
|
|
|
private async runPlannedAssignment(
|
|
run: WorkflowRun,
|
|
assignment: WorkflowAssignment,
|
|
) {
|
|
return this.runAssignment(
|
|
run,
|
|
assignment.role,
|
|
this.profileForAssignment(run, assignment),
|
|
assignment,
|
|
);
|
|
}
|
|
|
|
private async markStageCompleted(
|
|
run: WorkflowRun,
|
|
kind: WorkflowStage["kind"],
|
|
output?: unknown,
|
|
) {
|
|
return this.transition(run, (next) => {
|
|
const target = stage(next, kind);
|
|
target.status = "completed";
|
|
target.finishedAt = this.now();
|
|
if (output !== undefined) target.output = output;
|
|
});
|
|
}
|
|
|
|
private async openLifecycleRecovery(
|
|
run: WorkflowRun,
|
|
message: string,
|
|
assignmentIds: string[] = [],
|
|
) {
|
|
if (activeGate(run)?.kind === "recovery") return run;
|
|
return this.transition(run, (next) => {
|
|
next.status = "waiting";
|
|
next.error = { code: "lifecycle-recovery-required", message };
|
|
openGate(next, {
|
|
id: this.createId(),
|
|
kind: "recovery",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: {
|
|
interruptedAssignmentIds: assignmentIds,
|
|
message,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
private async runVerificationCommands(run: WorkflowRun) {
|
|
const evidence = run.verificationEvidence;
|
|
if (!evidence) throw new Error("Verification evidence is missing");
|
|
if (!this.options.hostCommands && evidence.commands.length > 0) {
|
|
return this.failRun(
|
|
run,
|
|
"host-command-executor-missing",
|
|
new Error("Verification commands require the host command executor"),
|
|
);
|
|
}
|
|
for (const current of evidence.commands) {
|
|
const command = run.verificationEvidence?.commands.find(
|
|
(candidate) => candidate.id === current.id,
|
|
);
|
|
if (
|
|
!command ||
|
|
command.status === "succeeded" ||
|
|
command.status === "superseded"
|
|
) {
|
|
continue;
|
|
}
|
|
if (command.status === "running" || command.status === "interrupted") {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Verification command ${command.id} was interrupted and will not be replayed automatically`,
|
|
);
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const pending = next.verificationEvidence?.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (!pending || (pending.status !== "pending" && pending.status !== "failed")) {
|
|
throw new Error(`Verification command ${command.id} is not runnable`);
|
|
}
|
|
pending.status = "running";
|
|
});
|
|
let safetyFailure: WorkspaceSafetyError | undefined;
|
|
try {
|
|
const executed = await this.options.hostCommands!.run({
|
|
worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot,
|
|
ownedPaths: command.ownedPaths,
|
|
executable: command.executable,
|
|
args: command.args,
|
|
});
|
|
run = await this.transition(run, (next) => {
|
|
const persisted = next.verificationEvidence?.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (!persisted) throw new Error(`Verification command ${command.id} is missing`);
|
|
persisted.exitCode = executed.result.code;
|
|
persisted.stdout = executed.result.stdout.slice(0, 32 * 1024);
|
|
persisted.stderr = executed.result.stderr.slice(0, 32 * 1024);
|
|
persisted.status = executed.result.code === 0 ? "succeeded" : "failed";
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSafetyError) safetyFailure = error;
|
|
run = await this.transition(run, (next) => {
|
|
const persisted = next.verificationEvidence?.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (!persisted) throw new Error(`Verification command ${command.id} is missing`);
|
|
persisted.status = "failed";
|
|
persisted.error = error instanceof Error ? error.message : String(error);
|
|
});
|
|
}
|
|
if (safetyFailure) {
|
|
return this.openLifecycleRecovery(run, safetyFailure.message);
|
|
}
|
|
const result = run.verificationEvidence?.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (result?.status !== "succeeded") return run;
|
|
}
|
|
return run;
|
|
}
|
|
|
|
private reviewSelection(run: WorkflowRun) {
|
|
const evaluations = eligibleProfiles(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: "review" },
|
|
"review",
|
|
);
|
|
const candidate = evaluations.find(
|
|
(evaluation) =>
|
|
evaluation.eligible &&
|
|
!run.mutatorProfileNames.includes(evaluation.profile.name),
|
|
);
|
|
if (!candidate) {
|
|
throw new Error("No independent review Agent Profile remains eligible");
|
|
}
|
|
return {
|
|
profile: candidate.profile,
|
|
selection: {
|
|
eligibleProfiles: evaluations.map((evaluation) => ({
|
|
profileName: evaluation.profile.name,
|
|
profileDigest: evaluation.profile.digest,
|
|
eligible:
|
|
evaluation.eligible &&
|
|
!run.mutatorProfileNames.includes(evaluation.profile.name),
|
|
reasons: [
|
|
...evaluation.reasons,
|
|
...(run.mutatorProfileNames.includes(evaluation.profile.name)
|
|
? ["profile mutated the reviewed diff"]
|
|
: []),
|
|
],
|
|
specificity: evaluation.specificity,
|
|
})),
|
|
selectedProfileName: candidate.profile.name,
|
|
rationale: "Selected an eligible reviewer outside the cumulative mutator set.",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async queueReviewAssignment(run: WorkflowRun) {
|
|
const selected = this.reviewSelection(run);
|
|
return this.transition(run, (next) => {
|
|
next.assignments.push({
|
|
id: this.createId(),
|
|
stageId: stage(next, "review").id,
|
|
role: "review",
|
|
profileName: selected.profile.name,
|
|
profileDigest: selected.profile.digest,
|
|
selection: selected.selection,
|
|
status: "queued",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
});
|
|
const review = stage(next, "review");
|
|
review.status = "pending";
|
|
review.startedAt = undefined;
|
|
review.finishedAt = undefined;
|
|
});
|
|
}
|
|
|
|
private async remediate(run: WorkflowRun, reviewerProfileName?: string) {
|
|
const persistedRemediation = this.queuedAssignment(run, "remediate")
|
|
? undefined
|
|
: stage(run, "remediate").status !== "completed"
|
|
? this.succeededAssignment(run, "remediate")
|
|
: undefined;
|
|
if (persistedRemediation) {
|
|
run = await this.markStageCompleted(
|
|
run,
|
|
"remediate",
|
|
implementationOutput(persistedRemediation.result),
|
|
);
|
|
return this.finishRemediation(run);
|
|
}
|
|
if (!reviewerProfileName) {
|
|
run = await this.transition(run, (next) => {
|
|
next.verificationCycles++;
|
|
});
|
|
if (run.verificationCycles >= run.effectiveConfig.maxReviewCycles) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Verification did not pass after ${run.verificationCycles} remediation cycles`,
|
|
);
|
|
}
|
|
} else if (run.reviewCycles >= run.effectiveConfig.maxReviewCycles) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Review did not approve after ${run.reviewCycles} cycles`,
|
|
);
|
|
}
|
|
const evaluations = eligibleProfiles(
|
|
run.pinnedProfiles,
|
|
{ ...run.facts, stage: "implement" },
|
|
"implement",
|
|
);
|
|
const selected = evaluations.find(
|
|
(evaluation) =>
|
|
evaluation.eligible && evaluation.profile.name !== reviewerProfileName,
|
|
);
|
|
if (!selected) {
|
|
return this.failRun(
|
|
run,
|
|
"remediation-profile-unavailable",
|
|
new Error("No eligible remediation profile remains"),
|
|
);
|
|
}
|
|
const ownedPaths = [
|
|
...new Set(
|
|
run.assignments
|
|
.filter((assignment) => assignment.mutates)
|
|
.flatMap((assignment) => assignment.ownedPaths),
|
|
),
|
|
];
|
|
run = await this.transition(run, (next) => {
|
|
const remediate = stage(next, "remediate");
|
|
remediate.status = "pending";
|
|
remediate.startedAt = undefined;
|
|
remediate.finishedAt = undefined;
|
|
next.assignments.push({
|
|
id: this.createId(),
|
|
stageId: remediate.id,
|
|
role: "remediate",
|
|
profileName: selected.profile.name,
|
|
profileDigest: selected.profile.digest,
|
|
selection: {
|
|
eligibleProfiles: evaluations.map((evaluation) => ({
|
|
profileName: evaluation.profile.name,
|
|
profileDigest: evaluation.profile.digest,
|
|
eligible:
|
|
evaluation.eligible &&
|
|
evaluation.profile.name !== reviewerProfileName,
|
|
reasons: [
|
|
...evaluation.reasons,
|
|
...(evaluation.profile.name === reviewerProfileName
|
|
? ["reviewer cannot remediate its own findings"]
|
|
: []),
|
|
],
|
|
specificity: evaluation.specificity,
|
|
})),
|
|
selectedProfileName: selected.profile.name,
|
|
rationale: "Selected an eligible implementer distinct from the reviewer.",
|
|
},
|
|
status: "queued",
|
|
mutates: true,
|
|
ownedPaths,
|
|
});
|
|
});
|
|
const assignment = this.queuedAssignment(run, "remediate");
|
|
if (!assignment) throw new Error("Remediation Assignment is missing");
|
|
const execution = await this.runPlannedAssignment(run, assignment);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
const output = implementationOutput(execution.output);
|
|
run = await this.markStageCompleted(run, "remediate", output);
|
|
return this.finishRemediation(run);
|
|
}
|
|
|
|
private async finishRemediation(run: WorkflowRun) {
|
|
run = await this.transition(run, (next) => {
|
|
if (!next.verificationEvidence) throw new Error("Verification evidence is missing");
|
|
for (const command of next.verificationEvidence.commands) {
|
|
if (command.status === "superseded") continue;
|
|
command.status = "pending";
|
|
command.exitCode = undefined;
|
|
command.stdout = undefined;
|
|
command.stderr = undefined;
|
|
command.error = undefined;
|
|
}
|
|
next.verificationEvidence.focusedBehavioralTests = false;
|
|
next.verificationEvidence.verifierSucceeded = false;
|
|
next.reviewVerdict = undefined;
|
|
const verifier = selectionEvidence(
|
|
next.pinnedProfiles,
|
|
{ ...next.facts, stage: "verify" },
|
|
"verify",
|
|
);
|
|
next.assignments.push({
|
|
id: this.createId(),
|
|
stageId: stage(next, "verify").id,
|
|
role: "verify",
|
|
profileName: verifier.profile.name,
|
|
profileDigest: verifier.profile.digest,
|
|
selection: verifier.selection,
|
|
status: "queued",
|
|
mutates: false,
|
|
ownedPaths: [],
|
|
});
|
|
const verify = stage(next, "verify");
|
|
verify.status = "pending";
|
|
verify.startedAt = undefined;
|
|
verify.finishedAt = undefined;
|
|
});
|
|
if (!this.queuedAssignment(run, "review")) {
|
|
run = await this.queueReviewAssignment(run);
|
|
}
|
|
return this.runImplementationPipeline(run);
|
|
}
|
|
|
|
private async runPrototypeStage(run: WorkflowRun): Promise<WorkflowRun> {
|
|
if (run.uiImpact !== "non-trivial" || run.implementationContext) return run;
|
|
while (true) {
|
|
const assignment = run.assignments.find(
|
|
(candidate) =>
|
|
candidate.role === "prototype" &&
|
|
!run.prototypeCandidates.some(
|
|
(prototype) => prototype.assignmentId === candidate.id,
|
|
) &&
|
|
(candidate.status === "queued" || candidate.status === "succeeded"),
|
|
);
|
|
if (!assignment) break;
|
|
let rawOutput = assignment.result;
|
|
if (assignment.status === "queued") {
|
|
const execution = await this.runPlannedAssignment(run, assignment);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
rawOutput = execution.output;
|
|
}
|
|
const output = prototypeOutput(rawOutput);
|
|
if (!this.options.prototypeValidator) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
"Prototype validation is unavailable",
|
|
[assignment.id],
|
|
);
|
|
}
|
|
let hostValidation: { artifactFiles: string[]; visualDigest: string };
|
|
try {
|
|
hostValidation = await this.options.prototypeValidator.validateCandidate({
|
|
worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot,
|
|
assignment,
|
|
output,
|
|
});
|
|
} catch (error) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Prototype ${assignment.id} failed host validation: ${error instanceof Error ? error.message : String(error)}`,
|
|
[assignment.id],
|
|
);
|
|
}
|
|
const candidatePath = output.path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
if (
|
|
path.isAbsolute(output.path) ||
|
|
candidatePath.split("/").includes("..") ||
|
|
!assignment.ownedPaths.some((ownedPath) => {
|
|
const root = ownedPath.replaceAll("\\", "/").replace(/\/$/, "");
|
|
return candidatePath === root || candidatePath.startsWith(`${root}/`);
|
|
})
|
|
) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Prototype ${assignment.id} reported a path outside its ownership`,
|
|
[assignment.id],
|
|
);
|
|
}
|
|
const revision = run.revision + 1;
|
|
const candidate = {
|
|
id: assignment.id,
|
|
revision,
|
|
assignmentId: assignment.id,
|
|
profileName: assignment.profileName,
|
|
status: "succeeded" as const,
|
|
path: candidatePath,
|
|
route: output.route,
|
|
structuralRationale: output.structuralRationale,
|
|
responsiveChecks: output.responsiveChecks,
|
|
browserEvidence: output.browserEvidence,
|
|
launchInstructions: output.launchInstructions,
|
|
visualDigest: hostValidation.visualDigest,
|
|
artifactPath: "",
|
|
retainedArtifacts: [] as string[],
|
|
};
|
|
const artifact = await this.options.store.savePrototypeArtifact(
|
|
run.runId,
|
|
candidate.id,
|
|
candidate,
|
|
run.workspace.root ?? run.workspace.sourceRoot,
|
|
hostValidation.artifactFiles,
|
|
);
|
|
candidate.artifactPath = artifact.path;
|
|
candidate.retainedArtifacts = artifact.retainedFiles;
|
|
run = await this.transition(run, (next) => {
|
|
next.prototypeCandidates.push(structuredClone(candidate));
|
|
});
|
|
}
|
|
|
|
const candidates = run.prototypeCandidates.slice(-3);
|
|
if (candidates.length !== 3) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
"Non-trivial UI work requires three successful Prototype Candidates",
|
|
);
|
|
}
|
|
for (const key of [
|
|
"path",
|
|
"route",
|
|
"structuralRationale",
|
|
"visualDigest",
|
|
] as const) {
|
|
if (new Set(candidates.map((candidate) => candidate[key])).size !== 3) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Prototype Candidates must have distinct ${key} values`,
|
|
candidates.map((candidate) => candidate.assignmentId),
|
|
);
|
|
}
|
|
}
|
|
return this.transition(run, (next) => {
|
|
const prototype = stage(next, "prototype");
|
|
prototype.status = "waiting";
|
|
prototype.output = structuredClone(candidates);
|
|
next.status = "waiting";
|
|
const gate: WorkflowGate = {
|
|
id: this.createId(),
|
|
kind: "prototype-acceptance",
|
|
status: "open",
|
|
revision: next.revision + 1,
|
|
openedAt: this.now(),
|
|
payload: { candidates: structuredClone(candidates) },
|
|
};
|
|
openGate(next, gate);
|
|
});
|
|
}
|
|
|
|
private async runPrototypeCleanup(run: WorkflowRun) {
|
|
if (run.prototypeCleanupCompleted) return run;
|
|
const accepted = run.decisions.some(
|
|
(decision) => decision.value.kind === "accept-prototype",
|
|
);
|
|
if (!accepted) return run;
|
|
const cleanup = [...run.assignments]
|
|
.reverse()
|
|
.find(
|
|
(assignment) =>
|
|
assignment.role === "remediate" &&
|
|
assignment.selection.rationale.startsWith("Remove prototype-only"),
|
|
);
|
|
if (!cleanup) {
|
|
return this.openLifecycleRecovery(run, "Prototype cleanup Assignment is missing");
|
|
}
|
|
let rawOutput = cleanup.result;
|
|
if (cleanup.status === "queued") {
|
|
const execution = await this.runPlannedAssignment(run, cleanup);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
rawOutput = execution.output;
|
|
} else if (cleanup.status !== "succeeded") {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
"Prototype cleanup did not complete safely",
|
|
[cleanup.id],
|
|
);
|
|
}
|
|
const output = implementationOutput(rawOutput);
|
|
if (!this.options.prototypeValidator) {
|
|
return this.openLifecycleRecovery(run, "Prototype cleanup validation is unavailable");
|
|
}
|
|
try {
|
|
await this.options.prototypeValidator.validateCleanup({
|
|
worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot,
|
|
candidates: run.prototypeCandidates,
|
|
});
|
|
} catch (error) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
`Prototype cleanup failed host validation: ${error instanceof Error ? error.message : String(error)}`,
|
|
[cleanup.id],
|
|
);
|
|
}
|
|
run = await this.markStageCompleted(run, "remediate", output);
|
|
return this.transition(run, (next) => {
|
|
next.prototypeCleanupCompleted = true;
|
|
});
|
|
}
|
|
|
|
private async runImplementationPipeline(run: WorkflowRun): Promise<WorkflowRun> {
|
|
if (run.implementationContext?.status !== "ready") return run;
|
|
const implementStage = stage(run, "implement");
|
|
if (implementStage.status !== "completed") {
|
|
const assignment = this.queuedAssignment(run, "implement");
|
|
const persisted = assignment
|
|
? undefined
|
|
: this.succeededAssignment(run, "implement");
|
|
if (!assignment && !persisted) {
|
|
return this.openLifecycleRecovery(run, "Implementation Assignment is missing");
|
|
}
|
|
if (persisted) {
|
|
run = await this.markStageCompleted(
|
|
run,
|
|
"implement",
|
|
implementationOutput(persisted.result),
|
|
);
|
|
} else if (assignment) {
|
|
const execution = await this.runPlannedAssignment(run, assignment);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
const output = implementationOutput(execution.output);
|
|
run = await this.markStageCompleted(run, "implement", output);
|
|
}
|
|
}
|
|
|
|
run = await this.runPrototypeCleanup(run);
|
|
if (run.status !== "running") return run;
|
|
|
|
run = await this.runVerificationCommands(run);
|
|
if (run.status !== "running") return run;
|
|
if (
|
|
run.verificationEvidence?.commands.some(
|
|
(command) =>
|
|
command.status !== "succeeded" && command.status !== "superseded",
|
|
)
|
|
) {
|
|
return this.remediate(run);
|
|
}
|
|
|
|
const verifyAssignment = this.queuedAssignment(run, "verify");
|
|
const persistedVerification = verifyAssignment
|
|
? undefined
|
|
: stage(run, "verify").status !== "completed"
|
|
? this.succeededAssignment(run, "verify")
|
|
: undefined;
|
|
if (verifyAssignment || persistedVerification) {
|
|
let output: VerificationOutput;
|
|
if (persistedVerification) {
|
|
output = verificationOutput(persistedVerification.result);
|
|
} else {
|
|
const execution = await this.runPlannedAssignment(run, verifyAssignment!);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
output = verificationOutput(execution.output);
|
|
}
|
|
const focusedCommandPassed =
|
|
run.verificationEvidence?.commands.some(
|
|
(command) => command.kind === "test" && command.status === "succeeded",
|
|
) ?? false;
|
|
run = await this.transition(run, (next) => {
|
|
if (!next.verificationEvidence) throw new Error("Verification evidence is missing");
|
|
next.verificationEvidence.focusedBehavioralTests =
|
|
output.focusedBehavioralTests && focusedCommandPassed;
|
|
next.verificationEvidence.verifierSucceeded = output.succeeded;
|
|
const verify = stage(next, "verify");
|
|
verify.output = output;
|
|
verify.status = output.succeeded ? "completed" : "failed";
|
|
verify.finishedAt = this.now();
|
|
});
|
|
if (
|
|
!output.succeeded ||
|
|
!output.focusedBehavioralTests ||
|
|
!focusedCommandPassed
|
|
) {
|
|
return this.remediate(run);
|
|
}
|
|
}
|
|
if (
|
|
!run.verificationEvidence?.verifierSucceeded ||
|
|
!run.verificationEvidence.focusedBehavioralTests
|
|
) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
"Completion requires successful verification and focused behavioral tests",
|
|
);
|
|
}
|
|
|
|
run = await this.runWorkflowScript(run, "before-review");
|
|
if (run.status === "failed") return run;
|
|
|
|
let reviewAssignment = this.queuedAssignment(run, "review");
|
|
let persistedReview = reviewAssignment
|
|
? undefined
|
|
: stage(run, "review").status !== "completed"
|
|
? this.succeededAssignment(run, "review")
|
|
: undefined;
|
|
if (!reviewAssignment && !persistedReview) {
|
|
run = await this.queueReviewAssignment(run);
|
|
reviewAssignment = this.queuedAssignment(run, "review");
|
|
persistedReview = undefined;
|
|
}
|
|
if (!reviewAssignment && !persistedReview) {
|
|
throw new Error("Review Assignment is missing");
|
|
}
|
|
const reviewer = reviewAssignment ?? persistedReview!;
|
|
if (run.mutatorProfileNames.includes(reviewer.profileName)) {
|
|
return this.failRun(
|
|
run,
|
|
"independent-reviewer-unavailable",
|
|
new Error("Review profile mutated the reviewed diff"),
|
|
);
|
|
}
|
|
let verdict: ReviewOutput;
|
|
if (persistedReview) {
|
|
verdict = reviewOutput(persistedReview.result);
|
|
} else {
|
|
const execution = await this.runAssignment(
|
|
run,
|
|
"review",
|
|
this.profileForAssignment(run, reviewer),
|
|
reviewer,
|
|
);
|
|
run = execution.run;
|
|
if (!execution.output || run.status !== "running") return run;
|
|
verdict = reviewOutput(execution.output);
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
next.reviewVerdict = verdict;
|
|
next.reviewCycles++;
|
|
const review = stage(next, "review");
|
|
review.output = verdict;
|
|
review.finishedAt = this.now();
|
|
review.status =
|
|
verdict.specification.verdict === "approved" &&
|
|
verdict.standards.verdict === "approved"
|
|
? "completed"
|
|
: "failed";
|
|
});
|
|
if (
|
|
verdict.specification.verdict !== "approved" ||
|
|
verdict.standards.verdict !== "approved"
|
|
) {
|
|
return this.remediate(run, reviewer.profileName);
|
|
}
|
|
return this.transition(run, (next) => {
|
|
stage(next, "complete").status = "completed";
|
|
stage(next, "complete").startedAt ??= this.now();
|
|
stage(next, "complete").finishedAt = this.now();
|
|
next.status = "completed";
|
|
next.error = undefined;
|
|
});
|
|
}
|
|
|
|
private assertContextCommand(
|
|
run: WorkflowRun,
|
|
command: ContextCommandBase,
|
|
) {
|
|
if (command.expectedRevision !== run.revision) {
|
|
throw new Error(
|
|
`Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`,
|
|
);
|
|
}
|
|
if (run.status !== "waiting") {
|
|
throw new Error("Implementation Context transition requires a waiting Workflow Run");
|
|
}
|
|
const context = run.implementationContext;
|
|
const gate = activeGate(run);
|
|
if (
|
|
!context ||
|
|
!gate ||
|
|
gate.kind !== "implementation-context" ||
|
|
gate.id !== command.gateId ||
|
|
context.gateId !== command.gateId ||
|
|
context.preparationId !== command.preparationId ||
|
|
context.planRevision !== command.planRevision ||
|
|
context.runRevision !== command.contextRevision
|
|
) {
|
|
throw new Error("Implementation Context command is stale or mismatched");
|
|
}
|
|
if (!run.planArtifact || run.planArtifact.revision !== command.planRevision) {
|
|
throw new Error("Implementation Context plan revision is stale");
|
|
}
|
|
if (
|
|
run.assignments.some(
|
|
(assignment) =>
|
|
assignment.status === "running" ||
|
|
(assignment.status === "queued" && Boolean(assignment.executionToken)),
|
|
)
|
|
) {
|
|
throw new Error("Implementation Context cannot change while an Assignment is active");
|
|
}
|
|
return { context, gate };
|
|
}
|
|
|
|
private async continueContext(
|
|
run: WorkflowRun,
|
|
command: ContinueContextCommand,
|
|
) {
|
|
const { context } = this.assertContextCommand(run, command);
|
|
if (context.status === "handoff-pending" && context.strategy === "fresh-session") {
|
|
if (!command.sourceSessionFile) {
|
|
throw new Error("fresh-session continuation requires sourceSessionFile");
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.sourceSessionFile = command.sourceSessionFile;
|
|
state.intentCreatedAt = this.now();
|
|
state.intentExpiresAt = this.now() + 5 * 60_000;
|
|
state.runRevision = next.revision + 1;
|
|
});
|
|
return view(run);
|
|
}
|
|
if (
|
|
context.status === "normal-compact-pending" &&
|
|
context.strategy === "smart-compact" &&
|
|
context.outcome === "unavailable"
|
|
) {
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.status = "normal-compact-running";
|
|
state.method = "normal-compact";
|
|
state.error = undefined;
|
|
state.runRevision = next.revision + 1;
|
|
});
|
|
return view(run);
|
|
}
|
|
throw new Error("Workflow Run has no continuable context intent");
|
|
}
|
|
|
|
private async claimContextHandoff(
|
|
run: WorkflowRun,
|
|
command: ClaimContextHandoffCommand,
|
|
) {
|
|
const { context } = this.assertContextCommand(run, command);
|
|
if (
|
|
context.status !== "handoff-pending" ||
|
|
context.strategy !== "fresh-session" ||
|
|
context.sourceSessionFile !== command.sourceSessionFile ||
|
|
context.sourceParentSessionId !== this.options.parentSessionId
|
|
) {
|
|
throw new Error("Fresh-session handoff claim does not match the pending intent");
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.targetSessionFile = command.targetSessionFile;
|
|
state.claimedAt = this.now();
|
|
state.status = "claimed-target-awaiting-withSession";
|
|
state.runRevision = next.revision + 1;
|
|
});
|
|
return view(run);
|
|
}
|
|
|
|
private async consumeContextHandoff(
|
|
run: WorkflowRun,
|
|
command: ConsumeContextHandoffCommand,
|
|
) {
|
|
const { context, gate } = this.assertContextCommand(run, command);
|
|
if (
|
|
context.status !== "claimed-target-awaiting-withSession" ||
|
|
context.strategy !== "fresh-session" ||
|
|
context.targetSessionFile !== command.targetSessionFile ||
|
|
context.sourceSessionFile !== command.previousSessionFile ||
|
|
context.activeParentSessionId === this.options.parentSessionId ||
|
|
(context.intentExpiresAt !== undefined && context.intentExpiresAt < this.now())
|
|
) {
|
|
throw new Error("Replacement session does not match the claimed handoff");
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.activeParentSessionId = this.options.parentSessionId;
|
|
state.status = "ready";
|
|
state.method = "fresh-session";
|
|
state.outcome = "succeeded";
|
|
state.error = undefined;
|
|
state.runRevision = next.revision + 1;
|
|
next.parentSessionIds = [
|
|
...new Set([...next.parentSessionIds, this.options.parentSessionId]),
|
|
];
|
|
if (!next.lease) throw new Error("Workflow Run lease is missing");
|
|
next.lease.activeParentSessionId = this.options.parentSessionId;
|
|
const nextGate = next.gates.find((candidate) => candidate.id === gate.id);
|
|
if (!nextGate || nextGate.status !== "open") {
|
|
throw new Error("Implementation Context Gate is not open");
|
|
}
|
|
nextGate.status = "satisfied";
|
|
next.status = "running";
|
|
});
|
|
run = await this.runImplementationPipeline(run);
|
|
return view(run);
|
|
}
|
|
|
|
private async recoverContextIntent(
|
|
run: WorkflowRun,
|
|
command: RecoverContextIntentCommand,
|
|
) {
|
|
if (
|
|
run.lease &&
|
|
run.lease.processId !== this.processId &&
|
|
this.isProcessAlive(run.lease.processId)
|
|
) {
|
|
throw new Error(
|
|
`Context handoff is owned by live process ${run.lease.processId}`,
|
|
);
|
|
}
|
|
const { context } = this.assertContextCommand(run, command);
|
|
const cancelledUnclaimed =
|
|
command.reason === "cancelled" &&
|
|
context.status === "handoff-pending" &&
|
|
Boolean(context.sourceSessionFile);
|
|
if (
|
|
context.status !== "claimed-target-awaiting-withSession" &&
|
|
!cancelledUnclaimed
|
|
) {
|
|
throw new Error("Workflow Run has no context intent to recover");
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.status = "handoff-pending";
|
|
state.sourceSessionFile = undefined;
|
|
state.targetSessionFile = undefined;
|
|
state.claimedAt = undefined;
|
|
state.intentCreatedAt = undefined;
|
|
state.intentExpiresAt = undefined;
|
|
state.error =
|
|
command.reason === "cancelled"
|
|
? "Fresh-session handoff was cancelled before session replacement; retry when ready"
|
|
: `Fresh-session handoff was not consumed during ${command.reason}; resume the source session and retry`;
|
|
state.runRevision = next.revision + 1;
|
|
next.status = "waiting";
|
|
});
|
|
return view(run);
|
|
}
|
|
|
|
private async completeNormalCompaction(
|
|
run: WorkflowRun,
|
|
command: CompleteNormalCompactionCommand,
|
|
) {
|
|
const { context, gate } = this.assertContextCommand(run, command);
|
|
if (
|
|
context.status !== "normal-compact-running" ||
|
|
context.method !== "normal-compact"
|
|
) {
|
|
throw new Error("Normal compaction callback does not match the pending intent");
|
|
}
|
|
run = await this.transition(run, (next) => {
|
|
const state = next.implementationContext;
|
|
if (!state) throw new Error("Implementation Context state is missing");
|
|
state.outcome = command.outcome;
|
|
state.runRevision = next.revision + 1;
|
|
if (command.outcome === "failed") {
|
|
state.status = "failed";
|
|
state.error = command.error;
|
|
next.status = "waiting";
|
|
return;
|
|
}
|
|
state.status = "ready";
|
|
state.error = undefined;
|
|
const nextGate = next.gates.find((candidate) => candidate.id === gate.id);
|
|
if (!nextGate || nextGate.status !== "open") {
|
|
throw new Error("Implementation Context Gate is not open");
|
|
}
|
|
nextGate.status = "satisfied";
|
|
next.status = "running";
|
|
});
|
|
if (command.outcome !== "failed") {
|
|
run = await this.runImplementationPipeline(run);
|
|
}
|
|
return view(run);
|
|
}
|
|
|
|
private throwIfStartCancelled(signal: AbortSignal) {
|
|
if (!signal.aborted) return;
|
|
throw signal.reason instanceof Error
|
|
? signal.reason
|
|
: new Error("Workflow start was cancelled");
|
|
}
|
|
|
|
private async abortCancelledStart(
|
|
runId: string,
|
|
idempotencyKey: string,
|
|
): Promise<WorkflowView | undefined> {
|
|
while (true) {
|
|
const run = await this.options.store.loadRun(runId).catch(() => undefined);
|
|
if (!run) return undefined;
|
|
if (isTerminalStatus(run.status)) return view(run);
|
|
try {
|
|
return await this.execute({
|
|
action: "abort",
|
|
runId,
|
|
expectedRevision: run.revision,
|
|
idempotencyKey,
|
|
});
|
|
} catch (error) {
|
|
const latest = await this.options.store.loadRun(runId).catch(() => undefined);
|
|
if (!latest) return undefined;
|
|
if (isTerminalStatus(latest.status)) return view(latest);
|
|
if (latest.revision !== run.revision) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async abort(run: WorkflowRun, command: AbortWorkflowCommand) {
|
|
const duplicate = this.commandReceipt(run, command);
|
|
if (duplicate) {
|
|
if (duplicate.status === "completed" && duplicate.result) {
|
|
return duplicate.result as WorkflowView;
|
|
}
|
|
throw new Error("Abort command is pending recovery");
|
|
}
|
|
if (command.expectedRevision !== run.revision) {
|
|
throw new Error(
|
|
`Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`,
|
|
);
|
|
}
|
|
if (
|
|
run.status === "completed" ||
|
|
run.status === "failed" ||
|
|
run.status === "aborted"
|
|
) {
|
|
throw new Error(`Cannot abort a ${run.status} Workflow Run`);
|
|
}
|
|
const commandFingerprint = fingerprint(command);
|
|
const activeExecutions = [...ACTIVE_ASSIGNMENT_EXECUTIONS.values()].filter(
|
|
(execution) => execution.runId === run.runId,
|
|
);
|
|
const localAssignmentIds = new Set(
|
|
activeExecutions.map((execution) => execution.assignmentId),
|
|
);
|
|
const externallyOwned = run.assignments.filter(
|
|
(assignment) =>
|
|
!localAssignmentIds.has(assignment.id) &&
|
|
Boolean(assignment.executionToken) &&
|
|
(assignment.status === "queued" ||
|
|
assignment.status === "running" ||
|
|
assignment.status === "interrupted"),
|
|
);
|
|
for (const assignment of externallyOwned) {
|
|
const lease = await this.options.store.loadAssignmentLease(
|
|
run.runId,
|
|
assignment.id,
|
|
);
|
|
if (lease && this.isProcessAlive(lease.processId)) {
|
|
throw new Error(
|
|
`Cannot safely abort Assignment ${assignment.id} owned by live process ${lease.processId}; abort from the owning process or retry after it exits`,
|
|
);
|
|
}
|
|
}
|
|
const activeScript = ACTIVE_SCRIPT_EXECUTIONS.get(run.runId);
|
|
for (const execution of activeExecutions) {
|
|
execution.controller.abort(new Error("Workflow Run was aborted"));
|
|
}
|
|
activeScript?.controller.abort(new Error("Workflow Run was aborted"));
|
|
try {
|
|
await waitForAssignmentStops(
|
|
activeExecutions,
|
|
this.assignmentAbortTimeoutMs,
|
|
);
|
|
if (activeScript) {
|
|
const stopped = await Promise.race([
|
|
activeScript.settled.then(() => true),
|
|
new Promise<false>((resolve) => {
|
|
const timer = setTimeout(
|
|
() => resolve(false),
|
|
this.assignmentAbortTimeoutMs,
|
|
);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
if (!stopped) {
|
|
throw new Error(
|
|
"Timed out stopping active script; Workflow Run was not aborted",
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
for (const execution of activeExecutions) {
|
|
execution.resolveResumeAfterAbort();
|
|
}
|
|
throw error;
|
|
}
|
|
const latest =
|
|
activeExecutions.length > 0 || activeScript
|
|
? await this.options.store.loadRun(run.runId)
|
|
: run;
|
|
const next = structuredClone(latest);
|
|
next.revision++;
|
|
next.status = "aborted";
|
|
next.lease = undefined;
|
|
const interruptedIds = new Set([
|
|
...activeExecutions.map((execution) => execution.assignmentId),
|
|
...externallyOwned.map((assignment) => assignment.id),
|
|
]);
|
|
for (const assignment of next.assignments) {
|
|
if (!interruptedIds.has(assignment.id)) continue;
|
|
assignment.status = "interrupted";
|
|
assignment.error = "Workflow Run was aborted";
|
|
}
|
|
for (const execution of activeExecutions) {
|
|
const assignment = next.assignments.find(
|
|
(candidate) => candidate.id === execution.assignmentId,
|
|
);
|
|
if (!assignment) continue;
|
|
applyExecutionMetadata(
|
|
assignment,
|
|
execution.metadata,
|
|
execution.transcriptArtifact,
|
|
);
|
|
}
|
|
next.deliveries.push({
|
|
key: `terminal:${next.runId}:aborted:${next.revision}`,
|
|
state: "pending",
|
|
createdAt: this.now(),
|
|
});
|
|
next.reportArtifact = await this.options.store.saveReport(next);
|
|
const result = view(next);
|
|
next.commandReceipts.push({
|
|
key: command.idempotencyKey,
|
|
action: "abort",
|
|
fingerprint: commandFingerprint,
|
|
status: "completed",
|
|
acceptedRevision: next.revision,
|
|
result,
|
|
});
|
|
try {
|
|
await this.options.store.saveRun(next, latest.revision);
|
|
await this.options.store.publishResult(next).catch(() => {});
|
|
await Promise.resolve(this.options.afterRunPersist?.(next)).catch(() => {});
|
|
} finally {
|
|
for (const execution of activeExecutions) {
|
|
execution.resolveResumeAfterAbort();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async resolveRecovery(run: WorkflowRun, decision: GateDecision) {
|
|
if (decision.kind !== "recover") return run;
|
|
if (decision.resolution === "abort") {
|
|
return this.transition(run, (next) => {
|
|
next.status = "aborted";
|
|
next.lease = undefined;
|
|
});
|
|
}
|
|
if (decision.resolution === "retry") {
|
|
if (run.mode === "feature" && run.workspace.state !== "ready") {
|
|
if (
|
|
run.lease &&
|
|
run.lease.processId !== this.processId &&
|
|
this.isProcessAlive(run.lease.processId)
|
|
) {
|
|
throw new Error(
|
|
"Workspace creation is still owned by a live process",
|
|
);
|
|
}
|
|
const creating = { ...run.workspace, state: "creating" as const };
|
|
let workspace = await this.options.workspace.reconcile(
|
|
creating,
|
|
run.lease?.worktreeIdentity,
|
|
);
|
|
if (workspace.state === "creating") {
|
|
try {
|
|
workspace = await this.options.workspace.create(
|
|
this.workspacePlanForRun(run),
|
|
);
|
|
} catch (error) {
|
|
workspace = {
|
|
...creating,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
run = await this.persistWorkspaceOutcome(run, workspace);
|
|
if (run.workspace.state === "ready") return this.runDiscovery(run);
|
|
return run;
|
|
}
|
|
const interruptedMutating = run.assignments.filter(
|
|
(assignment) => assignment.status === "interrupted" && assignment.mutates,
|
|
);
|
|
const interruptedReadOnly = run.assignments.filter(
|
|
(assignment) => assignment.status === "interrupted" && !assignment.mutates,
|
|
);
|
|
const interruptedCommands =
|
|
run.verificationEvidence?.commands.filter(
|
|
(command) => command.status === "running" || command.status === "interrupted",
|
|
) ?? [];
|
|
const failedCommands =
|
|
run.verificationEvidence?.commands.filter(
|
|
(command) => command.status === "failed",
|
|
) ?? [];
|
|
run = await this.transition(run, (next) => {
|
|
for (const assignment of next.assignments) {
|
|
if (
|
|
interruptedReadOnly.some((candidate) => candidate.id === assignment.id)
|
|
) {
|
|
if (
|
|
assignment.selection.rationale.startsWith(
|
|
"Custom read-only script Assignment",
|
|
)
|
|
) {
|
|
assignment.status = "interrupted";
|
|
} else {
|
|
assignment.status = "queued";
|
|
}
|
|
assignment.executionToken = undefined;
|
|
}
|
|
}
|
|
if (
|
|
interruptedReadOnly.some((assignment) =>
|
|
assignment.selection.rationale.startsWith(
|
|
"Custom read-only script Assignment",
|
|
),
|
|
) &&
|
|
next.script
|
|
) {
|
|
next.script.status = "pending";
|
|
next.script.executed = false;
|
|
next.script.error = undefined;
|
|
}
|
|
if (next.verificationEvidence) {
|
|
for (const command of failedCommands) {
|
|
const persisted = next.verificationEvidence.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (!persisted) continue;
|
|
persisted.status = "pending";
|
|
persisted.exitCode = undefined;
|
|
persisted.stdout = undefined;
|
|
persisted.stderr = undefined;
|
|
persisted.error = undefined;
|
|
}
|
|
if (failedCommands.length > 0) {
|
|
next.verificationEvidence.focusedBehavioralTests = false;
|
|
next.verificationEvidence.verifierSucceeded = false;
|
|
}
|
|
for (const command of interruptedCommands) {
|
|
const persisted = next.verificationEvidence.commands.find(
|
|
(candidate) => candidate.id === command.id,
|
|
);
|
|
if (!persisted) continue;
|
|
persisted.status = "superseded";
|
|
next.verificationEvidence.commands.push({
|
|
...structuredClone(persisted),
|
|
id: this.createId(),
|
|
status: "pending",
|
|
exitCode: undefined,
|
|
stdout: undefined,
|
|
stderr: undefined,
|
|
error: undefined,
|
|
});
|
|
}
|
|
}
|
|
next.status = "running";
|
|
});
|
|
if (run.implementationContext?.status === "ready") {
|
|
if (
|
|
interruptedMutating.length > 0 &&
|
|
stage(run, "implement").status !== "completed"
|
|
) {
|
|
run = await this.transition(run, (next) => {
|
|
const implement = stage(next, "implement");
|
|
implement.status = "completed";
|
|
implement.finishedAt = this.now();
|
|
implement.output = {
|
|
recovery: "User requested a new remediation Assignment after interruption",
|
|
};
|
|
});
|
|
return this.remediate(run);
|
|
}
|
|
return this.runImplementationPipeline(run);
|
|
}
|
|
if (
|
|
interruptedMutating.length > 0 &&
|
|
interruptedMutating.every((assignment) => assignment.role === "prototype") &&
|
|
run.uiImpact === "non-trivial"
|
|
) {
|
|
run = await this.transition(run, (next) => {
|
|
for (const interrupted of interruptedMutating) {
|
|
const root = interrupted.ownedPaths[0]?.replace(
|
|
/[/\\](?:candidate|refinement-[^/\\]+-candidate)-\d+$/,
|
|
"",
|
|
);
|
|
if (!root) throw new Error("Interrupted Prototype ownership is invalid");
|
|
next.assignments.push({
|
|
...structuredClone(interrupted),
|
|
id: this.createId(),
|
|
status: "queued",
|
|
executionToken: undefined,
|
|
result: undefined,
|
|
error: undefined,
|
|
selection: {
|
|
...structuredClone(interrupted.selection),
|
|
rationale:
|
|
"Explicit Recovery retry creates a fresh Prototype Assignment after interruption.",
|
|
},
|
|
ownedPaths: [
|
|
path.join(root, `recovery-${next.revision + 1}-${this.createId()}`),
|
|
],
|
|
});
|
|
}
|
|
next.status = "running";
|
|
});
|
|
return this.runPrototypeStage(run);
|
|
}
|
|
if (interruptedMutating.length > 0) {
|
|
return this.openLifecycleRecovery(
|
|
run,
|
|
"Interrupted mutating Assignments remain preserved and cannot be replayed before implementation context is approved",
|
|
interruptedMutating.map((assignment) => assignment.id),
|
|
);
|
|
}
|
|
return this.resumePersistedContinuation(run);
|
|
}
|
|
return this.transition(run, (next) => {
|
|
next.status = "waiting";
|
|
});
|
|
}
|
|
|
|
private workspacePlanForRun(run: WorkflowRun): WorkspacePlan {
|
|
const { workspace } = run;
|
|
if (!workspace.root || !workspace.branch || !workspace.baseHead) {
|
|
throw new Error(
|
|
"Workflow Run is missing persisted workspace creation intent",
|
|
);
|
|
}
|
|
return {
|
|
sourceRoot: workspace.sourceRoot,
|
|
worktreeRoot: path.dirname(workspace.root),
|
|
worktreePath: workspace.root,
|
|
branch: workspace.branch,
|
|
baseHead: workspace.baseHead,
|
|
branchExists: false,
|
|
sourceChanges: structuredClone(workspace.sourceChanges ?? []),
|
|
sourceChangesOmitted: workspace.sourceChangesOmitted ?? 0,
|
|
dirtySourceAllowed: workspace.dirtySourceAllowed ?? false,
|
|
};
|
|
}
|
|
|
|
private async resumePendingDecision(
|
|
run: WorkflowRun,
|
|
receipt: CommandReceipt,
|
|
) {
|
|
const decision = run.decisions.find(
|
|
(candidate) => candidate.idempotencyKey === receipt.key,
|
|
);
|
|
if (!decision)
|
|
throw new Error(`Decision for receipt ${receipt.key} is missing`);
|
|
const gate = run.gates.find(
|
|
(candidate) => candidate.id === decision.gateId,
|
|
);
|
|
if (!gate) throw new Error(`Gate ${decision.gateId} is missing`);
|
|
const continuation =
|
|
receipt.continuation ?? decisionContinuation(gate.kind, decision.value);
|
|
if (continuation === "design") {
|
|
const laterPlanGate = run.gates.some(
|
|
(candidate) =>
|
|
candidate.kind === "plan-approval" &&
|
|
candidate.status === "open" &&
|
|
candidate.revision > receipt.acceptedRevision,
|
|
);
|
|
if (!laterPlanGate && run.status !== "failed") {
|
|
run = await this.runDesign(
|
|
run,
|
|
decision.value.kind === "revise-plan" &&
|
|
run.revision === receipt.acceptedRevision,
|
|
);
|
|
}
|
|
const planGateReady = run.gates.some(
|
|
(candidate) =>
|
|
candidate.kind === "plan-approval" &&
|
|
candidate.status === "open" &&
|
|
candidate.revision > receipt.acceptedRevision,
|
|
);
|
|
if (!planGateReady && run.status !== "failed") return view(run);
|
|
} else if (continuation === "prototype") {
|
|
if (run.status !== "failed") {
|
|
run = await this.runPrototypeStage(run);
|
|
}
|
|
} else if (continuation === "implementation") {
|
|
if (run.status !== "failed") {
|
|
run = await this.runImplementationPipeline(run);
|
|
}
|
|
} else if (continuation === "recovery") {
|
|
run = await this.resolveRecovery(run, decision.value);
|
|
}
|
|
return this.completeReceipt(run, receipt.key);
|
|
}
|
|
|
|
private commandReceipt(
|
|
run: WorkflowRun,
|
|
command: DecideWorkflowCommand | AbortWorkflowCommand,
|
|
) {
|
|
const receipt = run.commandReceipts.find(
|
|
(candidate) => candidate.key === command.idempotencyKey,
|
|
);
|
|
if (!receipt) return undefined;
|
|
if (receipt.fingerprint !== fingerprint(command)) {
|
|
throw new Error("idempotency key was already used with another command");
|
|
}
|
|
return receipt;
|
|
}
|
|
|
|
private async completeReceipt(run: WorkflowRun, key: string) {
|
|
const next = structuredClone(run);
|
|
next.revision++;
|
|
const receipt = next.commandReceipts.find(
|
|
(candidate) => candidate.key === key,
|
|
);
|
|
if (!receipt) throw new Error(`Command receipt ${key} is missing`);
|
|
const result = view(next);
|
|
receipt.status = "completed";
|
|
receipt.result = result;
|
|
await this.options.store.saveRun(next, run.revision);
|
|
return result;
|
|
}
|
|
|
|
private async transition(
|
|
run: WorkflowRun,
|
|
update: (next: WorkflowRun) => void,
|
|
) {
|
|
const next = structuredClone(run);
|
|
update(next);
|
|
next.revision = run.revision + 1;
|
|
if (next.lease) next.lease.heartbeatAt = this.now();
|
|
if (isTerminalStatus(next.status) && !isTerminalStatus(run.status)) {
|
|
next.deliveries.push({
|
|
key: `terminal:${next.runId}:${next.status}:${next.revision}`,
|
|
state: "pending",
|
|
createdAt: this.now(),
|
|
});
|
|
next.reportArtifact = await this.options.store.saveReport(next);
|
|
}
|
|
await this.options.store.saveRun(next, run.revision);
|
|
if (isTerminalStatus(next.status)) {
|
|
await this.options.store.publishResult(next).catch(() => {});
|
|
}
|
|
await Promise.resolve(this.options.afterRunPersist?.(next)).catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
private async failRun(run: WorkflowRun, code: string, error: unknown) {
|
|
return this.transition(run, (next) => {
|
|
next.status = "failed";
|
|
next.error = {
|
|
code,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
});
|
|
}
|
|
|
|
private reclaimStaleRecoveryLease(
|
|
run: WorkflowRun,
|
|
command: WorkflowCommand,
|
|
) {
|
|
if (
|
|
command.action !== "decide" ||
|
|
command.decision.kind !== "recover" ||
|
|
command.decision.resolution !== "retry" ||
|
|
activeGate(run)?.id !== command.gateId ||
|
|
!run.parentSessionIds.includes(this.options.parentSessionId) ||
|
|
!this.ownershipIsRecoverable(run)
|
|
) {
|
|
return run;
|
|
}
|
|
const claimed = structuredClone(run);
|
|
this.rotateOwnership(claimed);
|
|
return claimed;
|
|
}
|
|
|
|
private rotateOwnership(run: WorkflowRun) {
|
|
run.lease = {
|
|
processId: this.processId,
|
|
activeParentSessionId: this.options.parentSessionId,
|
|
heartbeatAt: this.now(),
|
|
worktreeIdentity: run.workspace.root,
|
|
};
|
|
if (run.implementationContext) {
|
|
run.implementationContext.activeParentSessionId =
|
|
this.options.parentSessionId;
|
|
run.implementationContext.runRevision = run.revision + 1;
|
|
}
|
|
run.parentSessionIds = [
|
|
...new Set([...run.parentSessionIds, this.options.parentSessionId]),
|
|
];
|
|
}
|
|
|
|
private assertActiveParent(run: WorkflowRun) {
|
|
if (
|
|
run.lease &&
|
|
run.lease.activeParentSessionId !== this.options.parentSessionId
|
|
) {
|
|
throw new Error(
|
|
"Historical parent sessions may inspect but cannot mutate this run",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async resumePersistedContinuation(run: WorkflowRun) {
|
|
if (
|
|
run.script &&
|
|
!run.script.executed &&
|
|
(run.script.status === "pending" || run.script.status === "running")
|
|
) {
|
|
const insertionReady =
|
|
run.mode === "analysis" ||
|
|
(run.script.insertionPoint === "before-design" &&
|
|
stage(run, "discover").status === "completed") ||
|
|
(run.script.insertionPoint === "after-design" &&
|
|
run.gates.some((gate) => gate.kind === "plan-approval")) ||
|
|
(run.script.insertionPoint === "before-review" &&
|
|
stage(run, "verify").status === "completed");
|
|
if (insertionReady) {
|
|
return this.runWorkflowScript(run, run.script.insertionPoint);
|
|
}
|
|
}
|
|
const pendingDecision = run.commandReceipts.find(
|
|
(receipt) => receipt.action === "decide" && receipt.status === "pending",
|
|
);
|
|
if (pendingDecision) {
|
|
await this.resumePendingDecision(run, pendingDecision);
|
|
return this.options.store.loadRun(run.runId);
|
|
}
|
|
const discover = stage(run, "discover");
|
|
const design = stage(run, "design");
|
|
if (discover.status === "running") return this.runDiscovery(run);
|
|
if (discover.status === "completed" && design.status === "running") {
|
|
return this.runDesign(run);
|
|
}
|
|
if (
|
|
run.status === "running" &&
|
|
run.uiImpact === "non-trivial" &&
|
|
!run.implementationContext
|
|
) {
|
|
return this.runPrototypeStage(run);
|
|
}
|
|
if (
|
|
run.status === "running" &&
|
|
run.implementationContext?.status === "ready"
|
|
) {
|
|
return this.runImplementationPipeline(run);
|
|
}
|
|
return run;
|
|
}
|
|
|
|
private async hasActiveAssignmentLease(
|
|
runId: string,
|
|
assignments: WorkflowAssignment[],
|
|
) {
|
|
for (const assignment of assignments) {
|
|
if (!assignment.executionToken) continue;
|
|
const lease = await this.options.store.loadAssignmentLease(
|
|
runId,
|
|
assignment.id,
|
|
);
|
|
if (!lease || lease.token !== assignment.executionToken) continue;
|
|
const ownerIsRunning =
|
|
lease.processId === this.processId
|
|
? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token)
|
|
: this.isProcessAlive(lease.processId);
|
|
if (ownerIsRunning) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private ownershipIsRecoverable(run: WorkflowRun) {
|
|
if (!run.lease) return true;
|
|
if (
|
|
run.lease.processId === this.processId &&
|
|
run.lease.activeParentSessionId === this.options.parentSessionId
|
|
) {
|
|
return false;
|
|
}
|
|
return !this.isProcessAlive(run.lease.processId);
|
|
}
|
|
|
|
private validateStart(command: StartWorkflowCommand) {
|
|
if (command.request.trim().length === 0)
|
|
throw new Error("request is required");
|
|
if (
|
|
!FEATURE_NAME.test(command.featureName) ||
|
|
command.featureName.length > MAX_FEATURE_NAME_LENGTH
|
|
) {
|
|
throw new Error(
|
|
`featureName must be lowercase kebab case and at most ${MAX_FEATURE_NAME_LENGTH} characters`,
|
|
);
|
|
}
|
|
if (command.idempotencyKey.trim().length === 0) {
|
|
throw new Error("idempotencyKey is required");
|
|
}
|
|
if (
|
|
command.concurrency !== undefined &&
|
|
command.concurrency > this.options.effectiveConfig.concurrency.ceiling
|
|
) {
|
|
throw new Error(
|
|
`concurrency cannot exceed ceiling ${this.options.effectiveConfig.concurrency.ceiling}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private preflightEligibility(initialFacts: WorkflowFacts) {
|
|
const roles: ProfileRole[] = [
|
|
"discover",
|
|
"design",
|
|
"implement",
|
|
"verify",
|
|
"review",
|
|
];
|
|
const evidence: StageEligibilityEvidence[] = roles.map((role) => {
|
|
const candidates = eligibleProfiles(
|
|
this.options.profiles,
|
|
{ ...initialFacts, stage: role },
|
|
role,
|
|
).map((evaluation) => ({
|
|
profileName: evaluation.profile.name,
|
|
profileDigest: evaluation.profile.digest,
|
|
eligible: evaluation.eligible,
|
|
reasons: [...evaluation.reasons],
|
|
specificity: evaluation.specificity,
|
|
}));
|
|
if (!candidates.some((candidate) => candidate.eligible)) {
|
|
throw new Error(`No eligible ${role} Agent Profile is available`);
|
|
}
|
|
return { role, candidates };
|
|
});
|
|
const eligibleNames = (role: ProfileRole) =>
|
|
evidence
|
|
.find((entry) => entry.role === role)
|
|
?.candidates.filter((candidate) => candidate.eligible)
|
|
.map((candidate) => candidate.profileName) ?? [];
|
|
const implementers = eligibleNames("implement");
|
|
const reviewers = eligibleNames("review");
|
|
if (
|
|
!reviewers.some((reviewer) =>
|
|
implementers.some((implementer) => implementer !== reviewer),
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"No independently eligible review Agent Profile is available",
|
|
);
|
|
}
|
|
return evidence;
|
|
}
|
|
}
|