fix(triage): clear specifying status on pause/stuck and add StuckTaskDetector support

- Fix pause-abort handler using updateTask({status: undefined}) which was a
  no-op, leaving tasks stuck in 'specifying' forever after a pause interrupts
  a session post-APPROVE. Changed to status: null to actually clear the field.
- Apply same fix to transient-error retry and general error catch paths.
- Wire StuckTaskDetector into TriageProcessor: trackTask/untrackTask/recordActivity
  on session lifecycle, markStuckAborted to prevent stuck kills from being
  reported as errors, and clear status to null on stuck-kill for next-poll retry.
- Update dashboard.ts to pass stuckTaskDetector to TriageProcessor and call
  triageRef.current?.markStuckAborted in the shared onStuck callback.
- Add 3 tests covering pause-abort status clearing and stuck detector wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-07 21:51:09 -07:00
parent a11167081f
commit ec20ab1038
3 changed files with 193 additions and 20 deletions

View File

@@ -578,15 +578,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Start the AI engine (unless in dev mode)
if (!opts.dev) {
const triage = new TriageProcessor(store, cwd, {
semaphore,
usageLimitPauser,
agentStore,
onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`),
onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`),
onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
});
// ── Self-healing: auto-unpause, stuck kill budgets, maintenance ─────
const selfHealing = new SelfHealingManager(store, {
rootDir: cwd,
@@ -595,14 +586,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
// ── Stuck task detector: monitors agent sessions for stagnation ────
// Created before the executor so it can be passed in options.
// The onStuck callback is wired to executor.markStuckAborted after
// executor creation (late-binding via closure on executorRef).
// Created before triage/executor so it can be passed in options.
// The onStuck callback is wired via late-binding closures on triageRef
// and executorRef to avoid circular construction order dependencies.
const executorRef: { current: TaskExecutor | null } = { current: null };
const triageRef: { current: TriageProcessor | null } = { current: null };
const stuckTaskDetector = new StuckTaskDetector(store, {
beforeRequeue: (taskId) => selfHealing.checkStuckBudget(taskId),
onLoopDetected: (event) => executorRef.current?.handleLoopDetected(event) ?? Promise.resolve(false),
onStuck: (event) => {
// Notify whichever component owns this task (triage or executor).
// Both check their own tracking sets so only the owner acts.
triageRef.current?.markStuckAborted(event.taskId);
executorRef.current?.markStuckAborted(event.taskId, event.shouldRequeue);
console.log(
`[engine] ⚠ ${event.taskId} stuck (${event.reason}) — ` +
@@ -613,6 +608,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
},
});
const triage = new TriageProcessor(store, cwd, {
semaphore,
usageLimitPauser,
stuckTaskDetector,
agentStore,
onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`),
onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`),
onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
});
triageRef.current = triage;
const executor = new TaskExecutor(store, cwd, {
semaphore,
pool,