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:
7
.changeset/planning-evacuation-and-worktree-sweep.md
Normal file
7
.changeset/planning-evacuation-and-worktree-sweep.md
Normal 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.
|
||||
162
packages/engine/src/__tests__/planning-evacuation.test.ts
Normal file
162
packages/engine/src/__tests__/planning-evacuation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user