feat(FN-5168): complete Step 2 — executor feeds ignored step-update churn

Fusion-Task-Id: FN-5168
Fusion-Task-Lineage: 0c865d3e-0886-4e51-a7ff-cb4c713dcc54
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 13:37:39 -07:00
committed by gsxdsm
parent 913167c736
commit af16608810
2 changed files with 94 additions and 2 deletions

View File

@@ -1372,6 +1372,81 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
expect(onAgentTool).toHaveBeenCalledWith("FN-978", "bash");
});
it("feeds ignored fn_task_update rebuffs into the stuck detector", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
steps: [{ name: "Implement", status: "done" }],
});
store.updateStep.mockResolvedValue({
stuckKillCount: 2,
steps: [{ name: "Implement", status: "done" }],
});
const stuckDetector = {
recordProgress: vi.fn(),
recordIgnoredStepUpdate: vi.fn(),
getIgnoredStepUpdateCount: vi.fn().mockReturnValue(25),
};
const executor = new TaskExecutor(store, "/tmp/test", {
stuckTaskDetector: stuckDetector as any,
});
(executor as any).loopRecoveryState.set("FN-001", { attempts: 1, pending: false });
const tool = (executor as any).createTaskUpdateTool(
"FN-001",
new Map(),
{ current: null },
new Map(),
stuckDetector,
);
const result = await tool.execute("call-1", { step: 1, status: "in-progress" });
expect(stuckDetector.recordIgnoredStepUpdate).toHaveBeenCalledWith("FN-001");
expect(result.content[0].text).toContain("already done");
expect(executorLog.warn).toHaveBeenCalledWith(
expect.stringContaining(
"FN-001: no-progress churn detected (ignoredStepUpdates=25, stuckKillStreak=2) — escalating to STUCK_NO_PROGRESS_CHURN",
),
);
});
it("marks loop recovery as observed after successful compaction", async () => {
const store = createMockStore();
const stuckDetector = {
markLoopObserved: vi.fn(),
};
const executor = new TaskExecutor(store, "/tmp/test", {
stuckTaskDetector: stuckDetector as any,
});
const session = {
compact: vi.fn(async () => ({ summary: "Compacted conversation", tokensBefore: 150000 })),
steer: vi.fn(async () => {}),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
};
(executor as any).activeSessions.set("FN-001", {
session,
seenSteeringIds: new Set(),
});
const result = await executor.handleLoopDetected({
taskId: "FN-001",
reason: "loop",
noProgressMs: 600000,
inactivityMs: 0,
activitySinceProgress: 100,
ignoredStepUpdateCount: 0,
shouldRequeue: true,
});
expect(result).toBe(true);
expect(stuckDetector.markLoopObserved).toHaveBeenCalledWith("FN-001");
});
it("prevents duplicate execution when task:moved fires twice for same task", async () => {
const store = createMockStore();
let resolvePrompt: (() => void) | undefined;

View File

@@ -5128,9 +5128,22 @@ export class TaskExecutor {
// If the persisted status doesn't match the requested status, the
// store rejected the transition (currently: in-progress regression
// on a done/skipped step). Tell the agent honestly so it doesn't
// assume the step reopened.
// on a done/skipped step). FN-5168 treats repeated rebuffs after loop
// recovery as a deterministic churn signal, but the agent-facing text
// stays unchanged so the tool contract is preserved.
if (persistedStatus !== status) {
stuckDetector?.recordIgnoredStepUpdate(taskId);
const ignoredStepUpdates = stuckDetector?.getIgnoredStepUpdateCount(taskId) ?? 0;
const loopAttempts = this.loopRecoveryState.get(taskId)?.attempts ?? 0;
if (loopAttempts >= 1 && ignoredStepUpdates === 25) {
executorLog.warn(
`${taskId}: no-progress churn detected ` +
`(ignoredStepUpdates=${ignoredStepUpdates}, stuckKillStreak=${task.stuckKillCount ?? 0}) — ` +
`escalating to STUCK_NO_PROGRESS_CHURN`,
);
}
return {
content: [{
type: "text" as const,
@@ -9725,6 +9738,10 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
executorLog.log(`${taskId} compaction succeeded (freed ${compactResult.tokensBefore} tokens) — setting recovery-pending`);
await this.store.logEntry(taskId, `Context compacted successfully — will resume with fresh context`);
// FN-5168: once loop recovery has fired in this execute() lifecycle,
// ignored fn_task_update rebuffs can be promoted to no-progress churn.
this.options.stuckTaskDetector?.markLoopObserved(taskId);
// Mark recovery-pending so the execution flow can consume it
this.loopRecoveryState.set(taskId, { attempts: attempt, pending: true });