From d10d91bae5e2490a705e098313a2537ac6a67d84 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 15:20:17 -0700 Subject: [PATCH] fix(engine): stop planning when a card is withdrawn; sweep stale pre-execution worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdrawing a card from planning (todo -> Ideas) now stops the work: - triage aborts and disposes the planning session through the same path pause/delete already use, and clears status:"planning" so the planning badge goes away and the card reads as a plain idea again; - the executor aborts in-flight graph work on any backward move out of todo/triage, so a Plan Review does not keep streaming against a card the operator pulled back; - moving it back to todo needs no new code: the existing column wake fires and, with the status cleared, the card is an ordinary planning candidate again. Pre-execution worktrees (planning acquires one now) are reclaimed two ways: an immediate release on an explicit withdrawal, and a self-healing sweep `reconcile-pre-execution-worktrees`. The sweep is deliberately timid — 30 days of complete inactivity, and it skips anything active or waiting (todo, executing, in-review, done, paused, carrying any status, blocked, or scheduled for recovery). Every real safety condition lives in the executor: never executed, no live session, clean branch, nothing uncommitted. hasAdvancedPastPlanning no longer reads a worktree as execution evidence. Planning owns a worktree now, so that signal would have made every planning write skip; execution timestamps carry the meaning instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../planning-evacuation-and-worktree-sweep.md | 7 + .../src/__tests__/planning-evacuation.test.ts | 162 ++++++++++++++++++ .../engine/src/__tests__/self-healing.test.ts | 9 +- packages/engine/src/__tests__/triage.test.ts | 13 +- packages/engine/src/executor.ts | 76 ++++++++ packages/engine/src/replan-target.ts | 15 +- .../engine/src/runtimes/in-process-runtime.ts | 3 + packages/engine/src/self-healing.ts | 86 ++++++++++ packages/engine/src/triage.ts | 71 ++++++++ 9 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 .changeset/planning-evacuation-and-worktree-sweep.md create mode 100644 packages/engine/src/__tests__/planning-evacuation.test.ts diff --git a/.changeset/planning-evacuation-and-worktree-sweep.md b/.changeset/planning-evacuation-and-worktree-sweep.md new file mode 100644 index 0000000000..42245a1087 --- /dev/null +++ b/.changeset/planning-evacuation-and-worktree-sweep.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Moving a card out of Todo while it plans now stops planning, clears the badge, and frees its worktree. +category: fix +dev: TriageProcessor gains `taskEvacuatedFromPlanningHandler` (reuses the pause/delete abort path, clears `status: "planning"`); the executor aborts in-flight work on a backward move out of todo/triage and calls `releasePreExecutionWorktree`, which requires no execution timestamp, no live session, and a clean branch. A new self-healing sweep `reconcile-pre-execution-worktrees` reclaims parked worktrees only after 30 days of complete inactivity, skipping todo/executing/paused/status-carrying/blocked/recovery-scheduled rows. `hasAdvancedPastPlanning` no longer reads `worktree` as execution evidence — planning owns one now — and uses `firstExecutionAt`/`executionStartedAt` instead. diff --git a/packages/engine/src/__tests__/planning-evacuation.test.ts b/packages/engine/src/__tests__/planning-evacuation.test.ts new file mode 100644 index 0000000000..714d5e2307 --- /dev/null +++ b/packages/engine/src/__tests__/planning-evacuation.test.ts @@ -0,0 +1,162 @@ +/* +FNXC:PlanningEvacuation 2026-07-25-23:00 (withdrawing a card from planning — regression): +Operator requirement: moving a card from todo to Ideas WHILE it is being planned must stop the +planning session and all engine work on it, and the "planning" badge must disappear. Moving it back to +todo must restart planning. Planning-acquired worktrees must not accumulate on withdrawn cards, and the +background sweep that reclaims them must only touch very old, genuinely idle trees. + +Invariant under test: + 1. evacuation to a non-planner column aborts + disposes the triage session and clears the badge; + 2. it does NOT fire for moves within the planner lanes, or for the forward move into execution; + 3. moving back to a planner lane wakes the poll, so planning restarts; + 4. `hasAdvancedPastPlanning` no longer reads a worktree as execution evidence — planning owns one now; + 5. the sweep skips young, active, waiting, and executed tasks, and reclaims only 30-day-idle parked ones. +*/ +import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { Task, TaskStore } from "@fusion/core"; +import { TriageProcessor } from "../triage.js"; +import { hasAdvancedPastPlanning } from "../replan-target.js"; +import { SelfHealingManager } from "../self-healing.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeStore(overrides: Partial> = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + logEntry: vi.fn().mockResolvedValue(undefined), + updateTask: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({}), + listTasks: vi.fn().mockResolvedValue([]), + getTask: vi.fn(), + ...overrides, + }) as unknown as TaskStore & EventEmitter; +} + +function planningTask(overrides: Partial = {}): Task { + return { id: "FN-1403", column: "ideas", status: "planning", ...overrides } as Task; +} + +function attachLiveSession(processor: TriageProcessor, taskId: string) { + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() }; + (processor as any).activeSessions.set(taskId, session); + // The token-usage snapshot is unrelated bookkeeping here; stub it so the abort path is isolated. + vi.spyOn(processor as any, "recordTriageSessionTokenUsageSoon").mockImplementation(() => undefined); + return session; +} + +describe("withdrawing a card from planning", () => { + it("aborts the planning session and clears the planning badge when moved to ideas", () => { + const store = makeStore(); + const processor = new TriageProcessor(store, "/tmp/test"); + const session = attachLiveSession(processor, "FN-1403"); + + (processor as any).taskEvacuatedFromPlanningHandler(planningTask()); + + expect(session.abort).toHaveBeenCalled(); + expect(session.dispose).toHaveBeenCalled(); + expect((processor as any).activeSessions.has("FN-1403")).toBe(false); + // The badge is `status: "planning"` — clearing it is what makes the card read as a plain idea. + expect(store.updateTask).toHaveBeenCalledWith("FN-1403", { status: null }); + }); + + it.each([ + ["todo", "todo"], + ["triage", "triage"], + ["in-progress (forward into execution)", "in-progress"], + ])("does not abort planning for a move to %s", (_label, column) => { + const store = makeStore(); + const processor = new TriageProcessor(store, "/tmp/test"); + const session = attachLiveSession(processor, "FN-1403"); + + (processor as any).taskEvacuatedFromPlanningHandler(planningTask({ column })); + + expect(session.abort).not.toHaveBeenCalled(); + expect((processor as any).activeSessions.has("FN-1403")).toBe(true); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("wakes the poll when the card comes back to todo, so planning restarts", () => { + const store = makeStore(); + const processor = new TriageProcessor(store, "/tmp/test"); + const wake = vi.spyOn(processor as any, "requestImmediatePoll").mockImplementation(() => undefined); + + (processor as any).taskColumnWakeHandler({ id: "FN-1403", column: "todo" } as Task); + + expect(wake).toHaveBeenCalled(); + }); +}); + +describe("a planning worktree is not execution evidence", () => { + it("keeps a worktree-holding planner card in the planning stage", () => { + // Pre-change this returned true and every planning write (status, spec finalization) was skipped. + expect(hasAdvancedPastPlanning({ column: "todo", worktree: "/wt/fn-1403", steps: [], status: "planning" } as any)).toBe(false); + }); + + it("still treats a card that actually executed as advanced", () => { + expect( + hasAdvancedPastPlanning({ + column: "todo", + worktree: "/wt/fn-1403", + steps: [], + status: null, + firstExecutionAt: new Date().toISOString(), + } as any), + ).toBe(true); + }); +}); + +describe("pre-execution worktree sweep", () => { + const old = new Date(Date.now() - 40 * DAY_MS).toISOString(); + const recent = new Date(Date.now() - 2 * DAY_MS).toISOString(); + + function sweepWith(tasks: Array>) { + const store = makeStore({ listTasks: vi.fn().mockResolvedValue(tasks) }); + const release = vi.fn().mockResolvedValue(true); + const manager = new SelfHealingManager(store, { + rootDir: "/tmp/test", + releasePreExecutionWorktree: release, + } as any); + return { manager, release }; + } + + it("reclaims a parked card whose worktree has been idle past 30 days", async () => { + const { manager, release } = sweepWith([ + { id: "FN-OLD", column: "ideas", worktree: "/wt/old", columnMovedAt: old, updatedAt: old } as Partial, + ]); + + await expect(manager.reconcilePreExecutionWorktrees()).resolves.toBe(1); + expect(release).toHaveBeenCalledWith("FN-OLD", expect.stringContaining("parked pre-execution")); + }); + + it.each([ + ["recently touched", { column: "ideas", worktree: "/wt/x", columnMovedAt: recent, updatedAt: recent }], + ["queued in todo", { column: "todo", worktree: "/wt/x", columnMovedAt: old, updatedAt: old }], + ["executing", { column: "in-progress", worktree: "/wt/x", columnMovedAt: old, updatedAt: old }], + ["already executed once", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, firstExecutionAt: old }], + ["paused awaiting an operator", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, paused: true }], + ["carrying a status", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, status: "needs-replan" }], + ["blocked on another task", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, blockedBy: "FN-9" }], + ["scheduled for recovery", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, nextRecoveryAt: old }], + ["soft-deleted", { column: "ideas", worktree: "/wt/x", columnMovedAt: old, updatedAt: old, deletedAt: old }], + ["holding no worktree", { column: "ideas", columnMovedAt: old, updatedAt: old }], + ["of unprovable age", { column: "ideas", worktree: "/wt/x" }], + ])("leaves a task that is %s alone", async (_label, task) => { + const { manager, release } = sweepWith([{ id: "FN-SKIP", ...task } as Partial]); + + await expect(manager.reconcilePreExecutionWorktrees()).resolves.toBe(0); + expect(release).not.toHaveBeenCalled(); + }); + + it("does nothing while the engine or the board is paused", async () => { + const store = makeStore({ + listTasks: vi.fn().mockResolvedValue([{ id: "FN-OLD", column: "ideas", worktree: "/wt/old", columnMovedAt: old, updatedAt: old }]), + getSettings: vi.fn().mockResolvedValue({ enginePaused: true }), + }); + const release = vi.fn().mockResolvedValue(true); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test", releasePreExecutionWorktree: release } as any); + + await expect(manager.reconcilePreExecutionWorktrees()).resolves.toBe(0); + expect(release).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 88b2a38ddd..0c54898757 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -8338,10 +8338,17 @@ describe("SelfHealingManager", () => { FN-8361 regression: a stale candidate may be claimed by execution after listTasks but before the recovery patch acquires the task lock. */ + /* + FNXC:NodeWorktreeIsolation 2026-07-25-22:40: + The third case used a WORKTREE as the claim signal. Planning now acquires the task's own worktree + (so no lane runs in the shared checkout), which makes a worktree on a `status: "planning"` triage + row the normal state of a card being planned — not evidence that execution claimed it. Execution + TIMESTAMPS carry that meaning instead; the FN-8361 invariant is otherwise unchanged. + */ it.each([ { column: "in-progress", status: null, worktree: "/tmp/claimed" }, { column: "todo", status: null, worktree: undefined, steps: [{ id: "planned" }] }, - { column: "triage", status: "planning", worktree: "/tmp/claimed" }, + { column: "triage", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" }, ])("does not clear a stale candidate advanced to $column", async (live) => { const candidate = { id: "FN-8361", column: "triage", status: "planning", paused: false, diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index cbf0e4e7e5..fff48904f9 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1800,9 +1800,20 @@ Planner rewrote mission without the raw request. expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" })); }); + /* + FNXC:NodeWorktreeIsolation 2026-07-25-22:40: + Advancement evidence is an EXECUTION TIMESTAMP, not a worktree. Planning acquires the task's own + worktree now (so no lane runs in the shared checkout), which makes `worktree` the normal state of a + card being planned; reading it as advancement would skip every planning write. The invariant this + test guards — a genuinely advanced row is not re-dispatched — is unchanged. + */ it("does not repeatedly dispatch a triage row that already has executor advancement evidence", async () => { const tasks: Task[] = [ - createTriageTask({ id: "FN-ADVANCED", worktree: "/tmp/fusion-fn-advanced" }), + createTriageTask({ + id: "FN-ADVANCED", + worktree: "/tmp/fusion-fn-advanced", + firstExecutionAt: new Date().toISOString(), + }), createTriageTask({ id: "FN-UNPLANNED" }), ]; const triageStore = createMockStore({ diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 05a5bc5b45..5c039150dc 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -3265,6 +3265,22 @@ export class TaskExecutor { } }), ); + } else if ((from === "todo" || from === "triage") && to !== "in-progress" && to !== "in-review" && to !== "done") { + /* + FNXC:PlanningEvacuation 2026-07-25-23:00: + A card pulled BACKWARD out of a planner lane (the reported case: todo → Ideas) must stop all + engine work on it, not just its planning session. Plan Review and other pre-execution graph + nodes run while the card sits in todo/triage, so without this branch the reviewer kept + streaming against a card the operator had withdrawn. Forward transitions are excluded — those + are the card advancing, and their own lanes own the handoff. Also release the pre-execution + worktree acquired at planning time so a withdrawn card leaves nothing behind on disk. + */ + this.trackTaskDisposal( + task.id, + this.awaitAbortInFlightTaskWork(task.id, `task moved out of planning to ${to}`, { + userCanceled: source === "user", + }).then(async () => { await this.releasePreExecutionWorktree(task.id, `moved to ${to}`); }), + ); } else if (from === "in-progress") { if (this.workflowLifecycleMovesInFlight.has(task.id) && this.graphRouting.has(task.id)) { executorLog.log( @@ -8447,6 +8463,66 @@ export class TaskExecutor { Returns null (caller falls back to the root, unchanged behavior) when the project is a workspace, or when acquisition fails: planning must never be blocked by a worktree problem. */ + /* + FNXC:PlanningEvacuation 2026-07-25-23:00 (pre-execution worktree release): + Planning now acquires a worktree, so a card that never reaches execution — withdrawn to Ideas, + archived from a planner lane, or parked pre-execution — would otherwise hold one forever. Release + it. Safety conditions, all required: + - the task never executed (`firstExecutionAt`/`executionStartedAt` unset): execution evidence means + the worktree may hold real work, and only the normal merge/archive lifecycle may remove it; + - no live session registered on the path (the same isPathActive guard the other sweeps use); + - the branch carries no commits beyond its base — planning writes its spec to the task store, not + the worktree, so a clean branch means there is genuinely nothing to lose. + Metadata (`worktree`/`branch`) is cleared with it, so a later promotion re-acquires cleanly. + Fail-soft throughout: a cleanup problem must never block the lifecycle move that triggered it. + */ + public async releasePreExecutionWorktree(taskId: string, reason: string): Promise { + try { + const live = await this.store.getTask(taskId); + if (!live?.worktree) return false; + if (live.firstExecutionAt || live.executionStartedAt) return false; + if (activeSessionRegistry.isPathActive(live.worktree) || activeSessionRegistry.isPathActive(resolvePath(live.worktree))) return false; + if (this.hasLiveTaskSessionSurface(taskId) || executingTaskLock.has(taskId)) return false; + + if (existsSync(live.worktree)) { + if (await this.preExecutionWorktreeHasWork(live.worktree)) { + executorLog.log(`${taskId}: keeping pre-execution worktree ${live.worktree} — it carries commits or uncommitted changes`); + return false; + } + const settings = await this.store.getSettings(); + await removeWorktree({ + rootDir: this.rootDir, + worktreePath: live.worktree, + settings, + taskId, + reason: RemovalReason.SelfHealingReclaim, + }); + } + this.activeWorktrees.get(taskId)?.delete(live.worktree); + await this.store.updateTask(taskId, { worktree: null, branch: null, baseCommitSha: null, sessionFile: null }, this.getRunContextFor(taskId)); + await this.store.logEntry(taskId, `Released the pre-execution worktree (${reason}) — it will be re-acquired when planning or execution resumes`, undefined, this.getRunContextFor(taskId)).catch(() => undefined); + executorLog.log(`${taskId}: released pre-execution worktree ${live.worktree} (${reason})`); + return true; + } catch (error) { + executorLog.warn(`${taskId}: could not release the pre-execution worktree: ${formatError(error).message}`); + return false; + } + } + + /** True when a pre-execution worktree holds commits past its base or any uncommitted change. */ + private async preExecutionWorktreeHasWork(worktreePath: string): Promise { + try { + const { stdout: dirty } = await execFileAsync("git", ["status", "--porcelain"], { cwd: worktreePath, timeout: 30_000 }); + if (dirty.trim()) return true; + const { stdout: ahead } = await execFileAsync("git", ["log", "--oneline", "@{upstream}..HEAD"], { cwd: worktreePath, timeout: 30_000 }) + .catch(async () => await execFileAsync("git", ["log", "--oneline", "-1", "HEAD", "--not", "--remotes", "--branches=main", "--branches=master"], { cwd: worktreePath, timeout: 30_000 })); + return Boolean(ahead.trim()); + } catch { + // Cannot prove the worktree is clean → treat it as holding work and keep it. + return true; + } + } + public async ensureTaskWorktreeForPlanning(taskId: string): Promise { try { if (this.workspaceConfig === undefined) { diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts index 665e7e7482..c9b71f1645 100644 --- a/packages/engine/src/replan-target.ts +++ b/packages/engine/src/replan-target.ts @@ -44,7 +44,8 @@ legal from every legacy column and eligibleTriageTasks re-specifies unconditiona const PLANNING_STAGE_STATUSES = new Set(["planning", "needs-replan", "plan-review-unavailable"]); export function hasAdvancedPastPlanning( - task: Pick, + task: Pick + & Partial>, ): boolean { if ( task.column === "in-progress" @@ -54,8 +55,16 @@ export function hasAdvancedPastPlanning( ) { return true; } - // A worktree proves an executor claimed the card, even while it still sits in a planner lane. - if (task.worktree != null) { + /* + FNXC:NodeWorktreeIsolation 2026-07-25-22:40: + A worktree NO LONGER proves an executor claimed the card. Planning acquires the task's own + worktree up front (so no lane runs in the shared checkout), which means a card being planned right + now carries `worktree` — and reading that as "advanced" would make every planning write skip: + `status:"planning"` never lands, the spec finalization is refused, and the card is re-claimed + forever while occupying a maxTriageConcurrent slot. Execution TIMESTAMPS are the durable evidence + instead; they are written when implementation actually starts, never by worktree acquisition. + */ + if (task.firstExecutionAt != null || task.executionStartedAt != null) { return true; } // The planner column itself is never "advanced" — nothing executes out of triage. diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 77b7dbc093..90afeb74a3 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1203,6 +1203,9 @@ export class InProcessRuntime getExecutingTaskIds: () => this.executor?.getExecutingTaskIds() ?? new Set(), clearPhantomExecutorBinding: (taskId: string, options?: { preserveWorktrees?: boolean }) => this.executor?.clearPhantomExecutorBinding(taskId, options), listWorktreeHolders: () => this.executor?.listWorktreeHolders() ?? [], + // FNXC:PlanningEvacuation 2026-07-25-23:00: the executor owns the release safety conditions. + releasePreExecutionWorktree: (taskId, reason) => + this.executor?.releasePreExecutionWorktree(taskId, reason) ?? Promise.resolve(false), recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false), getPlanningTaskIds: () => this.triageProcessor?.getPlanningTaskIds() ?? new Set(), // FNXC:TaskTiming 2026-08-01-12:00: orphan planning recovery must defer diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 8596a46db5..0a70a62cbe 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -252,6 +252,16 @@ async function preserveWorktreeChanges(repoDir: string, worktreePath: string, ta +/* +FNXC:PlanningEvacuation 2026-07-25-23:20: +The pre-execution worktree sweep touches VERY OLD trees only. Planning-acquired worktrees are cheap to +re-create but expensive to lose track of, and a card parked for a few hours is routinely resumed — so +the sweep waits a month of complete inactivity before reclaiming anything. The event-driven release on +an explicit operator withdrawal (todo -> Ideas) is separate and immediate; this constant governs only +the unattended background pass. +*/ +const PRE_EXECUTION_WORKTREE_MAX_IDLE_MS = 30 * 24 * 60 * 60 * 1000; + export interface SelfHealingOptions { /** Project root directory (parent of .worktrees/) */ rootDir: string; @@ -270,6 +280,13 @@ export interface SelfHealingOptions { * (the leaked-slot reaper relies on this refusal signal). */ clearPhantomExecutorBinding?: (taskId: string, options?: { preserveWorktrees?: boolean }) => boolean | void; + /* + FNXC:PlanningEvacuation 2026-07-25-23:00: + Releases a task's PRE-EXECUTION worktree (acquired at planning time) when the card is parked + without ever executing. The executor owns the safety conditions — never executed, no live session, + clean branch — so this sweep only supplies candidates. Returns true when a worktree was released. + */ + releasePreExecutionWorktree?: (taskId: string, reason: string) => Promise; /** Optional AgentStore for agent-level self-healing checks. */ agentStore?: AgentStore; /** Canonical stale-lease recovery manager. */ @@ -2725,6 +2742,8 @@ export class SelfHealingManager { { name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() }, { name: "reclaim-phantom-workspace-land-leases", fn: () => this.reclaimPhantomWorkspaceLandLeases() }, { name: "reconcile-orphaned-workspace-worktrees", fn: () => this.reconcileOrphanedWorkspaceWorktrees() }, + // FNXC:PlanningEvacuation 2026-07-25-23:00: reclaim worktrees acquired at planning time by cards that never executed. + { name: "reconcile-pre-execution-worktrees", fn: () => this.reconcilePreExecutionWorktrees() }, { name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() }, { name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks() }, { name: "recover-post-done-noncontinuable-wedge", fn: () => this.recoverPostDoneNonContinuableWedge() }, @@ -8682,6 +8701,73 @@ export class SelfHealingManager { } } + /* + FNXC:PlanningEvacuation 2026-07-25-23:00 (pre-execution worktree sweep): + Planning acquires the task's own worktree, so cards that never reach execution would accumulate + worktrees on disk: withdrawn to an intake column, archived from a planner lane, or simply parked. + This sweep reclaims them. + + Candidates are addressed from task ROWS (never a directory walk — AGENTS.md forbids unbounded temp + scans): tasks holding `worktree` that sit in a non-executing column and carry no execution + timestamp. `todo` is deliberately EXCLUDED: a planned card waiting for a WIP slot legitimately + keeps its worktree, and churning it would just force a re-acquire minutes later. Every real safety + decision (live session, clean branch, execution evidence) belongs to + `TaskExecutor.releasePreExecutionWorktree`, so this sweep cannot diverge from the event-driven path + that runs on the move itself. + */ + async reconcilePreExecutionWorktrees(): Promise { + try { + const release = this.options.releasePreExecutionWorktree; + if (!release) return 0; + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const now = Date.now(); + const parked = await this.store.listTasks({ slim: true }); + const candidates = parked.filter((task) => { + if (!task.worktree || task.deletedAt) return false; + // Execution evidence — the worktree may hold real work; only the merge/archive lifecycle owns it. + if (task.firstExecutionAt || task.executionStartedAt) return false; + // Columns where a card is active or queued to become active. + if (task.column === "todo" || task.column === "in-progress" || task.column === "in-review" || task.column === "done") return false; + /* + WAITING is not PARKED. A card paused for an operator decision, carrying any status (planning, + needs-replan, awaiting-*), blocked on another task, or scheduled for a recovery attempt is + still expected to resume — taking its worktree would disturb work that is merely queued. + */ + if (task.paused || task.userPaused) return false; + if (task.status != null) return false; + if (task.blockedBy || task.overlapBlockedBy || task.nextRecoveryAt) return false; + /* + AGE GATE: only very old trees. A recently parked card is routinely un-parked within minutes, + and re-acquiring costs a clone-ish setup plus the project's init command. Idleness is measured + from the most recent of the column move and the last update, so any touch re-arms the clock. + */ + const lastTouchedMs = Math.max( + Date.parse(task.columnMovedAt ?? "") || 0, + Date.parse(task.updatedAt ?? "") || 0, + ); + if (!lastTouchedMs) return false; // cannot prove age → never sweep + return now - lastTouchedMs >= PRE_EXECUTION_WORKTREE_MAX_IDLE_MS; + }); + if (candidates.length === 0) return 0; + + let released = 0; + for (const task of candidates) { + try { + if (await release(task.id, `parked pre-execution in '${task.column}'`)) released++; + } catch (err: unknown) { + log.warn(`reconcilePreExecutionWorktrees: release failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (released > 0) log.log(`reconcilePreExecutionWorktrees: released ${released} pre-execution worktree(s)`); + return released; + } catch (err: unknown) { + log.error(`reconcilePreExecutionWorktrees sweep failed: ${err instanceof Error ? err.message : String(err)}`); + return 0; + } + } + /* FNXC:Workspace 2026-06-22-09:30 (Phase D U1, KTD4 — per-repo worktree cleanup from STORED paths): For done/dead workspace tasks, remove each recorded per-repo worktree. The paths are ADDRESSABLE diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 2bc13af869..2e8adb1f0b 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -288,6 +288,8 @@ export class TriageProcessor { private taskPausedHandler?: (task: Task) => void; /** FNXC:CodingIdeasWorkflow 2026-07-25-11:20: store-event wake for planning-eligible columns. */ private taskColumnWakeHandler?: (task: Task) => void; + /** FNXC:PlanningEvacuation 2026-07-25-23:00: stops planning when a card leaves the planner lanes. */ + private taskEvacuatedFromPlanningHandler?: (task: Task) => void; private _approvalRequestStore?: ApprovalRequestStore; /** @@ -548,6 +550,67 @@ export class TriageProcessor { if (this.processing.has(task.id) || this.hasLivePlanningWork(task.id)) return; this.requestImmediatePoll(); }; + + /* + FNXC:PlanningEvacuation 2026-07-25-23:00: + Moving a card OUT of the planner lanes while it is being planned (the reported case: dragging a + todo card back to Ideas) must stop the planning session immediately — the operator has withdrawn + the card, and an agent that keeps streaming tokens and writing a spec for it is doing work nobody + asked for. It must also stop LOOKING planned: the "planning" status badge is what the card shows, + so the abort path clears it (`pauseAborted` + the existing restore-status unwind) and the card + reads as a plain idea again. + + This reuses the pause/delete abort machinery verbatim — same abort(), same token-usage snapshot, + same `pauseAborted` unwind that clears status without reporting an error — so evacuation cannot + drift from the two paths that already work. + + Moving the card BACK to todo/triage needs no new code: `taskColumnWakeHandler` above wakes the + poll on that move, and with the planning status cleared the card is an ordinary planning + candidate again, so planning restarts. + + Columns are matched positively (todo/triage) rather than naming "ideas", so evacuation to ANY + non-planner column stops the session. `in-progress` is excluded from the abort because a card + that legitimately advances into execution is not an evacuation — its session is already + unwinding on its own. + */ + this.taskEvacuatedFromPlanningHandler = (task: Task) => { + if (!task?.id) return; + /* + Only an explicit, known destination column is evidence of evacuation. `task:updated` also + carries PARTIAL payloads (a pause flag flip, a steering comment) with no `column` field at all, + and treating an absent column as "not a planner lane" would abort a healthy planning session on + an unrelated update. + */ + if (typeof task.column !== "string") return; + if (task.column === "todo" || task.column === "triage" || task.column === "in-progress") return; + if (this.activeSubagentSessions.has(task.id)) { + this.disposeSubagentsForTask(task.id, `task moved to ${task.column}`); + } + const session = this.activeSessions.get(task.id); + if (!session) return; + planLog.log(`task moved out of planning to '${task.column}' — terminating triage session for ${task.id}`); + this.pauseAborted.add(task.id); + this.options.stuckTaskDetector?.untrackTask(task.id); + const sessionWithAbort = session as { abort?: () => Promise; dispose: () => void }; + if (typeof sessionWithAbort.abort === "function") { + void sessionWithAbort.abort().catch((err) => { + planLog.warn(`Failed to abort triage session for ${task.id}: ${err}`); + }); + } + this.recordTriageSessionTokenUsageSoon(task.id, session as AgentSession, { agentId: task.assignedAgentId ?? "triage" }); + session.dispose(); + this.activeSessions.delete(task.id); + /* + The `pauseAborted` unwind restores status only while the row is still in the planning stage; an + evacuated card is not, so clear the badge directly here. Fail-soft: a status write must never + break the abort. + */ + if (task.status === "planning") { + void Promise.resolve(this.store.updateTask(task.id, { status: null })).catch((err: unknown) => { + planLog.warn(`${task.id}: failed to clear planning status after evacuation: ${err instanceof Error ? err.message : String(err)}`); + }); + } + }; } start(): void { @@ -563,6 +626,11 @@ export class TriageProcessor { this.store.on("task:updated", this.taskColumnWakeHandler); this.store.on("task:created", this.taskColumnWakeHandler); } + if (this.taskEvacuatedFromPlanningHandler && typeof this.store.on === "function") { + // `task:updated` is the single event every move surface emits (see the wake handler's + // surface enumeration); `task:moved` carries a different payload shape and is not needed. + this.store.on("task:updated", this.taskEvacuatedFromPlanningHandler); + } // Clear stale "planning" statuses left by a prior crash/restart. // No triage agent is actually running at startup, so any task still @@ -624,6 +692,9 @@ export class TriageProcessor { this.store.off("task:updated", this.taskColumnWakeHandler); this.store.off("task:created", this.taskColumnWakeHandler); } + if (this.taskEvacuatedFromPlanningHandler && typeof this.store.off === "function") { + this.store.off("task:updated", this.taskEvacuatedFromPlanningHandler); + } // Tear down any in-flight specify sessions and reviewer subagents so they // don't keep streaming LLM tokens / tool calls past engine shutdown. this.abortAndDisposeActiveSessions("engine stop");