feat(FN-882): add loop detection recovery with compact-and-resume

- Add ContextLimitDetector to detect agent loops via repeated tool call patterns
- Implement compact-and-resume strategy: summarize conversation and restart agent from current step
- Add loop recovery to StuckTaskDetector with configurable attempt tracking and retry limits
- Extend executor with automatic loop recovery on context limit detection
- Add loop recovery support to pi executor with same compact-and-resume pattern
- Add comprehensive tests for context-limit-detector, stuck-task-detector loop detection, executor, and pi recovery
- Add changeset for patch bump to @gsxdsm/fusion
- Update README with loop detection and recovery documentation
This commit is contained in:
gsxdsm
2026-04-04 19:47:48 -07:00
parent c0b1c2ae30
commit 4c2be10d73
12 changed files with 846 additions and 6 deletions

View File

@@ -66,6 +66,18 @@ export interface StuckTaskDetectorOptions {
* (caller is responsible for marking the task as terminally failed).
* Used by SelfHealingManager to enforce stuck kill budgets. */
beforeRequeue?: (taskId: string) => Promise<boolean>;
/** Pre-kill callback invoked ONLY when reason is "loop".
* Called BEFORE session.dispose() / moveTask("todo") so the caller can
* attempt in-process recovery (e.g. compact-and-resume) without killing
* the agent session.
*
* Return `true` to signal "executor accepted ownership of recovery for this
* run" — the detector will skip dispose/requeue and remove the task from
* tracking (the caller is now responsible for the task's fate).
* Return `false` to let the detector proceed with the normal kill/requeue path.
*
* Errors in this callback fall through to the normal kill path (treated as `false`). */
onLoopDetected?: (event: StuckTaskEvent) => Promise<boolean>;
}
export class StuckTaskDetector {
@@ -74,6 +86,7 @@ export class StuckTaskDetector {
private pollIntervalMs: number;
private onStuck?: (event: StuckTaskEvent) => void;
private beforeRequeue?: (taskId: string) => Promise<boolean>;
private onLoopDetected?: (event: StuckTaskEvent) => Promise<boolean>;
constructor(
private store: TaskStore,
@@ -82,6 +95,7 @@ export class StuckTaskDetector {
this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
this.onStuck = options.onStuck;
this.beforeRequeue = options.beforeRequeue;
this.onLoopDetected = options.onLoopDetected;
}
/**
@@ -284,6 +298,32 @@ export class StuckTaskDetector {
shouldRequeue,
};
// ── Pre-kill loop interception ──────────────────────────────────
// When reason is "loop" and an onLoopDetected callback is registered,
// give the caller a chance to handle recovery in-process (e.g.
// compact-and-resume) before falling through to the kill/requeue path.
//
// If the callback returns true, the caller owns the task — we skip
// dispose/requeue and just untrack. Errors fall through to normal kill.
if (reason === "loop" && this.onLoopDetected) {
try {
const handled = await this.onLoopDetected(event);
if (handled) {
stuckLog.log(
`${taskId} loop recovery accepted by onLoopDetected callback — ` +
`skipping kill/requeue (caller owns recovery)`,
);
// The caller is now responsible for the task; remove from tracking
// so we don't double-trigger.
this.tracked.delete(taskId);
return;
}
} catch (err) {
stuckLog.error(`onLoopDetected callback failed for ${taskId}:`, err);
// Fall through to normal kill path
}
}
// Notify listeners before disposing the session so executor cleanup can
// mark the abort as intentional before the disposed session unwinds.
this.onStuck?.(event);