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:
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -135,7 +135,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -742,7 +742,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -767,11 +767,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -787,7 +787,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -811,7 +811,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -915,7 +915,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1281,7 +1281,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 29;
|
||||
const SCHEMA_VERSION = 31;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -144,6 +144,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
planningModelProvider TEXT,
|
||||
planningModelId TEXT,
|
||||
mergeRetries INTEGER,
|
||||
workflowStepRetries INTEGER,
|
||||
recoveryRetryCount INTEGER,
|
||||
nextRecoveryAt TEXT,
|
||||
error TEXT,
|
||||
@@ -1080,6 +1081,95 @@ export class Database {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFeatureAssertionsAssertionId ON mission_feature_assertions(assertionId)`);
|
||||
});
|
||||
}
|
||||
|
||||
// Workflow step failure retry support (FN-1586)
|
||||
// Adds workflowStepRetries column to track retry attempts for workflow step hard failures
|
||||
if (version < 30) {
|
||||
this.applyMigration(30, () => {
|
||||
this.addColumnIfMissing("tasks", "workflowStepRetries", "INTEGER");
|
||||
});
|
||||
}
|
||||
|
||||
// Loop state and validator run tables (FEAT-001)
|
||||
// Adds loop state tracking columns to mission_features for the execution loop:
|
||||
// implementationAttemptCount, validatorAttemptCount, lastValidatorRunId, lastValidatorStatus,
|
||||
// generatedFromFeatureId, generatedFromRunId, loopState
|
||||
if (version < 31) {
|
||||
this.applyMigration(31, () => {
|
||||
// Add loop state columns to mission_features
|
||||
this.addColumnIfMissing("mission_features", "loopState", "TEXT NOT NULL DEFAULT 'idle'");
|
||||
this.addColumnIfMissing("mission_features", "implementationAttemptCount", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.addColumnIfMissing("mission_features", "validatorAttemptCount", "INTEGER NOT NULL DEFAULT 0");
|
||||
this.addColumnIfMissing("mission_features", "lastValidatorRunId", "TEXT");
|
||||
this.addColumnIfMissing("mission_features", "lastValidatorStatus", "TEXT");
|
||||
this.addColumnIfMissing("mission_features", "generatedFromFeatureId", "TEXT");
|
||||
this.addColumnIfMissing("mission_features", "generatedFromRunId", "TEXT");
|
||||
|
||||
// Create mission_validator_runs table for tracking validation runs
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_validator_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
featureId TEXT NOT NULL,
|
||||
milestoneId TEXT NOT NULL,
|
||||
sliceId TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
triggerType TEXT,
|
||||
implementationAttempt INTEGER NOT NULL DEFAULT 0,
|
||||
validatorAttempt INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT,
|
||||
blockedReason TEXT,
|
||||
startedAt TEXT NOT NULL,
|
||||
completedAt TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (featureId) REFERENCES mission_features(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (milestoneId) REFERENCES milestones(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sliceId) REFERENCES slices(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorRunsFeatureId ON mission_validator_runs(featureId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorRunsMilestoneId ON mission_validator_runs(milestoneId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorRunsSliceId ON mission_validator_runs(sliceId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorRunsStatus ON mission_validator_runs(status)`);
|
||||
|
||||
// Create mission_validator_failures table for assertion failure records
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_validator_failures (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
featureId TEXT NOT NULL,
|
||||
assertionId TEXT NOT NULL,
|
||||
message TEXT,
|
||||
expected TEXT,
|
||||
actual TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (runId) REFERENCES mission_validator_runs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (featureId) REFERENCES mission_features(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorFailuresRunId ON mission_validator_failures(runId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorFailuresFeatureId ON mission_validator_failures(featureId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxValidatorFailuresAssertionId ON mission_validator_failures(assertionId)`);
|
||||
|
||||
// Create mission_fix_feature_lineage table for tracking fix feature relationships
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_fix_feature_lineage (
|
||||
id TEXT PRIMARY KEY,
|
||||
sourceFeatureId TEXT NOT NULL,
|
||||
fixFeatureId TEXT NOT NULL,
|
||||
runId TEXT NOT NULL,
|
||||
failedAssertionIds TEXT NOT NULL DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (sourceFeatureId) REFERENCES mission_features(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fixFeatureId) REFERENCES mission_features(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (runId) REFERENCES mission_validator_runs(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFixLineageSourceFeatureId ON mission_fix_feature_lineage(sourceFeatureId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFixLineageFixFeatureId ON mission_fix_feature_lineage(fixFeatureId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFixLineageRunId ON mission_fix_feature_lineage(runId)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,6 +179,8 @@ export {
|
||||
AUTOPILOT_STATES,
|
||||
MISSION_EVENT_TYPES,
|
||||
SLICE_PLAN_STATES,
|
||||
FEATURE_LOOP_STATES,
|
||||
VALIDATOR_RUN_STATUSES,
|
||||
} from "./mission-types.js";
|
||||
export type {
|
||||
MissionStatus,
|
||||
@@ -188,6 +190,8 @@ export type {
|
||||
InterviewState,
|
||||
AutopilotState,
|
||||
SlicePlanState,
|
||||
FeatureLoopState,
|
||||
ValidatorRunStatus,
|
||||
MissionEventType,
|
||||
AutopilotStatus,
|
||||
Mission,
|
||||
@@ -213,6 +217,11 @@ export type {
|
||||
FeatureEventPayload,
|
||||
FeatureDeletedPayload,
|
||||
FeatureLinkedPayload,
|
||||
// Validator run types
|
||||
MissionValidatorRun,
|
||||
MissionAssertionFailureRecord,
|
||||
MissionFixFeatureLineage,
|
||||
MissionFeatureLoopSnapshot,
|
||||
// Contract assertion types
|
||||
MISSION_ASSERTION_STATUSES,
|
||||
MILESTONE_VALIDATION_STATES,
|
||||
|
||||
@@ -2453,6 +2453,191 @@ describe("MissionStore", () => {
|
||||
expect(description).not.toContain("## Contract Assertions");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loop State & Validator Run Schema Tests ───────────────────────────
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 31 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
const cols = db.prepare("PRAGMA table_info(mission_features)").all() as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
expect(colNames).toContain("loopState");
|
||||
expect(colNames).toContain("implementationAttemptCount");
|
||||
expect(colNames).toContain("validatorAttemptCount");
|
||||
expect(colNames).toContain("lastValidatorRunId");
|
||||
expect(colNames).toContain("lastValidatorStatus");
|
||||
expect(colNames).toContain("generatedFromFeatureId");
|
||||
expect(colNames).toContain("generatedFromRunId");
|
||||
});
|
||||
|
||||
it("mission_validator_runs table exists with correct schema", () => {
|
||||
const cols = db.prepare("PRAGMA table_info(mission_validator_runs)").all() as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
expect(colNames).toContain("id");
|
||||
expect(colNames).toContain("featureId");
|
||||
expect(colNames).toContain("milestoneId");
|
||||
expect(colNames).toContain("sliceId");
|
||||
expect(colNames).toContain("status");
|
||||
expect(colNames).toContain("triggerType");
|
||||
expect(colNames).toContain("implementationAttempt");
|
||||
expect(colNames).toContain("validatorAttempt");
|
||||
expect(colNames).toContain("summary");
|
||||
expect(colNames).toContain("blockedReason");
|
||||
expect(colNames).toContain("startedAt");
|
||||
expect(colNames).toContain("completedAt");
|
||||
expect(colNames).toContain("createdAt");
|
||||
expect(colNames).toContain("updatedAt");
|
||||
});
|
||||
|
||||
it("mission_validator_failures table exists with correct schema", () => {
|
||||
const cols = db.prepare("PRAGMA table_info(mission_validator_failures)").all() as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
expect(colNames).toContain("id");
|
||||
expect(colNames).toContain("runId");
|
||||
expect(colNames).toContain("featureId");
|
||||
expect(colNames).toContain("assertionId");
|
||||
expect(colNames).toContain("message");
|
||||
expect(colNames).toContain("expected");
|
||||
expect(colNames).toContain("actual");
|
||||
expect(colNames).toContain("createdAt");
|
||||
});
|
||||
|
||||
it("mission_fix_feature_lineage table exists with correct schema", () => {
|
||||
const cols = db.prepare("PRAGMA table_info(mission_fix_feature_lineage)").all() as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map((c) => c.name));
|
||||
expect(colNames).toContain("id");
|
||||
expect(colNames).toContain("sourceFeatureId");
|
||||
expect(colNames).toContain("fixFeatureId");
|
||||
expect(colNames).toContain("runId");
|
||||
expect(colNames).toContain("failedAssertionIds");
|
||||
expect(colNames).toContain("createdAt");
|
||||
});
|
||||
|
||||
it("addFeature creates feature with correct loop state defaults", () => {
|
||||
const mission = store.createMission({ title: "Loop State Test" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = store.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
expect(feature.loopState).toBe("idle");
|
||||
expect(feature.implementationAttemptCount).toBe(0);
|
||||
expect(feature.validatorAttemptCount).toBe(0);
|
||||
expect(feature.lastValidatorRunId).toBeUndefined();
|
||||
expect(feature.lastValidatorStatus).toBeUndefined();
|
||||
expect(feature.generatedFromFeatureId).toBeUndefined();
|
||||
expect(feature.generatedFromRunId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getFeature returns feature with correct loop state defaults via rowToFeature", () => {
|
||||
const mission = store.createMission({ title: "Loop State Test" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const created = store.addFeature(slice.id, { title: "Test Feature" });
|
||||
const retrieved = store.getFeature(created.id);
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.loopState).toBe("idle");
|
||||
expect(retrieved!.implementationAttemptCount).toBe(0);
|
||||
expect(retrieved!.validatorAttemptCount).toBe(0);
|
||||
expect(retrieved!.lastValidatorRunId).toBeUndefined();
|
||||
expect(retrieved!.lastValidatorStatus).toBeUndefined();
|
||||
expect(retrieved!.generatedFromFeatureId).toBeUndefined();
|
||||
expect(retrieved!.generatedFromRunId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updateFeature persists loop state fields", () => {
|
||||
const mission = store.createMission({ title: "Loop State Test" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = store.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
const updated = store.updateFeature(feature.id, {
|
||||
loopState: "implementing",
|
||||
implementationAttemptCount: 1,
|
||||
validatorAttemptCount: 0,
|
||||
lastValidatorRunId: "VR-TEST-001",
|
||||
lastValidatorStatus: "running",
|
||||
});
|
||||
|
||||
expect(updated.loopState).toBe("implementing");
|
||||
expect(updated.implementationAttemptCount).toBe(1);
|
||||
expect(updated.validatorAttemptCount).toBe(0);
|
||||
expect(updated.lastValidatorRunId).toBe("VR-TEST-001");
|
||||
expect(updated.lastValidatorStatus).toBe("running");
|
||||
|
||||
// Verify persisted
|
||||
const retrieved = store.getFeature(feature.id);
|
||||
expect(retrieved!.loopState).toBe("implementing");
|
||||
expect(retrieved!.implementationAttemptCount).toBe(1);
|
||||
expect(retrieved!.lastValidatorRunId).toBe("VR-TEST-001");
|
||||
expect(retrieved!.lastValidatorStatus).toBe("running");
|
||||
});
|
||||
|
||||
it("existing feature read has correct defaults for new columns", () => {
|
||||
// Create a feature using the store (which sets loop state defaults)
|
||||
const mission = store.createMission({ title: "Existing Feature Test" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = store.addFeature(slice.id, { title: "Existing Feature" });
|
||||
|
||||
// Simulate reading from DB directly (as rowToFeature would)
|
||||
const row = db.prepare("SELECT * FROM mission_features WHERE id = ?").get(feature.id);
|
||||
expect((row as any).loopState).toBe("idle");
|
||||
expect((row as any).implementationAttemptCount).toBe(0);
|
||||
expect((row as any).validatorAttemptCount).toBe(0);
|
||||
expect((row as any).lastValidatorRunId).toBeNull();
|
||||
expect((row as any).lastValidatorStatus).toBeNull();
|
||||
});
|
||||
|
||||
it("migration is idempotent - running twice does not fail", () => {
|
||||
const versionBefore = db.getSchemaVersion();
|
||||
// init() calls migrate(), calling again should be a no-op
|
||||
db.init();
|
||||
const versionAfter = db.getSchemaVersion();
|
||||
expect(versionAfter).toBe(versionBefore);
|
||||
});
|
||||
|
||||
it("foreign key constraints exist on validator runs table", () => {
|
||||
// Create full hierarchy
|
||||
const mission = store.createMission({ title: "FK Test" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = store.addFeature(slice.id, { title: "FK Feature" });
|
||||
|
||||
// Insert a validator run
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO mission_validator_runs (id, featureId, milestoneId, sliceId, status, implementationAttempt, validatorAttempt, startedAt, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run("VR-TEST-001", feature.id, milestone.id, slice.id, "running", 1, 1, now, now, now);
|
||||
|
||||
// Verify the run exists
|
||||
const run = db.prepare("SELECT * FROM mission_validator_runs WHERE id = ?").get("VR-TEST-001");
|
||||
expect(run).toBeDefined();
|
||||
expect((run as any).featureId).toBe(feature.id);
|
||||
});
|
||||
|
||||
it("validator runs index exists", () => {
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='mission_validator_runs'").all() as Array<{ name: string }>;
|
||||
const indexNames = new Set(indexes.map((i) => i.name));
|
||||
expect(indexNames).toContain("idxValidatorRunsFeatureId");
|
||||
});
|
||||
|
||||
it("validator failures index exists", () => {
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='mission_validator_failures'").all() as Array<{ name: string }>;
|
||||
const indexNames = new Set(indexes.map((i) => i.name));
|
||||
expect(indexNames).toContain("idxValidatorFailuresRunId");
|
||||
});
|
||||
|
||||
it("fix lineage index exists", () => {
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='mission_fix_feature_lineage'").all() as Array<{ name: string }>;
|
||||
const indexNames = new Set(indexes.map((i) => i.name));
|
||||
expect(indexNames).toContain("idxFixLineageSourceFeatureId");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// vi import for vitest mocking
|
||||
|
||||
@@ -234,6 +234,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
status: row.status as FeatureStatus,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
loopState: (row.loopState as import("./mission-types.js").FeatureLoopState) || "idle",
|
||||
implementationAttemptCount: row.implementationAttemptCount ?? 0,
|
||||
validatorAttemptCount: row.validatorAttemptCount ?? 0,
|
||||
lastValidatorRunId: row.lastValidatorRunId || undefined,
|
||||
lastValidatorStatus: row.lastValidatorStatus as import("./mission-types.js").ValidatorRunStatus || undefined,
|
||||
generatedFromFeatureId: row.generatedFromFeatureId || undefined,
|
||||
generatedFromRunId: row.generatedFromRunId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1401,11 +1408,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
status: "defined",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
loopState: "idle",
|
||||
implementationAttemptCount: 0,
|
||||
validatorAttemptCount: 0,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO mission_features (id, sliceId, title, description, acceptanceCriteria, status, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO mission_features (id, sliceId, title, description, acceptanceCriteria, status, loopState, implementationAttemptCount, validatorAttemptCount, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
feature.id,
|
||||
feature.sliceId,
|
||||
@@ -1413,6 +1423,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
feature.description ?? null,
|
||||
feature.acceptanceCriteria ?? null,
|
||||
feature.status,
|
||||
feature.loopState ?? "idle",
|
||||
feature.implementationAttemptCount ?? 0,
|
||||
feature.validatorAttemptCount ?? 0,
|
||||
feature.createdAt,
|
||||
feature.updatedAt,
|
||||
);
|
||||
@@ -1477,6 +1490,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
acceptanceCriteria = ?,
|
||||
status = ?,
|
||||
taskId = ?,
|
||||
loopState = ?,
|
||||
implementationAttemptCount = ?,
|
||||
validatorAttemptCount = ?,
|
||||
lastValidatorRunId = ?,
|
||||
lastValidatorStatus = ?,
|
||||
generatedFromFeatureId = ?,
|
||||
generatedFromRunId = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -1485,6 +1505,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
updated.acceptanceCriteria ?? null,
|
||||
updated.status,
|
||||
updated.taskId ?? null,
|
||||
updated.loopState ?? "idle",
|
||||
updated.implementationAttemptCount ?? 0,
|
||||
updated.validatorAttemptCount ?? 0,
|
||||
updated.lastValidatorRunId ?? null,
|
||||
updated.lastValidatorStatus ?? null,
|
||||
updated.generatedFromFeatureId ?? null,
|
||||
updated.generatedFromRunId ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
@@ -31,6 +31,14 @@ export type SlicePlanState = (typeof SLICE_PLAN_STATES)[number];
|
||||
export const FEATURE_STATUSES = ["defined", "triaged", "in-progress", "done", "blocked"] as const;
|
||||
export type FeatureStatus = (typeof FEATURE_STATUSES)[number];
|
||||
|
||||
/** Loop state values for a feature's execution loop lifecycle */
|
||||
export const FEATURE_LOOP_STATES = ["idle", "implementing", "validating", "needs_fix", "passed", "blocked"] as const;
|
||||
export type FeatureLoopState = (typeof FEATURE_LOOP_STATES)[number];
|
||||
|
||||
/** Status values for a validator run */
|
||||
export const VALIDATOR_RUN_STATUSES = ["running", "passed", "failed", "blocked", "error"] as const;
|
||||
export type ValidatorRunStatus = (typeof VALIDATOR_RUN_STATUSES)[number];
|
||||
|
||||
/** Interview state for AI-assisted specification */
|
||||
export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const;
|
||||
export type InterviewState = (typeof INTERVIEW_STATES)[number];
|
||||
@@ -221,6 +229,130 @@ export interface MissionFeature {
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
/** Current loop state for the execution loop (idle, implementing, validating, needs_fix, passed, blocked) */
|
||||
loopState?: FeatureLoopState;
|
||||
/** Number of implementation attempts made for this feature */
|
||||
implementationAttemptCount?: number;
|
||||
/** Number of validation attempts made for this feature */
|
||||
validatorAttemptCount?: number;
|
||||
/** ID of the last validator run for this feature */
|
||||
lastValidatorRunId?: string;
|
||||
/** Status of the last validator run (passed, failed, blocked, error) */
|
||||
lastValidatorStatus?: ValidatorRunStatus;
|
||||
/** Feature ID that generated this feature as a fix (for lineage tracking) */
|
||||
generatedFromFeatureId?: string;
|
||||
/** Validator run ID that generated this feature as a fix (for lineage tracking) */
|
||||
generatedFromRunId?: string;
|
||||
}
|
||||
|
||||
// ── Validator Run & Loop Types ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A validator run represents a single execution of the validation phase
|
||||
* for a feature within the mission execution loop.
|
||||
*/
|
||||
export interface MissionValidatorRun {
|
||||
/** Unique identifier (e.g., "VR-XXXXXXXX-XXXX") */
|
||||
id: string;
|
||||
/** Parent feature ID */
|
||||
featureId: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Parent slice ID */
|
||||
sliceId: string;
|
||||
/** Current status of the run */
|
||||
status: ValidatorRunStatus;
|
||||
/** What triggered this run (e.g., "task_completion", "manual", "scheduled") */
|
||||
triggerType?: string;
|
||||
/** Which implementation attempt this run corresponds to */
|
||||
implementationAttempt: number;
|
||||
/** Which validation attempt this run corresponds to */
|
||||
validatorAttempt: number;
|
||||
/** Summary of the validation run results */
|
||||
summary?: string;
|
||||
/** Reason for blocked status if applicable */
|
||||
blockedReason?: string;
|
||||
/** ISO-8601 timestamp when the run started */
|
||||
startedAt: string;
|
||||
/** ISO-8601 timestamp when the run completed (if completed) */
|
||||
completedAt?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An assertion failure record represents a single assertion failure
|
||||
* within a validator run.
|
||||
*/
|
||||
export interface MissionAssertionFailureRecord {
|
||||
/** Unique identifier (e.g., "VAF-XXXXXXXX-XXXX") */
|
||||
id: string;
|
||||
/** Parent validator run ID */
|
||||
runId: string;
|
||||
/** Feature ID this failure belongs to */
|
||||
featureId: string;
|
||||
/** Assertion ID that failed */
|
||||
assertionId: string;
|
||||
/** Human-readable failure message */
|
||||
message?: string;
|
||||
/** Expected value or behavior */
|
||||
expected?: string;
|
||||
/** Actual value or behavior */
|
||||
actual?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fix feature lineage record tracks the relationship between a source
|
||||
* feature and a generated fix feature within the execution loop.
|
||||
*/
|
||||
export interface MissionFixFeatureLineage {
|
||||
/** Unique identifier (e.g., "FFL-XXXXXXXX-XXXX") */
|
||||
id: string;
|
||||
/** Source feature ID that failed validation */
|
||||
sourceFeatureId: string;
|
||||
/** Generated fix feature ID */
|
||||
fixFeatureId: string;
|
||||
/** Validator run ID that triggered the fix generation */
|
||||
runId: string;
|
||||
/** JSON array of assertion IDs that failed and triggered the fix */
|
||||
failedAssertionIds: string[];
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete loop state snapshot for a feature, including all validator
|
||||
* runs, failures, and lineage information.
|
||||
*/
|
||||
export interface MissionFeatureLoopSnapshot {
|
||||
/** Feature ID */
|
||||
featureId: string;
|
||||
/** The feature object */
|
||||
feature: MissionFeature;
|
||||
/** Current loop state */
|
||||
loopState: FeatureLoopState;
|
||||
/** Number of implementation attempts */
|
||||
implementationAttemptCount: number;
|
||||
/** Number of validation attempts */
|
||||
validatorAttemptCount: number;
|
||||
/** ID of the last validator run */
|
||||
lastValidatorRunId?: string;
|
||||
/** Status of the last validator run */
|
||||
lastValidatorStatus?: ValidatorRunStatus;
|
||||
/** Feature ID that generated this feature (if applicable) */
|
||||
generatedFromFeatureId?: string;
|
||||
/** Validator run ID that generated this feature (if applicable) */
|
||||
generatedFromRunId?: string;
|
||||
/** All validator runs for this feature, newest first */
|
||||
validatorRuns: MissionValidatorRun[];
|
||||
/** All assertion failures across all runs */
|
||||
failures: MissionAssertionFailureRecord[];
|
||||
/** All lineage entries for this feature (as source or fix) */
|
||||
lineage: MissionFixFeatureLineage[];
|
||||
}
|
||||
|
||||
// ── Input Types (for creation) ──────────────────────────────────────
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 28", () => {
|
||||
expect(db.getSchemaVersion()).toBe(29);
|
||||
expect(db.getSchemaVersion()).toBe(31);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +197,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
planningModelProvider: row.planningModelProvider || undefined,
|
||||
planningModelId: row.planningModelId || undefined,
|
||||
mergeRetries: row.mergeRetries ?? undefined,
|
||||
workflowStepRetries: row.workflowStepRetries ?? undefined,
|
||||
stuckKillCount: row.stuckKillCount ?? undefined,
|
||||
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
|
||||
nextRecoveryAt: row.nextRecoveryAt || undefined,
|
||||
@@ -285,14 +286,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
||||
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
|
||||
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
||||
stuckKillCount, recoveryRetryCount, nextRecoveryAt, error,
|
||||
workflowStepRetries, stuckKillCount, recoveryRetryCount, nextRecoveryAt, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -317,6 +318,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.planningModelProvider ?? null,
|
||||
task.planningModelId ?? null,
|
||||
task.mergeRetries ?? null,
|
||||
task.workflowStepRetries ?? null,
|
||||
task.stuckKillCount ?? 0,
|
||||
task.recoveryRetryCount ?? null,
|
||||
task.nextRecoveryAt ?? null,
|
||||
@@ -1604,7 +1606,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
runContext?: RunMutationContext,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
@@ -1698,6 +1700,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (updates.size !== undefined) task.size = updates.size;
|
||||
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
|
||||
if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries;
|
||||
if (updates.workflowStepRetries !== undefined) task.workflowStepRetries = updates.workflowStepRetries;
|
||||
if (updates.stuckKillCount === null) {
|
||||
task.stuckKillCount = undefined;
|
||||
} else if (updates.stuckKillCount !== undefined) {
|
||||
|
||||
@@ -673,6 +673,10 @@ export interface Task {
|
||||
workflowStepResults?: WorkflowStepResult[];
|
||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||
mergeRetries?: number;
|
||||
/** Number of workflow step failure retry attempts made for this task.
|
||||
* When pre-merge workflow steps fail, the executor retries up to MAX_WORKFLOW_STEP_RETRIES
|
||||
* times before marking the task as failed. Cleared on successful workflow step completion. */
|
||||
workflowStepRetries?: number;
|
||||
/** Number of times the stuck-task detector has killed this task's agent session.
|
||||
* Incremented by the self-healing manager on each stuck kill. When this reaches
|
||||
* `maxStuckKills`, the task is marked as permanently failed instead of re-queued. */
|
||||
|
||||
@@ -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