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:
committed by
gsxdsm
parent
913167c736
commit
af16608810
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user