diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index cde08cffa5..8c718b289b 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -3676,12 +3676,274 @@ describe("specified triage recovery", () => { noCommitsExpected: true, }); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); + // FNXC:TriageStuckKill 2026-07-18-21:05: terminal status clear after release move (FN-1312). + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null }); expect(store.logEntry).toHaveBeenCalledWith( "FN-001", "Auto-recovered specified task stuck in planning — moved to todo", ); }); + /* + FNXC:TriageStuckKill 2026-07-18-22:30: + Null status alone is not proof of an approved plan (Greptile P1). Only recover null-status + triage cards when Plan Review already recorded a passed verdict. + */ + it("recovers a null-status triage task only when Plan Review already passed", async () => { + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + requirePlanApproval: false, + } as Settings), + }); + + const processor = new TriageProcessor(store, rootDir); + const recovered = await processor.recoverApprovedTask({ + id: "FN-001", + description: "Recovered triage task", + column: "triage", + status: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workflowStepResults: [ + { + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + phase: "pre-merge", + status: "passed", + verdict: "APPROVE", + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + } as any); + + expect(recovered).toBe(true); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); + }); + + it("does not recover a null-status triage draft that never passed Plan Review", async () => { + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + requirePlanApproval: false, + } as Settings), + }); + + const processor = new TriageProcessor(store, rootDir); + const recovered = await processor.recoverApprovedTask({ + id: "FN-001", + description: "Unapproved draft after early status clear", + column: "triage", + status: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }); + + expect(recovered).toBe(false); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("does not recover needs-replan cards (those require a real replan, not handoff recovery)", async () => { + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + requirePlanApproval: false, + } as Settings), + }); + + const processor = new TriageProcessor(store, rootDir); + const recovered = await processor.recoverApprovedTask({ + id: "FN-001", + description: "Rejected plan under revision", + column: "triage", + status: "needs-replan", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }); + + expect(recovered).toBe(false); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("defers stuck-abort requeue while Plan Review finalize is still in flight", async () => { + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + maxStuckKills: 6, + } as Settings), + getTask: vi.fn().mockResolvedValue({ + id: "FN-001", + description: "mid finalize", + column: "triage", + status: null, + stuckKillCount: 0, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }), + }); + + const processor = new TriageProcessor(store, rootDir); + (processor as any).finalizing.add("FN-001"); + + await (processor as any).handleStuckAbortRequeue( + { + id: "FN-001", + description: "mid finalize", + column: "triage", + status: null, + stuckKillCount: 0, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }, + "catch", + ); + + // Must not force needs-replan while the in-flight finalize owns the handoff. + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { stuckKillCount: 1 }); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "needs-replan" }), + ); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + /* + FNXC:TriageStuckKill 2026-07-18-22:30: + After a successful release, outer stuckAborted cleanup must not re-apply needs-replan + (Greptile P1 post-handoff race on PR #2326). + */ + it("does not write needs-replan when stuck-abort cleanup runs after handoff to todo", async () => { + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + maxStuckKills: 6, + } as Settings), + getTask: vi.fn().mockResolvedValue({ + id: "FN-001", + description: "already released", + column: "todo", + status: null, + stuckKillCount: 0, + dependencies: [], + steps: [{ name: "step-1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }), + }); + + const processor = new TriageProcessor(store, rootDir); + + await (processor as any).handleStuckAbortRequeue( + { + id: "FN-001", + description: "already released", + column: "todo", + status: null, + stuckKillCount: 0, + dependencies: [], + steps: [{ name: "step-1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }, + "catch", + ); + + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { stuckKillCount: 1 }); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "needs-replan" }), + ); + }); + + /* + FNXC:TriageStuckKill 2026-07-18-22:50: + Plan-in-place workflows plan inside todo with status:"planning". Stuck-abort must requeue + those cards, not treat every todo row as a completed handoff (CodeRabbit on PR #2326). + */ + it("requeues plan-in-place todo cards still in planning status after stuck-abort", async () => { + await writeFile( + join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), + "# Task: FN-001\n\n**Size:** M\n\n## Mission\n\nDraft still being planned in todo\n", + ); + + const planningTodo = { + id: "FN-001", + description: "plan-in-place mid plan", + column: "todo" as const, + status: "planning" as const, + stuckKillCount: 0, + dependencies: [] as string[], + steps: [] as Array<{ name: string; status: string }>, + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:02:00.000Z", + }; + + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + maxStuckKills: 6, + } as Settings), + getTask: vi.fn().mockResolvedValue(planningTodo), + }); + + const processor = new TriageProcessor(store, rootDir); + await (processor as any).handleStuckAbortRequeue(planningTodo, "catch"); + + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "needs-replan", stuckKillCount: 1 }), + ); + }); + it("stamps source metadata from sanitized effective write scope during recovery", async () => { await writeFile( join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"), @@ -7117,6 +7379,60 @@ describe("evictStaleProcessing", () => { expect(processor.getProcessingTaskIds().has("FN-001")).toBe(false); expect(processor.getProcessingTaskIds().has("FN-002")).toBe(true); }); + + /* + FNXC:TriageStuckKill 2026-07-18-21:05: + FN-1312: after stuck-kill of the main triage session, Plan Review still runs as a + subagent and finalize is mid-handoff. Eviction at the 30m threshold must not drop + those cards or self-healing/poll will start a concurrent planner. + */ + it("retains stuck-aborted tasks that still have a live Plan Review subagent", () => { + const store = createMockStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + (processor as any).processing.add("FN-1312"); + (processor as any).processingSince.set("FN-1312", Date.now()); + (processor as any).stuckAborted.add("FN-1312"); + (processor as any).activeSubagentSessions.set("FN-1312", new Set([{ dispose: vi.fn() }])); + + vi.setSystemTime(new Date("2026-01-01T00:31:00.000Z")); + + const evicted = processor.evictStaleProcessing(); + + expect(evicted).toEqual(new Set()); + expect(processor.getProcessingTaskIds().has("FN-1312")).toBe(true); + }); + + it("retains stuck-aborted tasks currently inside finalizeApprovedTask", () => { + const store = createMockStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + (processor as any).processing.add("FN-1312"); + (processor as any).processingSince.set("FN-1312", Date.now()); + (processor as any).stuckAborted.add("FN-1312"); + (processor as any).finalizing.add("FN-1312"); + + vi.setSystemTime(new Date("2026-01-01T00:31:00.000Z")); + + const evicted = processor.evictStaleProcessing(); + + expect(evicted).toEqual(new Set()); + expect(processor.getProcessingTaskIds().has("FN-1312")).toBe(true); + }); + + it("includes finalizing and subagent tasks in getProcessingTaskIds even when not in processing", () => { + const store = createMockStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + + (processor as any).finalizing.add("FN-finalizing"); + (processor as any).activeSubagentSessions.set("FN-subagent", new Set([{ dispose: vi.fn() }])); + + const ids = processor.getProcessingTaskIds(); + expect(ids.has("FN-finalizing")).toBe(true); + expect(ids.has("FN-subagent")).toBe(true); + }); }); // ── Agent Delegation Tool Tests ────────────────────────────────────── diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 599f35e7b0..cfb8996b48 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -120,7 +120,7 @@ import type { AgentSession, } from "@earendil-works/pi-coding-agent"; import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js"; -import { isTaskStillInPlanningStage } from "./replan-target.js"; +import { hasAdvancedPastPlanning, isTaskStillInPlanningStage } from "./replan-target.js"; import { createResolvedAgentSession, extractRuntimeHint, @@ -243,6 +243,18 @@ export class TriageProcessor { * verdicts. Mirrors `TaskExecutor.activeSubagentSessions`. */ private activeSubagentSessions = new Map>(); + /** + * FNXC:TriageStuckKill 2026-07-18-21:05: + * Tasks currently inside `finalizeApprovedTask` (PROMPT hygiene → Plan Review → + * column handoff). The main planning session is often already untracked/disposed by + * the time Plan Review runs, so without this set a stuck-kill + stale-processing + * eviction can drop the card from `processing` and let a concurrent second planner + * claim `status:"planning"` while the first finalize still moves triage→todo — the + * move does not clear planning statuses, so the scheduler then holds the card as + * unplanned until the second planner finishes (FN-1312: 6m+ idle after Plan Review + * APPROVE). Finalizing tasks stay in getProcessingTaskIds and are not rediscovered. + */ + private finalizing = new Set(); /** Tasks aborted due to globalPause (to avoid reporting as errors). */ private pauseAborted = new Set(); /** Tasks killed by the stuck task detector (to avoid reporting as errors). */ @@ -642,9 +654,28 @@ export class TriageProcessor { /** * Return a snapshot of tasks currently being specified by this processor. * Used by self-healing maintenance to avoid recovering live sessions. + * + * FNXC:TriageStuckKill 2026-07-18-21:05: + * Include Plan Review subagents and in-flight finalize handoffs so self-healing + * and poll rediscovery cannot start a second planner while the first session is + * still completing Plan Review → todo after a stuck-kill of the main session. */ getProcessingTaskIds(): Set { - return new Set(this.processing); + const ids = new Set(this.processing); + for (const taskId of this.finalizing) ids.add(taskId); + for (const taskId of this.activeSubagentSessions.keys()) { + const sessions = this.activeSubagentSessions.get(taskId); + if (sessions && sessions.size > 0) ids.add(taskId); + } + return ids; + } + + /** True when this processor still owns live work for `taskId` (main, subagent, or finalize). */ + private hasLivePlanningWork(taskId: string): boolean { + if (this.finalizing.has(taskId)) return true; + const subagents = this.activeSubagentSessions.get(taskId); + if (subagents && subagents.size > 0) return true; + return this.activeSessions.has(taskId) && !this.stuckAborted.has(taskId); } /** @@ -674,9 +705,15 @@ export class TriageProcessor { /* FNXC:Triage 2026-07-16-18:29: Stale-processing eviction must retain a task with a live, non-aborted triage session (`activeSessions.has(id) && !stuckAborted.has(id)`). Removing it would drop genuinely active planning from `getProcessingTaskIds()` and let self-healing prematurely finalize it to todo/awaiting-approval, clear planning status, or nudge priority. Hung promises without a session and stuck-aborted/disposed sessions remain evictable. + + FNXC:TriageStuckKill 2026-07-18-21:05: + Also retain Plan Review subagents and finalize handoffs after the main session is + stuck-killed. Stuck kill often fires near the 30m threshold (same clock as this + eviction), so without this guard the card is rediscovered while finalize is still + moving triage→todo and a concurrent planner leaves `status:"planning"` on a + todo card the scheduler refuses to release (FN-1312). */ - const hasLiveSession = this.activeSessions.has(taskId) && !this.stuckAborted.has(taskId); - if (hasLiveSession) continue; + if (this.hasLivePlanningWork(taskId)) continue; planLog.warn( `${taskId} has been in processing for ${Math.round((now - since) / 60_000)}min ` + @@ -686,18 +723,35 @@ export class TriageProcessor { this.processingSince.delete(taskId); this.activeSessions.delete(taskId); this.stuckAborted.delete(taskId); + this.finalizing.delete(taskId); evicted.add(taskId); } return evicted; } + /** True when Plan Review already recorded a passed verdict on this task. */ + private hasPassedPlanReview(task: Pick): boolean { + return task.workflowStepResults?.some( + (result) => result.workflowStepId === PLAN_REVIEW_GROUP_ID && result.status === "passed", + ) === true; + } + /** * Recover a triage task whose PROMPT.md was already written but the final - * handoff out of `status: "planning"` never completed. + * handoff out of planning never completed. + * + * FNXC:TriageStuckKill 2026-07-18-21:05: + * Classic path: status is `planning`. Extended path: status is null after finalize's + * early clear ONLY when Plan Review already passed — null alone must not promote an + * unapproved draft (a lightly-edited seed can fail the exact seed equality check). + * Do not recover `needs-replan` / `plan-review-unavailable`. */ async recoverApprovedTask(task: Task): Promise { - if (task.column !== "triage" || task.status !== "planning") { + const recoverableStatus = + task.status === "planning" + || (task.status == null && this.hasPassedPlanReview(task)); + if (task.column !== "triage" || !recoverableStatus) { return false; } @@ -724,6 +778,12 @@ export class TriageProcessor { return false; } + // Bootstrap / refinement seeds are not approved specs — leave them for normal planning. + if (isUnplannedSeedPrompt(written, task.id, task.title, task.description)) { + planLog.warn(`${task.id} planning recovery skipped — PROMPT.md is still an unplanned seed`); + return false; + } + const deterministicSpecFailure = await this.validateGeneratedPrompt(task.id, written); if (deterministicSpecFailure) { planLog.warn(`${task.id} planning recovery skipped — PROMPT.md failed deterministic validation (${deterministicSpecFailure})`); @@ -784,6 +844,15 @@ export class TriageProcessor { /* FNXC:Triage 2026-06-27-00:00: A stuck-killed planning session that already wrote a usable PROMPT.md or plan task document must resume in revision mode on the next poll, not re-triage from scratch. Reuse stuckKillCount and maxStuckKills for the triage retry budget so repeated stuck resumes escalate to manual intervention instead of looping forever. + + FNXC:TriageStuckKill 2026-07-18-21:05: + Do not invalidate an already-approved plan. Finalize clears `status` to null before Plan + Review, so a stuck-kill mid-review used to skip recoverApprovedTask (which required + status:"planning") and force needs-replan — even when Plan Review had just APPROVEd and + the card was about to move to todo. That left the scheduler holding an "unplanned" todo + card until a second planner rewrote PROMPT.md (FN-1312). If Plan Review already passed + or a valid draft exists after the early status clear, complete the handoff instead of + replan-invalidating. */ const freshTask = await this.store.getTask(task.id).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); @@ -791,6 +860,56 @@ export class TriageProcessor { return task; }); + /* + FNXC:TriageStuckKill 2026-07-18-21:05: + If finalize is still running Plan Review after the main session was killed, leave the + card alone — the in-flight finalize owns the handoff. Setting needs-replan here races + the APPROVE path and strands the card unplanned in todo. + */ + if (this.finalizing.has(task.id) || (this.activeSubagentSessions.get(task.id)?.size ?? 0) > 0) { + planLog.log( + `${task.id} killed by stuck detector during Plan Review/finalize — deferring requeue to the in-flight handoff (${context})`, + ); + await this.store.updateTask(task.id, { + stuckKillCount: (freshTask.stuckKillCount ?? task.stuckKillCount ?? 0) + 1, + }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + planLog.warn(`${task.id}: failed to increment stuckKillCount during deferred stuck-detector ${context} cleanup: ${msg}`); + }); + return; + } + + /* + FNXC:TriageStuckKill 2026-07-18-22:30: + Finalize can succeed (move to todo + clear status) and clear `finalizing` before the + outer stuckAborted catch runs — e.g. dispose of an already-killed main session throws + after handoff. Recovery then fails (column is no longer triage) and the draft path + would write needs-replan, re-stranding an approved plan (Greptile P1 on PR #2326). + + FNXC:TriageStuckKill 2026-07-18-22:50: + Do NOT treat every `todo` card as released. Plan-in-place workflows plan inside `todo` + with status:"planning"/"needs-replan"; those must still requeue (CodeRabbit on PR #2326). + hasAdvancedPastPlanning covers execution columns and released todo (steps/worktree). + Released handoffs with status cleared but no steps yet are also preserved: todo without + a planning-stage status means the scheduler can claim the card. + */ + const planningStageStatus = + freshTask.status === "planning" + || freshTask.status === "needs-replan" + || freshTask.status === "plan-review-unavailable"; + const releasedToTodo = freshTask.column === "todo" && !planningStageStatus; + if (hasAdvancedPastPlanning(freshTask) || releasedToTodo) { + const nextStuckKillCount = (freshTask.stuckKillCount ?? task.stuckKillCount ?? 0) + 1; + planLog.log( + `${task.id} killed by stuck detector after planning handoff completed (column=${freshTask.column}, status=${freshTask.status ?? "null"}) — preserving released state (${context})`, + ); + await this.store.updateTask(task.id, { stuckKillCount: nextStuckKillCount }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + planLog.warn(`${task.id}: failed to increment stuckKillCount after post-handoff stuck-detector ${context} cleanup: ${msg}`); + }); + return; + } + const recovered = await this.recoverApprovedTask(freshTask).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: planning recovery failed during stuck-detector ${context} cleanup: ${msg}`); @@ -937,7 +1056,7 @@ export class TriageProcessor { } const eligibleTriageTasks = allTasks.filter( - (t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused + (t) => t.column === "triage" && !this.processing.has(t.id) && !this.hasLivePlanningWork(t.id) && !t.paused // Skip tasks awaiting manual plan approval — they should not be auto-discovered && t.status !== "awaiting-approval" // Skip failed specifications until the user explicitly retries them. @@ -955,7 +1074,7 @@ export class TriageProcessor { 2. Refinement seeds (`# {title}\n\n{description}`, no id prefix) previously failed the strict bootstrap-stub equality, so a promoted refinement skipped planning entirely; isUnplannedSeedPrompt accepts both seed shapes. */ const eligibleTodoTasksRaw = allTasks.filter( - (t) => t.column === "todo" && !this.processing.has(t.id) && !t.paused + (t) => t.column === "todo" && !this.processing.has(t.id) && !this.hasLivePlanningWork(t.id) && !t.paused && t.status !== "awaiting-approval" && t.status !== "failed" && t.status !== "stuck-killed" @@ -1086,7 +1205,13 @@ export class TriageProcessor { * quality gate before execution; triage does not inject a separate review tool. */ async specifyTask(task: Task): Promise { - if (this.processing.has(task.id)) return; + /* + FNXC:TriageStuckKill 2026-07-18-21:05: + Refuse a second planner when finalize/Plan Review is still live even if + `processing` was cleared by a stuck-kill eviction race. Concurrent claim is + what leaves `status:"planning"` on a todo card after Plan Review APPROVE. + */ + if (this.processing.has(task.id) || this.hasLivePlanningWork(task.id)) return; this.processing.add(task.id); this.processingSince.set(task.id, Date.now()); @@ -2771,6 +2896,30 @@ export class TriageProcessor { recoveryLogAction?: string; preservePromptContent?: boolean; } = {}, + ): Promise { + /* + FNXC:TriageStuckKill 2026-07-18-21:05: + Mark the card finalizing for the whole Plan Review → column handoff so stuck-kill + eviction and poll rediscovery cannot start a concurrent planner (FN-1312). + */ + this.finalizing.add(task.id); + try { + await this.finalizeApprovedTaskBody(task, writtenInput, settings, options); + } finally { + this.finalizing.delete(task.id); + } + } + + private async finalizeApprovedTaskBody( + task: Task, + writtenInput: string, + settings: Settings, + options: { + isReplan?: boolean; + feedback?: string; + recoveryLogAction?: string; + preservePromptContent?: boolean; + } = {}, ): Promise { let written = writtenInput; const explicitDuplicateMarker = parseExplicitDuplicateMarker(written); @@ -3207,6 +3356,45 @@ export class TriageProcessor { await this.store.moveTask(task.id, "todo"); } + /* + FNXC:TriageStuckKill 2026-07-18-21:05: + Re-assert status:null after the release move. finalize clears status early (before Plan + Review); triage→todo does not clear planning statuses; a concurrent stuck-kill requeue + or rediscovered planner can stamp status:"planning" between those points. Without this + terminal clear the scheduler holds the card as unplanned after Plan Review APPROVE + (FN-1312). + + FNXC:TriageStuckKill 2026-07-18-22:30: + Only clear planning-stage statuses under the task lock. Do not wipe a concurrent + operator/engine write of failed, awaiting-approval, or a genuine later needs-replan + that is not the mid-handoff planner race (Greptile P2 on PR #2326). Concurrent + `status:"planning"` from a rediscovered second planner is still cleared. + */ + if (typeof this.store.updateTaskAtomic === "function") { + await this.store.updateTaskAtomic(task.id, (live) => { + if ( + live.status === "planning" + || live.status === "plan-review-unavailable" + || live.status == null + ) { + return { status: null, error: null }; + } + // Leave needs-replan/failed/awaiting-approval and other durable statuses alone. + return null; + }); + } else { + const live = await Promise.resolve(this.store.getTask(task.id)).catch(() => null); + if ( + live + && (live.status === "planning" || live.status === "plan-review-unavailable" || live.status == null) + ) { + await this.store.updateTask(task.id, { status: null, error: null }); + } else if (!live) { + // Minimal test stores often omit getTask; still clear the mid-handoff planning stamp. + await this.store.updateTask(task.id, { status: null, error: null }); + } + } + if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { await this.store.updateTask(task.id, { title: promptDeclaredTitle }); }