FN-7717: release active-session locks when a task is archived

Archiving a task from triage/planning/todo (not just in-progress) previously left leaked active-session-registry entries, so a successor task could hit ActiveSessionPathHeldByForeignTaskError and get blocked from Plan Review.

- Add an explicit `to === "archived"` branch in the task-move handler that awaits abort of in-flight task work and sweeps any leftover activeSessionRegistry paths for the task, checked before the narrower `from === "in-progress"` branch so direct in-progress→archived transitions are covered too.
- Deliberately exclude `to === "done"` / `to === "in-review"` from this sweep since those columns legitimately hold ai-merge / workspace-repo-land merge leases that must survive the transition.
- Add regression test coverage for archive releasing active sessions across originating columns.
- Add changeset and architecture doc note.

Files changed:
 .../fn-7717-archive-active-session-release.md      |   7 +
 docs/architecture.md                               |   1 +
 ...xecutor-archive-releases-active-session.test.ts | 167 +++++++++++++++++++++
 packages/engine/src/executor.ts                    |  35 +++++
 4 files changed, 210 insertions(+)

Fusion-Task-Id: FN-7717

Fusion-Task-Lineage: 7cff6821-7bb3-4b75-b502-a26467ca7f51

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 08:03:51 -07:00
parent b2613b7132
commit c8fcbec94f
4 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Archiving a task now releases its active-session lock so the next task can run Plan Review.
category: fix
dev: task:moved handler in packages/engine/src/executor.ts now disposes active surfaces and sweeps activeSessionRegistry paths for any move to the terminal "archived" column (previously only from==="in-progress"); done/in-review merge leases are deliberately untouched.

View File

@@ -2102,6 +2102,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. FN-5304 guard: when `<rebaseBaseSha>..HEAD` reports zero own commits, merger must also validate the source `fusion/<id>` tip; if that source tip still has attributable own commits relative to `rebaseBaseSha`, throw `SilentNoOpAttributionMismatchError`, refuse writing `mergeConfirmed: true`, park the task in `in-review` with `status: "failed"`, and emit `merge:no-op-attribution-mismatch`. If source ref is unavailable, skip with diagnostic + `merge:no-op-attribution-mismatch-skipped` (`reason: "source-ref-unavailable"`). Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged.
- **Soft-delete scheduler invalidation (FN-5137)**: `task:deleted` events must invalidate `AutoClaimSnapshotManager` and clear scheduler bookkeeping (`pausedTaskIds`, `failedTaskIds`, `wasNodeDispatchValidationBlocked`, `wasNodeBlocked`); `executor.execute()` / `resumeOrphaned()` / `resumeTaskForAgent()` refuse any task with `deletedAt` set.
- **Soft-delete in-flight abort (FN-5142)**: `task:deleted` must immediately abort/dispose active executor work (`activeSessions`, `activeStepExecutors`, `activeWorkflowStepSessions`, reviewer subagents), interrupt active merge state (`mergeAbortController`, `activeMergeSession`, `activeMergeTaskId`, `mergeActive`, `mergeQueue`, `pausedReviewTaskIds`), and abort triage specify/subagent sessions for that id. Handlers are per-task and idempotent.
- **Archive releases active-session locks (FN-7717)**: `task:moved` with `to === "archived"` (from ANY column, including in-progress via a direct single-hop `fn_task_archive`, and triage/planning where Plan Review and other workflow-step sessions run) now disposes in-flight session surfaces via `awaitAbortInFlightTaskWork` and sweeps any remaining `activeSessionRegistry` paths for the task, so a leaked lock can never survive archive and block a successor task's `registerPath` on the same session path. The `to === "archived"` check is ordered BEFORE the `from === "in-progress"` branch so a direct in-progress→archived move gets the same full cleanup instead of falling into the narrower in-progress-only branch. `to === "done"`/`"in-review"` are deliberately excluded — those columns legitimately hold `ai-merge`/`workspace-repo-land` merge leases.
- **Soft-delete audit + column reconcile (FN-5175)**: `TaskStore.deleteTask` records a `runAuditEvents` row (`mutationType: "task:deleted"`, `domain: "database"`) inside the same transaction that sets `deletedAt`, and sets `"column" = 'archived'` on the row. Callers without a heartbeat run context (`fn task delete`, pi extension, dashboard delete route) pass an `auditContext` with `agentId: "system"` and a synthetic `runId`. The watcher cross-instance emit path does NOT re-record the audit event. The row stays in `tasks` (not `archivedTasks`); `archiveTask` is unchanged.
- **Soft-delete resurrection guard (FN-5208)**: `TaskStore.readTaskJson()` must never fall back to `.fusion/tasks/<id>/task.json` when the DB row exists with `deletedAt` set — it throws `TaskDeletedError`. `atomicCreateTaskJson` / `atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit` refuse to upsert a task whose row is currently soft-deleted (unless the in-memory task carries `deletedAt` itself, for soft-delete maintenance paths), emit a `[soft-delete-resurrection-blocked]` log line, and record a `task:resurrection-blocked` run-audit event. Stale in-flight planner/triage writes for a soft-deleted ID surface `TaskDeletedError` and abort cleanly without emitting `task:created`.
- **Exhausted in-review visibility surfaces (FN-5513/FN-6569)**: retry-exhausted merge failures (`column='in-review'`, `status='failed'`, `mergeRetries >= maxAutoMergeRetries`, default `3`) can remain soft-deleted for lifecycle safety, but are now intentionally discoverable through opt-in read paths: `TaskStore.listExhaustedInReviewTasks({ includeDeleted })`, `GET /api/tasks/exhausted-in-review`, `GET /api/tasks/:id?includeDeleted=true`, CLI `fn_task_show` soft-delete fallback marker, CLI `fn_task_list({ includeDeleted: true })`, and the dashboard ReliabilityView "Exhausted in-review (hidden blockers)" panel. This complements FN-5488/FN-5496 downstream blocker healing by surfacing the upstream blocker without mutating lifecycle state.

