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");
|
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 () => {
|
it("prevents duplicate execution when task:moved fires twice for same task", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
let resolvePrompt: (() => void) | undefined;
|
let resolvePrompt: (() => void) | undefined;
|
||||||
|
|||||||
@@ -5128,9 +5128,22 @@ export class TaskExecutor {
|
|||||||
|
|
||||||
// If the persisted status doesn't match the requested status, the
|
// If the persisted status doesn't match the requested status, the
|
||||||
// store rejected the transition (currently: in-progress regression
|
// store rejected the transition (currently: in-progress regression
|
||||||
// on a done/skipped step). Tell the agent honestly so it doesn't
|
// on a done/skipped step). FN-5168 treats repeated rebuffs after loop
|
||||||
// assume the step reopened.
|
// recovery as a deterministic churn signal, but the agent-facing text
|
||||||
|
// stays unchanged so the tool contract is preserved.
|
||||||
if (persistedStatus !== status) {
|
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 {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text" as const,
|
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`);
|
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`);
|
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
|
// Mark recovery-pending so the execution flow can consume it
|
||||||
this.loopRecoveryState.set(taskId, { attempts: attempt, pending: true });
|
this.loopRecoveryState.set(taskId, { attempts: attempt, pending: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user