fix(engine): stop planning when a card is withdrawn; sweep stale pre-execution worktrees

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 15:20:17 -07:00
parent 168819b35d
commit d10d91bae5
9 changed files with 437 additions and 5 deletions

View File

@@ -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.

View File

@@ -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<Record<string, unknown>> = {}): 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> = {}): 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<Partial<Task>>) {
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<Task>,
]);
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<Task>]);
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();
});
});

View File

@@ -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,

View File

@@ -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({

View File

@@ -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<boolean> {
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<boolean> {
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<string | null> {
try {
if (this.workspaceConfig === undefined) {

View File

@@ -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, "column" | "worktree" | "steps" | "status">,
task: Pick<Task, "column" | "worktree" | "steps" | "status">
& Partial<Pick<Task, "firstExecutionAt" | "executionStartedAt">>,
): 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.

View File

@@ -1203,6 +1203,9 @@ export class InProcessRuntime
getExecutingTaskIds: () => this.executor?.getExecutingTaskIds() ?? new Set<string>(),
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<string>(),
// FNXC:TaskTiming 2026-08-01-12:00: orphan planning recovery must defer

View File

@@ -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<boolean>;
/** 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<number> {
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

View File

@@ -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<void>; 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");