View File

@@ -0,0 +1,167 @@
/*
FNXC:WorkflowLifecycle 2026-07-09-00:00 (FN-7717 regression):
Archiving a task must release every activeSessionRegistry entry it holds. Plan Review and
other workflow-step / step-session sessions run while a task is in triage/planning/todo (not
in-progress), so the executor's task:moved handler previously only disposed session surfaces
via the `from === "in-progress"` branch — a task archived from any OTHER column leaked its
registry entry and blocked a successor task from registering the same session path with
ActiveSessionPathHeldByForeignTaskError (NEXT-508 -> NEXT-433). This suite proves the fix
across all three registration surfaces (executor / step-session / workflow-step), the
leaked-entry sweep path (no in-memory session) including when archiving DIRECTLY from
in-progress in a single task:moved hop (a branch-ordering gap the fix also closes), the
done/in-review merge-lease exclusion, and the no-op case (task with no held paths).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { TaskStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { activeSessionRegistry, ActiveSessionPathHeldByForeignTaskError } from "../active-session-registry.js";
const SHARED_ROOT = "/tmp/fusion-test-archive-shared-root";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter();
return Object.assign(emitter, {
logEntry: vi.fn().mockResolvedValue(undefined),
getRunContextFor: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
}) as unknown as TaskStore & EventEmitter;
}
function makeExecutor(): { executor: TaskExecutor; store: TaskStore & EventEmitter } {
const store = createStore();
const executor = new TaskExecutor(store, SHARED_ROOT);
return { executor, store };
}
function makeTask(id: string): any {
return { id, column: "archived" };
}
describe("archiving a task releases its active-session registry entries (FN-7717)", () => {
beforeEach(() => activeSessionRegistry.clear());
afterEach(() => activeSessionRegistry.clear());
it("releases a workflow-step session held by a task archived from triage, letting a successor acquire the same path", async () => {
const { executor, store } = makeExecutor();
// Task A registers a workflow-step (Plan Review) session on the shared root — the
// reported NEXT-508 case.
(executor as any).setActiveWorkflowStepSession("TASK-A", {}, SHARED_ROOT);
expect(activeSessionRegistry.isPathActive(SHARED_ROOT)).toBe(true);
// Drive the archive transition: to === "archived", from a NON-in-progress column
// (Plan Review runs in triage), exactly like archiveTask emits.
store.emit("task:moved", { task: makeTask("TASK-A"), from: "triage", to: "archived", source: "user" });
// Await the disposal chain the handler kicked off via trackTaskDisposal.
await (executor as any).pendingTaskDisposals.get("TASK-A");
expect(activeSessionRegistry.isPathActive(SHARED_ROOT)).toBe(false);
expect(activeSessionRegistry.pathsForTask("TASK-A")).toHaveLength(0);
// Successor task B can now register the same path without throwing.
expect(() =>
activeSessionRegistry.registerPath(SHARED_ROOT, { taskId: "TASK-B", kind: "workflow-step", ownerKey: "TASK-B#workflow-step" }),
).not.toThrow();
});
it("releases executor and step-session surfaces archived from planning/todo columns", async () => {
const { executor, store } = makeExecutor();
(executor as any).setActiveSession("TASK-C", { session: { dispose: vi.fn() } }, `${SHARED_ROOT}-c`);
(executor as any).setActiveStepExecutor("TASK-D", { terminateAllSessions: vi.fn().mockResolvedValue(undefined) }, `${SHARED_ROOT}-d`);
store.emit("task:moved", { task: makeTask("TASK-C"), from: "planning", to: "archived", source: "user" });
store.emit("task:moved", { task: makeTask("TASK-D"), from: "todo", to: "archived", source: "user" });
await Promise.all([
(executor as any).pendingTaskDisposals.get("TASK-C"),
(executor as any).pendingTaskDisposals.get("TASK-D"),
]);
expect(activeSessionRegistry.pathsForTask("TASK-C")).toHaveLength(0);
expect(activeSessionRegistry.pathsForTask("TASK-D")).toHaveLength(0);
});
it("sweeps a leaked registry entry with no in-memory session on archive", async () => {
const { executor, store } = makeExecutor();
// Simulate a LEAKED entry: registered directly in the registry with no corresponding
// in-memory activeSessions/activeStepExecutors/activeWorkflowStepSessions entry, so the
// abort call itself finds nothing to dispose — only the sweep clears it.
activeSessionRegistry.registerPath(`${SHARED_ROOT}-leak`, { taskId: "TASK-E", kind: "workflow-step", ownerKey: "TASK-E#workflow-step" });
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-leak`)).toBe(true);
store.emit("task:moved", { task: makeTask("TASK-E"), from: "in-review", to: "archived", source: "user" });
await (executor as any).pendingTaskDisposals.get("TASK-E");
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-leak`)).toBe(false);
expect(activeSessionRegistry.pathsForTask("TASK-E")).toHaveLength(0);
});
it("sweeps a leaked registry entry when a task is archived DIRECTLY from in-progress (single task:moved hop, no todo stop)", async () => {
const { executor, store } = makeExecutor();
// fn_task_archive can move a live in-progress task straight to archived in one
// `task:moved` event (from: "in-progress", to: "archived") with no intermediate
// stop in "todo". Before the branch-ordering fix, this hit the narrower
// `from === "in-progress"` branch first and skipped the archive-only leaked-entry
// sweep, so a registry entry with no matching in-memory session would survive.
activeSessionRegistry.registerPath(`${SHARED_ROOT}-inprogress-leak`, { taskId: "TASK-I", kind: "workflow-step", ownerKey: "TASK-I#workflow-step" });
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-inprogress-leak`)).toBe(true);
store.emit("task:moved", { task: makeTask("TASK-I"), from: "in-progress", to: "archived", source: "user" });
await (executor as any).pendingTaskDisposals.get("TASK-I");
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-inprogress-leak`)).toBe(false);
expect(activeSessionRegistry.pathsForTask("TASK-I")).toHaveLength(0);
});
it("does NOT clear a held merge lease when a task moves to done or in-review", async () => {
const { executor, store } = makeExecutor();
activeSessionRegistry.registerPath(`${SHARED_ROOT}-merge-done`, { taskId: "TASK-F", kind: "ai-merge", ownerKey: "TASK-F#ai-merge" });
activeSessionRegistry.registerPath(`${SHARED_ROOT}-merge-review`, { taskId: "TASK-G", kind: "workspace-repo-land", ownerKey: "TASK-G#workspace-repo-land" });
store.emit("task:moved", { task: makeTask("TASK-F"), from: "in-progress", to: "done", source: "engine" });
store.emit("task:moved", { task: makeTask("TASK-G"), from: "in-progress", to: "in-review", source: "engine" });
// These moves go through the existing `from === "in-progress"` branch, which is
// unrelated to and does not fire the new archive-only sweep — the merge lease survives.
await Promise.resolve();
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-merge-done`)).toBe(true);
expect(activeSessionRegistry.isPathActive(`${SHARED_ROOT}-merge-review`)).toBe(true);
});
it("is a no-op that does not throw when archiving a task with no held registry paths", async () => {
const { executor, store } = makeExecutor();
expect(() =>
store.emit("task:moved", { task: makeTask("TASK-H"), from: "triage", to: "archived", source: "user" }),
).not.toThrow();
await (executor as any).pendingTaskDisposals.get("TASK-H");
expect(activeSessionRegistry.pathsForTask("TASK-H")).toHaveLength(0);
});
it("reproduces the original ActiveSessionPathHeldByForeignTaskError before archive, and confirms it is gone after", async () => {
const { executor, store } = makeExecutor();
(executor as any).setActiveWorkflowStepSession("NEXT-508", {}, SHARED_ROOT);
// Before archive: a second task trying to register the same path is rejected.
expect(() =>
activeSessionRegistry.registerPath(SHARED_ROOT, { taskId: "NEXT-433", kind: "workflow-step", ownerKey: "NEXT-433#workflow-step" }),
).toThrow(ActiveSessionPathHeldByForeignTaskError);
store.emit("task:moved", { task: makeTask("NEXT-508"), from: "triage", to: "archived", source: "user" });
await (executor as any).pendingTaskDisposals.get("NEXT-508");
// After archive: the successor can now acquire the path.
expect(() =>
activeSessionRegistry.registerPath(SHARED_ROOT, { taskId: "NEXT-433", kind: "workflow-step", ownerKey: "NEXT-433#workflow-step" }),
).not.toThrow();
});
});

View File

@@ -2793,6 +2793,41 @@ export class TaskExecutor {
})().catch((err) =>
executorLog.error(`Failed to start ${task.id}:`, err),
);
} else if (to === "archived") {
/*
FNXC:WorkflowLifecycle 2026-07-09-00:05:
Archived is terminal, so it must release every active-session registry entry the
task holds. Plan Review / other workflow-step and step-session sessions run while
the task is in triage/planning/todo (not in-progress), so the old
`from === "in-progress"`-only disposal branch below never fired for them — the
registry entry (activeSessions / activeStepExecutors / activeWorkflowStepSessions,
keyed on the shared project browse root) leaked past archive and blocked a
successor task from acquiring the same session path with
ActiveSessionPathHeldByForeignTaskError (FN-7717 / NEXT-508 -> NEXT-433). We
deliberately do NOT do this for to === "done" / "in-review": those columns
legitimately hold ai-merge / workspace-repo-land merge leases that must survive
the transition (FN-6736 / Phase C/D merge-lease guarantees).
This branch is checked BEFORE `from === "in-progress"` (and handles it too — a
task can be archived directly from in-progress via fn_task_archive, a single
`task:moved` event with no intermediate todo hop). Ordering the plain
`from === "in-progress"`-only branch first would let that direct
in-progress → archived transition fall into the narrower branch and skip the
leaked-entry sweep below, re-opening the exact class of leak this fix closes for
that one origin column. `awaitAbortInFlightTaskWork` here is the same call the
in-progress branch makes (superset of its cleanup), so no case regresses.
*/
this.trackTaskDisposal(
task.id,
this.awaitAbortInFlightTaskWork(task.id, "task archived").then(() => {
// Belt-and-suspenders sweep: clear any registry entry that survived the
// abort above because its in-memory session map was already empty
// (a leaked entry with no live session to abort).
for (const path of activeSessionRegistry.pathsForTask(task.id)) {
activeSessionRegistry.unregisterPath(path);
}
}),
);
} else if (from === "in-progress") {
this.trackTaskDisposal(
task.id,