feat(FEAT-001): add loop state columns and validator run tables for mission execution loop
This commit adds the schema migration and types for the mission execution loop validation system: - Adds loop state tracking columns to mission_features table (loopState, implementationAttemptCount, validatorAttemptCount, lastValidatorRunId, lastValidatorStatus, generatedFromFeatureId, generatedFromRunId) - Creates mission_validator_runs table for tracking validation runs - Creates mission_validator_failures table for assertion failure records - Creates mission_fix_feature_lineage table for tracking fix feature relationships - Adds workflowStepRetries column to tasks table for retry tracking - Adds FEATURE_LOOP_STATES and VALIDATOR_RUN_STATUSES enums - Updates TaskStore to support workflowStepRetries field - Updates TaskExecutor to handle workflow step failures with retry logic Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
@@ -164,6 +164,7 @@ function createMockStore() {
|
||||
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
@@ -6581,6 +6582,7 @@ describe("Workflow Steps Execution", () => {
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -6625,6 +6627,7 @@ describe("Workflow Steps Execution", () => {
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -6671,6 +6674,7 @@ describe("Workflow Steps Execution", () => {
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -6703,6 +6707,7 @@ describe("Workflow Steps Execution", () => {
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepRetries: 3, // Exhaust retries so task fails immediately
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -9560,6 +9565,7 @@ describe("StepSessionExecutor integration", () => {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Steps succeed, but workflow step will fail
|
||||
mockExecuteAll.mockResolvedValue([
|
||||
{ stepIndex: 0, success: true, retries: 0 },
|
||||
]);
|
||||
@@ -9567,7 +9573,8 @@ describe("StepSessionExecutor integration", () => {
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
|
||||
await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }] }));
|
||||
// Exhaust retries so workflow step failure is immediate
|
||||
await executor.execute(createTaskWithSteps({ steps: [{ name: "Step 0", status: "pending" }], workflowStepRetries: 3, enabledWorkflowSteps: ["WS-001"] }));
|
||||
|
||||
// Should have called getWorkflowStep to look up the workflow step
|
||||
expect(store.getWorkflowStep).toHaveBeenCalledWith("WS-001");
|
||||
|
||||
@@ -50,6 +50,9 @@ export {
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
/** Maximum retry attempts for workflow step hard failures before giving up */
|
||||
const MAX_WORKFLOW_STEP_RETRIES = 3;
|
||||
|
||||
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
|
||||
|
||||
const taskUpdateParams = Type.Object({
|
||||
@@ -107,7 +110,7 @@ export interface WorkflowStepOutcome {
|
||||
*/
|
||||
export type WorkflowStepResult =
|
||||
| { allPassed: true }
|
||||
| { allPassed: false; revisionRequested: false }
|
||||
| { allPassed: false; revisionRequested: false; feedback: string; stepName: string }
|
||||
| { allPassed: false; revisionRequested: true; feedback: string; stepName: string };
|
||||
|
||||
|
||||
@@ -1112,7 +1115,12 @@ export class TaskExecutor {
|
||||
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
||||
return;
|
||||
}
|
||||
// Hard failure - move to in-review
|
||||
// Try to fix workflow step failures with retries
|
||||
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
||||
if (retried) {
|
||||
return; // Retry scheduled
|
||||
}
|
||||
// Retries exhausted - hard failure
|
||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
// Audit trail: record task move (FN-1404)
|
||||
@@ -1122,6 +1130,9 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset workflowStepRetries counter on success
|
||||
await this.store.updateTask(task.id, { workflowStepRetries: undefined });
|
||||
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
// Audit trail: record task move (FN-1404)
|
||||
await audit.database({ type: "task:move", target: task.id, metadata: { to: "in-review" } });
|
||||
@@ -1529,7 +1540,12 @@ export class TaskExecutor {
|
||||
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
||||
return;
|
||||
}
|
||||
// Hard failure - move to in-review so users can see the failure
|
||||
// Try to fix workflow step failures with retries
|
||||
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
||||
if (retried) {
|
||||
return; // Retry scheduled
|
||||
}
|
||||
// Retries exhausted - hard failure
|
||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
executorLog.log(`✗ ${task.id} workflow step failed → in-review`);
|
||||
@@ -1537,6 +1553,9 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset workflowStepRetries counter on success
|
||||
await this.store.updateTask(task.id, { workflowStepRetries: undefined });
|
||||
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
executorLog.log(`✓ ${task.id} completed → in-review`);
|
||||
this.options.onComplete?.(task);
|
||||
@@ -2431,6 +2450,155 @@ ${feedback}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workflow step hard failures by retrying execution up to MAX_WORKFLOW_STEP_RETRIES times.
|
||||
* This gives the executor a chance to fix workflow step failures automatically before
|
||||
* moving the task to in-review with failed status.
|
||||
*
|
||||
* @returns true if a retry was scheduled, false if retries are exhausted
|
||||
*/
|
||||
private async handleWorkflowStepFailure(
|
||||
task: Task,
|
||||
worktreePath: string,
|
||||
failureFeedback: string,
|
||||
stepName: string,
|
||||
): Promise<boolean> {
|
||||
const currentRetries = task.workflowStepRetries ?? 0;
|
||||
|
||||
if (currentRetries >= MAX_WORKFLOW_STEP_RETRIES) {
|
||||
// Retries exhausted — caller should fall through to hard failure
|
||||
executorLog.warn(`${task.id}: workflow step "${stepName}" failed — retries exhausted (${MAX_WORKFLOW_STEP_RETRIES}/${MAX_WORKFLOW_STEP_RETRIES})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const retryCount = currentRetries + 1;
|
||||
executorLog.log(`${task.id}: workflow step "${stepName}" failed — retry ${retryCount}/${MAX_WORKFLOW_STEP_RETRIES} (executor will attempt to fix)`);
|
||||
|
||||
// 1. Update the workflowStepRetries counter on the task
|
||||
await this.store.updateTask(task.id, {
|
||||
workflowStepRetries: retryCount,
|
||||
});
|
||||
|
||||
// 2. Inject failure feedback into PROMPT.md
|
||||
await this.injectWorkflowStepFailureInstructions(task, failureFeedback, stepName, retryCount);
|
||||
|
||||
// 3. Reset all steps to pending for fresh execution
|
||||
const updatedTask = await this.store.getTask(task.id);
|
||||
for (let i = 0; i < updatedTask.steps.length; i++) {
|
||||
if (updatedTask.steps[i].status !== "pending") {
|
||||
await this.store.updateStep(task.id, i, "pending");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Clear any session file so we get a fresh session
|
||||
await this.store.updateTask(task.id, {
|
||||
status: null,
|
||||
sessionFile: null,
|
||||
});
|
||||
|
||||
// 5. Schedule fresh execution after guard unwinds
|
||||
executorLog.log(`${task.id}: scheduling fresh execution after workflow step failure (retry ${retryCount}/${MAX_WORKFLOW_STEP_RETRIES})`);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Move task to todo briefly, then back to in-progress to trigger fresh execution
|
||||
await this.store.moveTask(task.id, "todo");
|
||||
await this.store.moveTask(task.id, "in-progress");
|
||||
executorLog.log(`${task.id}: workflow step retry scheduled — moved to todo then in-progress`);
|
||||
} catch (err: any) {
|
||||
executorLog.error(`${task.id}: failed to schedule workflow step retry: ${err.message}`);
|
||||
// Fallback: log entry and let scheduler pick it up on next tick
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Workflow step failed — executor ready for fresh execution",
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject or update the "Workflow Step Failure" section in PROMPT.md.
|
||||
* This section contains failure feedback from workflow steps that hard-failed.
|
||||
* The section is replaced entirely to avoid accumulation of old feedback.
|
||||
*/
|
||||
private async injectWorkflowStepFailureInstructions(
|
||||
task: Task,
|
||||
failureFeedback: string,
|
||||
stepName: string,
|
||||
retryCount: number,
|
||||
): Promise<void> {
|
||||
const promptPath = join(this.store.getFusionDir(), "tasks", task.id, "PROMPT.md");
|
||||
|
||||
// Read existing PROMPT.md
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(promptPath, "utf-8");
|
||||
} catch {
|
||||
executorLog.warn(`${task.id}: PROMPT.md not found at ${promptPath}, skipping workflow failure injection`);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingRetries = MAX_WORKFLOW_STEP_RETRIES - retryCount;
|
||||
const failureSectionHeader = "## Workflow Step Failure";
|
||||
const failureSectionContent = `${failureSectionHeader}
|
||||
|
||||
The following workflow step failed and requires implementation fixes:
|
||||
|
||||
**Step:** ${stepName}
|
||||
|
||||
**Failure Feedback:**
|
||||
${failureFeedback}
|
||||
|
||||
**Retry:** ${retryCount}/${MAX_WORKFLOW_STEP_RETRIES} (${remainingRetries} remaining)
|
||||
|
||||
**Important:** This is a workflow step failure — fix the issues above by making the necessary code changes. The task will be retried automatically. If all ${MAX_WORKFLOW_STEP_RETRIES} retries are exhausted, the task will be moved to in-review for manual inspection.
|
||||
|
||||
`;
|
||||
|
||||
let newContent: string;
|
||||
if (content.includes(failureSectionHeader)) {
|
||||
// Replace existing section
|
||||
const sectionRegex = new RegExp(
|
||||
`${failureSectionHeader}[\\s\\S]*?(?=\\n## |\\n# |$)`,
|
||||
"i"
|
||||
);
|
||||
if (sectionRegex.test(content)) {
|
||||
newContent = content.replace(sectionRegex, failureSectionContent);
|
||||
} else {
|
||||
// Fallback: append at end
|
||||
newContent = content + "\n" + failureSectionContent;
|
||||
}
|
||||
} else {
|
||||
// Remove any existing Workflow Revision Instructions section first (conflicting state)
|
||||
const revisionSectionHeader = "## Workflow Revision Instructions";
|
||||
if (content.includes(revisionSectionHeader)) {
|
||||
const revisionRegex = new RegExp(
|
||||
`${revisionSectionHeader}[\\s\\S]*?(?=\\n## |\\n# |$)`,
|
||||
"i"
|
||||
);
|
||||
content = content.replace(revisionRegex, "");
|
||||
}
|
||||
|
||||
// Append new section before any closing markers or at end
|
||||
const acceptanceCriteriaMatch = content.match(/\n##\s+Acceptance Criteria\n/);
|
||||
if (acceptanceCriteriaMatch) {
|
||||
const insertIdx = acceptanceCriteriaMatch.index!;
|
||||
newContent = content.slice(0, insertIdx) + "\n" + failureSectionContent + content.slice(insertIdx);
|
||||
} else {
|
||||
newContent = content + "\n" + failureSectionContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated content
|
||||
try {
|
||||
await writeFile(promptPath, newContent);
|
||||
executorLog.log(`${task.id}: injected workflow step failure instructions into PROMPT.md (retry ${retryCount}/${MAX_WORKFLOW_STEP_RETRIES})`);
|
||||
} catch (err: any) {
|
||||
executorLog.error(`${task.id}: failed to inject workflow step failure instructions: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the list of files modified during agent execution.
|
||||
* Uses git diff against the stored baseCommitSha to determine what changed.
|
||||
@@ -2630,7 +2798,12 @@ ${feedback}
|
||||
completedAt,
|
||||
});
|
||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||
return { allPassed: false, revisionRequested: false };
|
||||
return {
|
||||
allPassed: false,
|
||||
revisionRequested: false,
|
||||
feedback: result.error || "Workflow step failed",
|
||||
stepName: ws.name,
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
const completedAt = new Date().toISOString();
|
||||
@@ -2650,7 +2823,12 @@ ${feedback}
|
||||
completedAt,
|
||||
});
|
||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||
return { allPassed: false, revisionRequested: false };
|
||||
return {
|
||||
allPassed: false,
|
||||
revisionRequested: false,
|
||||
feedback: err.message || "Workflow step error",
|
||||
stepName: ws.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user