fix: stabilize workflow board and loop recovery

This commit is contained in:
gsxdsm
2026-06-07 22:48:28 -07:00
parent e1a244adf6
commit e138971030
33 changed files with 1372 additions and 321 deletions

View File

@@ -1154,6 +1154,40 @@ describe("StuckTaskDetector", () => {
vi.useRealTimers();
});
it("suppresses another loop classification while accepted recovery is pending", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true);
const customDetector = new StuckTaskDetector(store, { onLoopDetected });
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
customDetector.trackTask("FN-201", session);
vi.advanceTimersByTime(61000);
for (let i = 0; i < 80; i++) {
customDetector.recordActivity("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
await customDetector.killAndRetry("FN-201", 60000);
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBeNull();
customDetector.markLoopObserved("FN-201");
for (let i = 0; i < 25; i++) {
customDetector.recordIgnoredStepUpdate("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("no-progress-churn");
customDetector.recordProgress("FN-201");
vi.advanceTimersByTime(61000);
for (let i = 0; i < 80; i++) {
customDetector.recordActivity("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
vi.useRealTimers();
});
it("does NOT call onLoopDetected when reason is inactivity", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true);
const onStuck = vi.fn();

View File

@@ -163,6 +163,25 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(result.reason).toMatch(/workflow-missing/);
});
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
const calls: string[] = [];
const store: WorkflowGraphRunnerStore = {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: async () => undefined,
};
const runner = new WorkflowGraphTaskRunner({
store,
seams: recordingSeams(calls),
runCustomNode: async () => ({ outcome: "success" }),
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(result.reason).toBeUndefined();
});
it("falls back (never strands the task) when the interpreter throws", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
const badIr: WorkflowIr = {

View File

@@ -419,6 +419,15 @@ export class StuckTaskDetector {
return "no-progress-churn";
}
// After onLoopDetected accepts compact-and-resume, the executor owns the
// recovery prompt. Do not immediately classify the same stale timestamps as
// another loop before the executor has a chance to emit fresh progress. The
// deterministic no-progress-churn terminal path above still has to fire if
// the recovered session keeps hammering rejected step updates.
if (entry.recoveryInProgress) {
return null;
}
// Check loop — active but not making progress, with enough activity to be a real loop
if (noProgressMs >= timeoutMs && entry.activitySinceProgress >= LOOP_ACTIVITY_THRESHOLD) {
return "loop";

View File

@@ -1,5 +1,5 @@
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core";
import { isExperimentalFeatureEnabled } from "@fusion/core";
import { getBuiltinWorkflow, isBuiltinWorkflowId, isExperimentalFeatureEnabled } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
import type {
@@ -146,7 +146,9 @@ export class WorkflowGraphTaskRunner {
let definition: WorkflowDefinition | undefined;
try {
definition = await this.deps.store.getWorkflowDefinition(selection.workflowId);
definition = isBuiltinWorkflowId(selection.workflowId)
? getBuiltinWorkflow(selection.workflowId)
: await this.deps.store.getWorkflowDefinition(selection.workflowId);
} catch (err) {
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
}