diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 8e4a20f67b..cf42b4aed5 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Agent, AgentStore, TaskStore, Task, TaskCreateInput } from "@fusion/core"; import { createAgentTask, createListAgentsTool, createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js"; +import { RENAMED_VOCAB, lifecycleIr } from "./_workflow-vocabulary-fixture.js"; function createMockAgentStore(overrides: Partial = {}): AgentStore { return { @@ -432,35 +433,61 @@ describe("createDelegateTaskTool", () => { }), expect.anything()); }); - it("does not let a completed diagnostic suppress newly required work", async () => { - const completed = { - id: "FN-DONE", - description: "Fix unresolved `html2canvas` typecheck failure.", - dependencies: [], - column: "done" as const, - sourceParentTaskId: "FN-OLD-PARENT", - steps: [], - currentStep: 0, - log: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as Task; - const created = { - ...completed, - id: "FN-NEW", - column: "triage" as const, - sourceParentTaskId: "FN-NEW-PARENT", - } as Task; - vi.mocked(taskStore.searchTasks).mockResolvedValue([completed]); - vi.mocked(taskStore.createTask).mockResolvedValue(created); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-10:20 (batch-engine tail): + DIFFERENTIAL over the column vocabulary. This invariant — "a FINISHED diagnostic must not suppress + newly required work" — was asserted only against the legacy `done` id, so it passed for a guard that + compared `candidate.column !== "done"`. On a board whose complete lane is renamed, that comparison is + true for a shipped card, the dedup guard adopts it as canonical, and the new diagnostic is silently + absorbed into a task nobody is working on. - const result = await createAgentTask(taskStore, { - description: "Restore the missing html2canvas dependency so dashboard typecheck passes.", - }, { sourceTaskId: "FN-NEW-PARENT" }); + The renamed run supplies a real workflow IR; without one `resolveWorkflowIrForTask` returns the BUILT-IN + coding IR (it degrades rather than throwing), the resolved complete lane would be `done`, and the + renamed case would be indistinguishable from the default one. - expect(result).toEqual({ task: created, wasDuplicate: false }); - expect(taskStore.createTask).toHaveBeenCalledOnce(); - }); + REVERT CHECK, measured: with `.filter((c) => c.column !== "done" && c.column !== "archived")` restored, + the RENAMED case fails — `wasDuplicate: true` and `createTask` is never called. The DEFAULT case passes + before and after, which is why both are run. + */ + for (const [label, completeColumn] of [["DEFAULT", "done"], ["RENAMED", "shipped"]] as const) { + it(`does not let a completed diagnostic suppress newly required work (${label} complete column: ${completeColumn})`, async () => { + const completed = { + id: "FN-DONE", + description: "Fix unresolved `html2canvas` typecheck failure.", + dependencies: [], + column: completeColumn, + sourceParentTaskId: "FN-OLD-PARENT", + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + const created = { + ...completed, + id: "FN-NEW", + column: "triage" as const, + sourceParentTaskId: "FN-NEW-PARENT", + } as Task; + vi.mocked(taskStore.searchTasks).mockResolvedValue([completed]); + vi.mocked(taskStore.createTask).mockResolvedValue(created); + if (label === "RENAMED") { + const ir = lifecycleIr(RENAMED_VOCAB, "agent-tools-dedup"); + Object.assign(taskStore, { + getTaskWorkflowSelectionAsync: async () => ({ workflowId: "agent-tools-dedup", stepIds: [] }), + getTaskWorkflowSelection: () => ({ workflowId: "agent-tools-dedup", stepIds: [] }), + getWorkflowDefinition: async (id: string) => (id === "agent-tools-dedup" ? { ir } : undefined), + }); + } + + const result = await createAgentTask(taskStore, { + description: "Restore the missing html2canvas dependency so dashboard typecheck passes.", + }, { sourceTaskId: "FN-NEW-PARENT" }); + + expect(result).toEqual({ task: created, wasDuplicate: false }); + expect(taskStore.createTask).toHaveBeenCalledOnce(); + }); + } it("fails closed when cross-parent diagnostic lookup is unavailable", async () => { vi.mocked(taskStore.searchTasks).mockRejectedValue(new Error("database unavailable")); @@ -666,28 +693,58 @@ describe("createDelegateTaskTool", () => { expect(store.createTask).not.toHaveBeenCalled(); }); - it("does not select an archived same-agent task as a defined-feature bootstrap canonical", async () => { - const archived = { - id: "FN-archived", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature", - sourceAgentId: "agent-001", dependencies: [], column: "archived" as const, steps: [], currentStep: 0, - log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - } as Task; - const store = createMockTaskStore({ listTasks: vi.fn().mockResolvedValue([archived]) }); - const validate = vi.fn().mockResolvedValue(undefined); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-10:35 (batch-engine tail): + DIFFERENTIAL over the archive lane's id. The invariant below was asserted only against the legacy + `archived` id, so it passed for a guard comparing `task.column === "archived"`. - const result = await createAgentTask(store, { - title: "Bootstrap feature", - description: "Bootstrap the hand-authored feature", - source: { sourceType: "api", sourceAgentId: "agent-001" }, - preflightSameAgentDuplicate: true, - validateDuplicateCanonical: validate, - } as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise }); + NOT the query-filter class, which is why this one is load-bearing: the preflight's own query passes + `includeArchived: true`, so this predicate is the ONLY archived guard on the path. On a renamed archive + lane the archived sibling became the bootstrap canonical and `claimDefinedFeatureTask` then rejects the + non-live row — so a valid first task fails to be created at all. - /* FNXC:MissionAdmission 2026-07-23-21:10: archived tasks are not live bootstrap canonicals and must not block a valid first task. */ - expect(result.wasDuplicate).toBe(false); - expect(validate).not.toHaveBeenCalled(); - expect(store.createTask).toHaveBeenCalledOnce(); - }); + The renamed IR is built HERE rather than in `_workflow-vocabulary-fixture.ts`: that shared fixture + declares no `archived`-trait column, and widening it would change the subject of every suite already + built on it. + + REVERT CHECK, measured: with `task.column === "archived"` restored, the RENAMED case fails — `validate` + is called with the archived sibling and `createTask` is never called. The DEFAULT case passes both ways. + */ + for (const [label, archivedColumn] of [["DEFAULT", "archived"], ["RENAMED", "boxed"]] as const) { + it(`does not select an archived same-agent task as a defined-feature bootstrap canonical (${label} archive lane: ${archivedColumn})`, async () => { + const archived = { + id: "FN-archived", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature", + sourceAgentId: "agent-001", dependencies: [], column: archivedColumn, steps: [], currentStep: 0, + log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } as Task; + const base = lifecycleIr(RENAMED_VOCAB, "agent-tools-archive"); + const ir = { ...base, columns: [...base.columns, { id: "boxed", name: "Boxed", traits: [{ trait: "archived" as const }] }] }; + const store = createMockTaskStore({ + listTasks: vi.fn().mockResolvedValue([archived]), + ...(label === "RENAMED" + ? { + getTaskWorkflowSelectionAsync: (async () => ({ workflowId: "agent-tools-archive", stepIds: [] })) as never, + getTaskWorkflowSelection: (() => ({ workflowId: "agent-tools-archive", stepIds: [] })) as never, + getWorkflowDefinition: (async (id: string) => (id === "agent-tools-archive" ? { ir } : undefined)) as never, + } + : {}), + }); + const validate = vi.fn().mockResolvedValue(undefined); + + const result = await createAgentTask(store, { + title: "Bootstrap feature", + description: "Bootstrap the hand-authored feature", + source: { sourceType: "api", sourceAgentId: "agent-001" }, + preflightSameAgentDuplicate: true, + validateDuplicateCanonical: validate, + } as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise }); + + /* FNXC:MissionAdmission 2026-07-23-21:10: archived tasks are not live bootstrap canonicals and must not block a valid first task. */ + expect(result.wasDuplicate).toBe(false); + expect(validate).not.toHaveBeenCalled(); + expect(store.createTask).toHaveBeenCalledOnce(); + }); + } it("serializes three concurrent paraphrased creates from one parent", async () => { const tasks: Task[] = []; diff --git a/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts b/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts new file mode 100644 index 0000000000..02ade13fc3 --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts @@ -0,0 +1,144 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-09:40 (batch-engine tail — the agent tools listed FINISHED cards as active): +DIFFERENTIAL: one task set, one pair of tools, two column VOCABULARIES. + +`fn_task_list` describes itself as "list active tasks that aren't done or archived" and `fn_task_search` +offers `includeDone: false`. Both filtered with `task.column !== "done"`. On a board whose complete lane is +renamed, a finished card came back as ACTIVE — to an AGENT, which then reasons and acts on it as +outstanding work. Nothing throws; the agent is simply told the wrong thing. + +`includeArchived: false` was always enforced by the QUERY, so it survived a rename. "done" was only ever a +TypeScript predicate, which is why exactly this half broke. + +WHY A NEW FILE: no existing suite exercised `createTaskListTool` or `createTaskSearchTool` at all, so the +"304/304 green" the conversion originally cited was not evidence about the conversion. Per +`docs/solutions/test-failures/optional-flags-seam-hides-unconverted-column-guards.md`, a suite that never +supplies a workflow asserts the legacy fallback and passes before AND after the change. + +BOTH SURFACES, deliberately. Converting two copies and testing one is the Surface Enumeration failure this +program has already hit twice; `fn_task_list` and `fn_task_search` are separate call sites of the helper. + +REVERT CHECK, measured (each run against `task.column !== "done"` restored): + - "fn_task_list omits a finished card on a RENAMED complete column" fails: the shipped card is listed. + - "fn_task_search omits a finished card on a RENAMED complete column" fails: same. +Both DEFAULT-vocabulary cases pass before and after, which is the point of running both. +*/ +import { describe, expect, it } from "vitest"; +import type { Task, TaskStore, WorkflowIr } from "@fusion/core"; +import { createTaskListTool, createTaskSearchTool } from "../agent-tools.js"; +import { DEFAULT_VOCAB, RENAMED_VOCAB, lifecycleIr, type Vocabulary } from "./_workflow-vocabulary-fixture.js"; + +/** + * Three cards spanning the roles the filter must separate: one mid-flight, one finished, and one in a + * lane that is neither. The third is the NON-VACUOUS companion — without it a filter that returned + * everything, or nothing, would satisfy the finished-card assertions. + */ +function tasksFor(vocab: Vocabulary): Task[] { + const card = (id: string, column: string, title: string): Task => + ({ + id, + title, + description: title, + column, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-07-30T00:00:00.000Z", + updatedAt: "2026-07-30T00:00:00.000Z", + }) as Task; + + return [ + card("FN-9101", vocab.wip, "still building"), + card("FN-9102", vocab.complete, "shipped already"), + card("FN-9103", vocab.hold, "waiting to start"), + ]; +} + +/** + * A store that resolves a REAL workflow IR, so the helper reads the vocabulary under test rather than + * failing soft to the legacy ids. Failing soft would make the renamed run indistinguishable from the + * default one and the differential meaningless. + */ +function fixture(vocab: Vocabulary) { + const ir: WorkflowIr = lifecycleIr(vocab, "agent-tools-lifecycle"); + const tasks = tasksFor(vocab); + const store = { + listTasks: async () => tasks, + searchTasks: async () => tasks, + getTaskWorkflowSelectionAsync: async () => ({ workflowId: "agent-tools-lifecycle", stepIds: [] }), + getTaskWorkflowSelection: () => ({ workflowId: "agent-tools-lifecycle", stepIds: [] }), + getWorkflowDefinition: async (id: string) => (id === "agent-tools-lifecycle" ? { ir } : undefined), + } as unknown as TaskStore; + return { store, tasks }; +} + +const VOCABULARIES: ReadonlyArray = [ + ["DEFAULT", DEFAULT_VOCAB], + ["RENAMED", RENAMED_VOCAB], +]; + +describe("agent task-discovery tools resolve the terminal lane by ROLE, not by id", () => { + for (const [label, vocab] of VOCABULARIES) { + it(`fn_task_list omits a finished card on a ${label} complete column (${vocab.complete})`, async () => { + const { store } = fixture(vocab); + const result = await createTaskListTool(store).execute("call-1", {} as never); + const text = result.content[0].text; + + expect(text).not.toContain("FN-9102"); + // Non-vacuous: the two non-terminal cards must SURVIVE the filter. + expect(text).toContain("FN-9101"); + expect(text).toContain("FN-9103"); + }); + + it(`fn_task_search omits a finished card on a ${label} complete column (${vocab.complete})`, async () => { + const { store } = fixture(vocab); + const result = await createTaskSearchTool(store).execute("call-2", { + query: "a", + includeDone: false, + } as never); + const text = result.content[0].text; + + expect(text).not.toContain("FN-9102"); + expect(text).toContain("FN-9101"); + expect(text).toContain("FN-9103"); + expect(result.details).toMatchObject({ count: 2 }); + }); + } + + it("fn_task_search still returns the finished card when includeDone is left at its default", async () => { + /* + The other direction, and the reason the helper is only invoked when `includeDone` is false: the tool + documents itself as searching "including done and archived tasks by default", which is what makes it + usable for duplicate detection. A conversion that filtered unconditionally would pass every case above. + */ + const { store } = fixture(RENAMED_VOCAB); + const result = await createTaskSearchTool(store).execute("call-3", { query: "a" } as never); + + expect(result.content[0].text).toContain("FN-9102"); + expect(result.details).toMatchObject({ count: 3 }); + }); + + it("falls back to the legacy terminal pair when the workflow cannot be resolved", async () => { + /* + `resolveWorkflowIrForTask` returns the BUILT-IN IR for a missing or corrupt workflow rather than + throwing, so the legacy union in the helper is load-bearing: without it a degraded board would resolve + a terminal set excluding its own terminal lane and the filter would go INERT — the exact failure being + fixed, reintroduced by the error path. + */ + const tasks = tasksFor(DEFAULT_VOCAB); + const store = { + listTasks: async () => tasks, + getTaskWorkflowSelectionAsync: async () => { + throw new Error("workflow store unavailable"); + }, + getTaskWorkflowSelection: () => undefined, + getWorkflowDefinition: async () => undefined, + } as unknown as TaskStore; + + const result = await createTaskListTool(store).execute("call-4", {} as never); + + expect(result.content[0].text).not.toContain("FN-9102"); + expect(result.content[0].text).toContain("FN-9101"); + }); +}); diff --git a/packages/engine/src/__tests__/gridlock-detector.test.ts b/packages/engine/src/__tests__/gridlock-detector.test.ts index 5b9dc116f3..e176f34522 100644 --- a/packages/engine/src/__tests__/gridlock-detector.test.ts +++ b/packages/engine/src/__tests__/gridlock-detector.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Settings, Task, TaskStore } from "@fusion/core"; import { GridlockDetector } from "../gridlock-detector.js"; import type { GridlockEvent } from "../gridlock-detector.js"; +import { RENAMED_VOCAB, lifecycleIr } from "./_workflow-vocabulary-fixture.js"; function createTask(id: string, overrides: Partial = {}): Task { return { @@ -76,6 +77,74 @@ describe("GridlockDetector", () => { expect(onGridlock).toHaveBeenCalledTimes(1); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-11:05 (batch-engine tail): + The dependency-satisfaction gate resolved by ROLE. Every other case in this file omits a workflow, so + `resolveWorkflowIrForTask` degrades to the built-in coding IR and they all assert the LEGACY answer — + they pass before and after this conversion, and would pass for a broken one too. + + A FALSE ALARM is the failure being fixed: on a renamed board no dependency ever satisfied the three + literal comparisons, so the detector reported dependency gridlock for tasks that are not blocked and + `notifyGridlock` paged the operator about it. + + REVERT CHECK, measured: with `dep.column !== "done" && dep.column !== "in-review" && dep.column !== + "archived"` restored, this fails — a gridlock event is raised naming FN-1 blocked by FN-10. + */ + it("does not report dependency gridlock when the blocker sits in a RENAMED complete lane", async () => { + const ir = lifecycleIr(RENAMED_VOCAB, "gridlock-lifecycle"); + store = { + listTasks: vi.fn(async () => tasks), + getSettings: vi.fn(async () => settings), + parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? []), + getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "gridlock-lifecycle", stepIds: [] })), + getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "gridlock-lifecycle", stepIds: [] })), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "gridlock-lifecycle" ? { ir } : undefined)), + } as unknown as TaskStore; + detector = new GridlockDetector(store, { onGridlock, onGridlockCleared }); + + tasks = [ + // Schedulable: sits in the renamed HOLD lane, blocked by a card that has SHIPPED. + createTask("FN-1", { column: RENAMED_VOCAB.hold, dependencies: ["FN-10"] }), + createTask("FN-10", { column: RENAMED_VOCAB.complete }), + // Keeps the ACTIVE set non-empty; an empty one is its own early return and would + // make this pass without the dependency gate ever being consulted. + createTask("FN-9", { column: RENAMED_VOCAB.wip }), + ]; + + const event = await detector.detectGridlock(); + + expect(event).toBeNull(); + expect(onGridlock).not.toHaveBeenCalled(); + }); + + it("still reports dependency gridlock when the blocker is mid-flight on a RENAMED board", async () => { + /* + Non-vacuous companion: without it, a gate that treated EVERY dependency as satisfied would pass the + case above. Same renamed board, same shape — only the blocker's lane changes. + */ + const ir = lifecycleIr(RENAMED_VOCAB, "gridlock-lifecycle"); + store = { + listTasks: vi.fn(async () => tasks), + getSettings: vi.fn(async () => settings), + parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? []), + getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "gridlock-lifecycle", stepIds: [] })), + getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "gridlock-lifecycle", stepIds: [] })), + getWorkflowDefinition: vi.fn(async (id: string) => (id === "gridlock-lifecycle" ? { ir } : undefined)), + } as unknown as TaskStore; + detector = new GridlockDetector(store, { onGridlock, onGridlockCleared }); + + tasks = [ + createTask("FN-1", { column: RENAMED_VOCAB.hold, dependencies: ["FN-10"] }), + createTask("FN-10", { column: RENAMED_VOCAB.wip }), + createTask("FN-9", { column: RENAMED_VOCAB.wip }), + ]; + + const event = await detector.detectGridlock(); + + expect(event?.blockedTaskIds).toEqual(["FN-1"]); + expect(event?.reasons).toEqual({ "FN-1": "dependency" }); + }); + it("detects gridlock when all todo tasks are blocked by file overlap", async () => { tasks = [ createTask("FN-1", { column: "todo" }), diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index cb51f569ac..3d74a06816 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1183,6 +1183,14 @@ async function findDefinedFeatureBootstrapDuplicate( if (!sourceAgentId && !sourceParentTaskId) return undefined; const candidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true }); const byId = new Map(candidates.map((task) => [task.id, task])); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-10:05 (batch-engine tail): + Resolved AHEAD of the synchronous `flatMap` below, which cannot await. NOT the query-filter class: this + query passes `includeArchived: true`, so the predicate inside the callback is the ONLY archived guard on + this path — on a renamed archive lane an archived sibling became a bootstrap canonical, and + `claimDefinedFeatureTask` then rejects the non-live row, so the claim fails outright. + */ + const isArchivedCandidate = await resolveArchivedColumnsForTasks(store, candidates); const matches = findSameAgentDuplicates({ title: input.title, description: input.description, @@ -1195,7 +1203,7 @@ async function findDefinedFeatureBootstrapDuplicate( task boundary. An archived sibling cannot be a bootstrap canonical because claimDefinedFeatureTask rejects non-live task rows. */ - if (Number.isNaN(createdAt) || task.deletedAt || task.column === "archived") return []; + if (Number.isNaN(createdAt) || task.deletedAt || isArchivedCandidate(task)) return []; return [{ id: task.id, title: task.title ?? "", @@ -1227,6 +1235,67 @@ async function carryCanonicalTaskRouting( return task; } + +/* +FNXC:WorkflowResolvedColumns 2026-07-30-23:05 (batch-engine — the agent tools listed finished cards as active): +`fn_task_list` describes itself as "list active tasks that aren't done or archived", and `fn_task_search` +offers `includeDone: false`. Both filtered with `task.column !== "done"`, so on a board whose complete lane +is renamed a FINISHED card came back as active — to an AGENT, which then reasons and acts on it as +outstanding work. `includeArchived: false` is handled by the query, but "done" was only ever a TS predicate. + +MEMBERSHIP over the complete AND archived roles, unioned with the legacy pair: `resolveWorkflowIrForTask` +returns the BUILT-IN IR for a missing or corrupt workflow rather than throwing, so without the union a +degraded renamed board would resolve a terminal set that excludes its own terminal lane and the filter +would go inert. + +ONE CACHE per call, so a list spanning three workflows reads three IRs rather than one per task. +*/ +export async function resolveTerminalColumnsForTasks( + store: TaskStore, + tasks: readonly Task[], +): Promise<(task: Task) => boolean> { + const cache = new Map>>(); + const terminalByTaskId = new Map>(); + for (const task of tasks) { + if (terminalByTaskId.has(task.id)) continue; + const columns = new Set(["done", "archived"]); + try { + const ir = await fusionCore.resolveWorkflowIrForTask(store, task.id, cache); + if (ir) { + for (const id of fusionCore.columnsWithFlag(ir, "complete")) columns.add(id); + for (const id of fusionCore.columnsWithFlag(ir, "archived")) columns.add(id); + } + } catch { /* degraded: legacy pair only */ } + terminalByTaskId.set(task.id, columns); + } + return (task: Task) => terminalByTaskId.get(task.id)?.has(task.column) === true; +} + +/** + * MEMBERSHIP over the `archived` role for a fixed task set, unioned with the legacy id. + * + * Split from `resolveTerminalColumnsForTasks` rather than parameterised: the two callers ask genuinely + * different questions — "is this finished?" (complete OR archived) versus "is this archived?" — and + * collapsing them would make an archived-only guard also reject completed rows. + */ +async function resolveArchivedColumnsForTasks( + store: TaskStore, + tasks: readonly Task[], +): Promise<(task: Task) => boolean> { + const cache = new Map>>(); + const archivedByTaskId = new Map>(); + for (const task of tasks) { + if (archivedByTaskId.has(task.id)) continue; + const columns = new Set(["archived"]); + try { + const ir = await fusionCore.resolveWorkflowIrForTask(store, task.id, cache); + if (ir) for (const id of fusionCore.columnsWithFlag(ir, "archived")) columns.add(id); + } catch { /* degraded: legacy id only */ } + archivedByTaskId.set(task.id, columns); + } + return (task: Task) => archivedByTaskId.get(task.id)?.has(task.column) === true; +} + export async function createAgentTask( store: TaskStore, input: TaskCreateInput, @@ -1279,11 +1348,19 @@ export async function createAgentTask( try { const acknowledged = new Set(options?.acknowledgedDuplicates ?? []); const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; - const candidates = (await store.searchTasks(crossParentDiagnosticClaim.searchTerm, { + const searched = await store.searchTasks(crossParentDiagnosticClaim.searchTerm, { slim: true, includeArchived: false, - })) - .filter((candidate) => candidate.column !== "done" && candidate.column !== "archived") + }); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-10:05 (batch-engine tail): + On a renamed complete lane a FINISHED diagnostic card passed this filter, so the dedup guard + adopted it as canonical and returned `wasDuplicate: true` — silently absorbing new diagnostic + work into a task nobody is working on. Same shape as the eval-followup dedup defect. + */ + const isTerminalCandidate = await resolveTerminalColumnsForTasks(store, searched); + const candidates = searched + .filter((candidate) => !isTerminalCandidate(candidate)) .filter((candidate) => Date.parse(candidate.createdAt) >= cutoffMs) .filter((candidate) => !acknowledged.has(candidate.id)) .filter((candidate) => computeCrossParentDiagnosticClaimId({ @@ -1642,7 +1719,8 @@ export function createTaskListTool(store: TaskStore): ToolDefinition { parameters: taskListParams, execute: async () => { const tasks = await store.listTasks({ slim: true, includeArchived: false }); - const active = tasks.filter((task) => task.column !== "done"); + const isTerminal = await resolveTerminalColumnsForTasks(store, tasks); + const active = tasks.filter((task) => !isTerminal(task)); const lines = active.map(formatTaskSummaryLine); return { content: [{ type: "text" as const, text: formatTaskReadLines(lines, "No active tasks.") }], @@ -1675,7 +1753,8 @@ export function createTaskSearchTool(store: TaskStore): ToolDefinition { limit, }); const includeDone = params.includeDone ?? true; - const filtered = includeDone ? results : results.filter((task) => task.column !== "done"); + const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); + const filtered = includeDone ? results : results.filter((task) => !isTerminalResult!(task)); const lines = filtered.map(formatTaskSummaryLine); const text = formatTaskReadLines( lines.length > 0 ? [`Search results for "${query}" (${filtered.length}):`, ...lines] : [], diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 7f03b456f4..63b4e3c90f 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, import { getUnmetSchedulingDependencies } from "./scheduler.js"; import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js"; import { emitWorkflowLifecycleEvent } from "@fusion/core"; -import { resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel } from "@fusion/core"; +import { resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel } from "@fusion/core"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js"; @@ -12570,9 +12570,40 @@ export class TaskExecutor { reviewAddressingActivated = true; // Check dependencies const allTasks = await this.store.listTasks({ slim: true, includeArchived: false }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:50 (batch-engine — dependency satisfaction, per DEPENDENCY): + Resolved from each DEPENDENCY's own workflow, not this task's: dependencies routinely span workflows, + so asking "is my blocker finished?" against the blocked task's vocabulary is the wrong question. That + is the answer main settled on in `branch-group-ops.ts` (#2720) and it is reused here rather than + re-derived. + + MEMBERSHIP and unioned with the legacy trio, because a workflow may declare more than one complete or + review lane and `resolveWorkflowIrForTask` yields the BUILT-IN IR for a missing workflow rather than + throwing — without the union a degraded renamed board treats a finished blocker as unmet and the + dependent never runs. + + NOTE the set is wider than the terminal pair: this guard has always counted `in-review` as satisfying + a dependency, so the review role is included. Narrowing it to terminal-only would be a behaviour + change, not a conversion. + */ + const depIrCache = new Map>>(); + const satisfiedByDep = new Map>(); + for (const depId of task.dependencies) { + if (satisfiedByDep.has(depId)) continue; + const satisfied = new Set(["done", "in-review", "archived"]); + try { + const depIr = await resolveWorkflowIrForTask(this.store, depId, depIrCache); + if (depIr) { + for (const flag of ["complete", "archived", "mergeOrchestration", "mergeBlocker", "humanReview"] as const) { + for (const id of columnsWithFlag(depIr, flag)) satisfied.add(id); + } + } + } catch { /* degraded: the legacy trio */ } + satisfiedByDep.set(depId, satisfied); + } const unmetDeps = task.dependencies.filter((depId) => { const dep = allTasks.find((t) => t.id === depId); - return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; + return dep !== undefined && !satisfiedByDep.get(depId)!.has(dep.column); }); if (unmetDeps.length > 0) { diff --git a/packages/engine/src/gridlock-detector.ts b/packages/engine/src/gridlock-detector.ts index cc0589f3eb..78f0cdd466 100644 --- a/packages/engine/src/gridlock-detector.ts +++ b/packages/engine/src/gridlock-detector.ts @@ -21,7 +21,7 @@ future cleanup revisits this, the question to ask is whether dependency and over deadlock are still possible — not whether capacity is simpler. */ import type { MissionStore, Task, TaskStore, WorkflowIr } from "@fusion/core"; -import { resolveTaskLifecycleColumns } from "@fusion/core"; +import { resolveTaskLifecycleColumns, resolveWorkflowIrForTask, columnsWithFlag } from "@fusion/core"; import { createLogger } from "./logger.js"; import { filterPathsByIgnoreList, pathsOverlap } from "./scheduler.js"; @@ -153,10 +153,44 @@ export class GridlockDetector { const reasons: Record = {}; const blockingTaskIds = new Set(); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-10:55 (batch-engine tail): + "Is this dependency satisfied?" resolved per DEPENDENCY, not per dependent: a blocker's OWN workflow + decides when it stops blocking, and the two tasks need not share one. + + On a renamed board every one of these three comparisons was true for a finished blocker, so NO + dependency ever counted as met — the detector then reports dependency gridlock for tasks that are + not actually blocked, and `notifyGridlock` pages the operator about it. + + REVIEW COUNTS AS SATISFIED, deliberately, and via the SAME five flags the executor dependency gate + uses — `review` is not a trait: the role is carried by mergeOrchestration/mergeBlocker/humanReview. + Two gates answering "is this dependency satisfied?" differently is a split brain. A dependent may + start once its blocker reaches review. Collapsing this to complete-only is the flattening that + deadlocks a board, so the three roles stay a union rather than becoming `resolveLifecycleColumns`'s + first-per-role. + + Unioned with the legacy trio because `resolveWorkflowIrForTask` returns the BUILT-IN IR for a missing + or corrupt workflow rather than throwing; without the union a degraded board resolves a satisfied set + that excludes its own terminal lanes and every dependency reads as unmet. + */ + const satisfiedColumnsByTaskId = new Map>(); + for (const task of tasks) { + const columns = new Set(["done", "in-review", "archived"]); + try { + const ir = await resolveWorkflowIrForTask(this.store, task.id, irCache); + if (ir) { + for (const flag of ["complete", "archived", "mergeOrchestration", "mergeBlocker", "humanReview"] as const) { + for (const id of columnsWithFlag(ir, flag)) columns.add(id); + } + } + } catch { /* degraded: legacy trio only */ } + satisfiedColumnsByTaskId.set(task.id, columns); + } + for (const task of schedulable) { const unmetDeps = task.dependencies.filter((depId) => { const dep = tasks.find((candidate) => candidate.id === depId); - return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; + return dep !== undefined && satisfiedColumnsByTaskId.get(dep.id)?.has(dep.column) !== true; }); if (unmetDeps.length > 0) { diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 9b498d054a..bf21e8a0c2 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -26,7 +26,7 @@ import type { ValidationDiagnostics, } from "@fusion/core"; import { MissionRemediationStoppedError, normalizeMissionAssertionType, normalizeValidationDiagnostics, renderValidationFailureDescription, - resolveTaskLifecycleColumns, + resolveTaskLifecycleColumns, resolveWorkflowIrForTask, columnsWithFlag, } from "@fusion/core"; import { GitCheckoutMaterializer, type CheckoutMaterializer, type VerificationOutcome } from "./mission-verification.js"; import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js"; @@ -1744,7 +1744,30 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`; // FNXC:MissionValidationDiagnostics 2026-07-23-13:15: A stale task ID // is not proof that remediation is live. Only an open, non-deleted task // makes duplicate triage safe to suppress; otherwise persist an action. - const hasLiveFixTask = Boolean(linkedFixTask && !linkedFixTask.deletedAt && linkedFixTask.column !== "done" && linkedFixTask.column !== "archived" && linkedFixTask.status !== "failed"); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-11:55 (batch-engine tail): + "Open" is the COMPLETE and ARCHIVED roles, not the two ids. The note above states the rule this + line implements — only an OPEN task makes duplicate triage safe to suppress — and on a renamed + board the rule inverts: a FINISHED fix task reads as live, so remediation for a fresh validation + failure is suppressed indefinitely and the mission stalls with no error surfaced. + + Resolved from the FIX TASK's own workflow (it need not share the feature's), unioned with the + legacy pair because `resolveWorkflowIrForTask` returns the BUILT-IN IR for a missing or corrupt + workflow rather than throwing — without the union a degraded board resolves a terminal set that + excludes its own terminal lanes and every fix task reads as live, which is the bug being fixed. + */ + const fixTaskTerminalColumns = new Set(["done", "archived"]); + if (linkedFixTask) { + try { + const fixIr = await resolveWorkflowIrForTask(this.taskStore, linkedFixTask.id); + if (fixIr) { + for (const flag of ["complete", "archived"] as const) { + for (const id of columnsWithFlag(fixIr, flag)) fixTaskTerminalColumns.add(id); + } + } + } catch { /* degraded: legacy pair only */ } + } + const hasLiveFixTask = Boolean(linkedFixTask && !linkedFixTask.deletedAt && !fixTaskTerminalColumns.has(linkedFixTask.column) && linkedFixTask.status !== "failed"); if (hasLiveFixTask) { loopLog.log(`Fix feature ${fixFeature.id} already has canonical task ${fixFeature.taskId}; skipping duplicate triage`); } else try { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 934148aa0d..eb08f20d02 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -5423,6 +5423,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const allTasks = await this.store.listTasks({ includeArchived: true }); const taskById = new Map(allTasks.map((task) => [task.id, task])); + + const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; const filteredScopeByTaskId = new Map(); /* @@ -5469,6 +5471,17 @@ export class SelfHealingManager extends SelfHealingGitEvidence { : false; if ( !candidates.has(taskId) + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:40 (FLAGGED AND LEFT COUNTED): + This sits in a log-dedup closure defined BEFORE the per-referenced-task lane prefetch below, so + the resolved sets are not in scope here and tsc says so. Hoisting the prefetch above the closure + is not available either — it is keyed on `candidates`, which this closure helps build. + + Left as the literal rather than restructured: the closure only decides whether to re-log an + already-logged blocker, so the degraded answer costs a duplicate log line on a renamed board, not + a wrong lifecycle decision. Restructuring a sweep's control flow to convert a logging guard is + the wrong trade. + */ || memoTask?.column !== "todo" || memoTask.status !== "queued" || memoTask.overlapBlockedBy !== lastLoggedBlockerId @@ -5478,6 +5491,55 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } } + /* + FNXC:WorkflowResolvedColumns 2026-07-31-01:30 (batch-engine — every lane question here is about ANOTHER task): + This method classifies why a BLOCKER or a DEPENDENCY is no longer blocking, and those rows routinely + belong to a different workflow than the blocked card. So lanes are resolved PER REFERENCED TASK, not + from the blocked task — the same answer main settled on for dependency satisfaction in + branch-group-ops (#2720). + + Prefetched once for the referenced ids only (blockers plus declared dependencies), through one shared + IR cache, so the predicates below stay synchronous and a board spanning three workflows reads three + IRs rather than one per row. + + Membership, and unioned with the legacy ids: `resolveWorkflowIrForTask` returns the BUILT-IN IR for a + missing or corrupt workflow instead of throwing, so without the union a degraded renamed board reads + a finished blocker as still blocking and the card stays stuck — the exact stall this sweep exists to + clear. + */ + const laneIrCache = new Map>>(); + const lanesById = new Map; archived: Set; hold: Set; review: Set }>(); + const referencedIds = new Set(); + for (const task of candidates.values()) { + if (task.blockedBy) referencedIds.add(task.blockedBy); + for (const depId of task.dependencies ?? []) referencedIds.add(depId); + referencedIds.add(task.id); + } + for (const refId of referencedIds) { + const lanes = { + complete: new Set(["done"]), + archived: new Set(["archived"]), + hold: new Set(["todo"]), + review: new Set(["in-review"]), + }; + try { + const ir = await resolveWorkflowIrForTask(this.store, refId, laneIrCache); + if (ir) { + for (const id of columnsWithFlag(ir, "complete")) lanes.complete.add(id); + for (const id of columnsWithFlag(ir, "archived")) lanes.archived.add(id); + for (const id of columnsWithFlag(ir, "hold")) lanes.hold.add(id); + for (const flag of ["mergeOrchestration", "mergeBlocker", "humanReview"] as const) { + for (const id of columnsWithFlag(ir, flag)) lanes.review.add(id); + } + } + } catch { /* degraded: legacy ids only */ } + lanesById.set(refId, lanes); + } + const lanesOf = (id: string) => lanesById.get(id) ?? { + complete: new Set(["done"]), archived: new Set(["archived"]), + hold: new Set(["todo"]), review: new Set(["in-review"]), + }; + for (const task of candidates.values()) { const blockerId = task.blockedBy; @@ -5485,7 +5547,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const dep = taskById.get(depId); // listTasks excludes soft-deleted rows, so missing dependency IDs are // treated as resolved here by design. - return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; + if (!dep || dep.deletedAt) return false; + const depLanes = lanesOf(depId); + return !depLanes.complete.has(dep.column) && !depLanes.review.has(dep.column) && !depLanes.archived.has(dep.column); }); const hasActiveOverlapBlocker = await hasActiveFileScopeOverlapBlocker(task, task.overlapBlockedBy); @@ -5515,34 +5579,34 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } else if (blocker.deletedAt) { reasonCode = "soft-deleted-blocker"; reason = `blocker ${blockerId} soft-deleted at ${blocker.deletedAt}`; - } else if (blocker.column === "done") { + } else if (lanesOf(blocker.id).complete.has(blocker.column)) { reasonCode = "blocker-done"; reason = `blocker ${blockerId} is done`; - } else if (blocker.column === "archived") { + } else if (lanesOf(blocker.id).archived.has(blocker.column)) { reasonCode = "blocker-archived"; reason = `blocker ${blockerId} is archived`; - } else if (blocker.column === "todo") { + } else if (lanesOf(blocker.id).hold.has(blocker.column)) { reasonCode = "blocker-moved-todo"; reason = `blocker ${blockerId} moved to todo`; - } else if (blocker.column === "in-review" && blocker.paused) { + } else if (lanesOf(blocker.id).review.has(blocker.column) && blocker.paused) { reasonCode = "in-review-paused"; reason = `blocker ${blockerId} in-review + paused`; } else if ( - blocker.column === "in-review" && + lanesOf(blocker.id).review.has(blocker.column) && blocker.status === "failed" && (blocker.mergeRetries ?? 0) >= maxAutoMergeRetries ) { reasonCode = "failed-retry-exhausted"; reason = `blocker ${blockerId} in-review + failed (mergeRetries ${blocker.mergeRetries ?? 0}/${maxAutoMergeRetries})`; } else if ( - blocker.column === "in-review" && + lanesOf(blocker.id).review.has(blocker.column) && blocker.status === "failed" && isMissingWorktreeSessionStartFailure(blocker.error) ) { reasonCode = "missing-worktree-session-start"; reason = `blocker ${blockerId} in-review + failed (missing-worktree session start)`; } else if ( - blocker.column === "in-review" && + lanesOf(blocker.id).review.has(blocker.column) && (blocker.status === "merging" || blocker.status === "merging-pr" || blocker.status == null) && (!activeMergeTaskId || activeMergeTaskId !== blocker.id) ) { diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index db06721419..6843ece9ec 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -194,6 +194,7 @@ import { createTaskPromptWriteTool, createWorkflowListTool, createWorkflowSelectTool, + resolveTerminalColumnsForTasks, } from "./agent-tools.js"; import { getResearchGuidanceForSurface, @@ -3181,7 +3182,11 @@ export class TriageProcessor { parameters: Type.Object({}), execute: async () => { const tasks = await store.listTasks({ slim: true, includeArchived: false }); - const active = tasks.filter((t) => t.column !== "done"); + /* FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-engine tail): triage's own copy of the + fn_task_list terminal filter — same defect, same helper. Converting one copy and leaving the + other is the Surface Enumeration failure this program keeps hitting. */ + const isTerminal = await resolveTerminalColumnsForTasks(store, tasks); + const active = tasks.filter((t) => !isTerminal(t)); if (active.length === 0) { return { content: [{ type: "text" as const, text: "No active tasks." }], @@ -3237,9 +3242,10 @@ export class TriageProcessor { limit: params.limit ?? 20, }); const includeDone = params.includeDone ?? true; + const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); const filtered = includeDone ? results - : results.filter((t) => t.column !== "done"); + : results.filter((t) => !isTerminalResult!(t)); if (filtered.length === 0) { return { content: [{ type: "text" as const, text: "No tasks matched." }], @@ -4039,9 +4045,13 @@ export class TriageProcessor { } const nowMs = Date.now(); - const candidates = (await this.store.listTasks({ slim: false, includeArchived: false })) + const listed = await this.store.listTasks({ slim: false, includeArchived: false }); + /* FNXC:WorkflowResolvedColumns 2026-07-30-11:30 (batch-engine tail): a FINISHED card passed this + dedup filter on a renamed board, so completed work was offered as a duplicate candidate. */ + const isTerminalCandidate = await resolveTerminalColumnsForTasks(this.store, listed); + const candidates = listed .filter((candidate) => candidate.id !== task.id) - .filter((candidate) => candidate.column !== "done") + .filter((candidate) => !isTerminalCandidate(candidate)) .filter((candidate) => Date.parse(candidate.createdAt) >= nowMs - 7 * 24 * 60 * 60 * 1000) .map((candidate) => ({ id: candidate.id, diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 479d4ccb3d..ebf57d2eb0 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -3,7 +3,7 @@ import { promisify } from "node:util"; import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path"; -import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; +import type { SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js"; import { worktreePoolLog } from "./logger.js"; /* @@ -25,6 +25,7 @@ import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import type { RunAuditor } from "./run-audit.js"; import { pruneWorktreeAdminEntries } from "./worktree-prune.js"; +import { resolveWorkflowIrForTask, columnsWithFlag } from "@fusion/core"; export { NativeWorktreeBackend, @@ -1173,7 +1174,6 @@ export async function reapOrphanWorktrees( } /** Columns where merger/finalization owns branch lifecycle. */ -const MERGER_MANAGED_COLUMNS: ReadonlySet = new Set(["in-review", "done"]); /** * Return local `fusion/*` branches not associated with any active task. @@ -1200,10 +1200,34 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P if (allBranches.length === 0) return []; const tasks = await store.listTasks({ slim: true, includeArchived: false }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-08:20 (batch-engine — census-invisible membership, #2763 class): + A branch is "active" (and so must not be reclaimed) unless the merger owns the card or it is archived. + Both tests were hardcoded, so on a renamed board a card in review or complete was NOT recognised as + merger-managed and its branch was treated as reclaimable — deleting a branch out from under an in-flight + merge. One IR cache for the pass; the predicates below stay synchronous. + */ + const poolIrCache = new Map>>(); + const poolLanes = new Map; archived: Set }>(); + for (const task of tasks) { + if (poolLanes.has(task.id)) continue; + const managed = new Set(["in-review", "done"]); + const archived = new Set(["archived"]); + try { + const ir = await resolveWorkflowIrForTask(store, task.id, poolIrCache); + if (ir) { + for (const flag of ["mergeOrchestration", "mergeBlocker", "humanReview", "complete"] as const) { + for (const id of columnsWithFlag(ir, flag)) managed.add(id); + } + for (const id of columnsWithFlag(ir, "archived")) archived.add(id); + } + } catch { /* degraded: legacy ids */ } + poolLanes.set(task.id, { managed, archived }); + } const activeBranches = new Set(); for (const task of tasks) { - if (MERGER_MANAGED_COLUMNS.has(task.column)) continue; - if (task.column === "archived") continue; + if (poolLanes.get(task.id)?.managed.has(task.column) === true) continue; + if (poolLanes.get(task.id)?.archived.has(task.column) === true) continue; if (task.branch) activeBranches.add(task.branch); activeBranches.add(canonicalFusionBranchName(task.id)); } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index ada5cc2dbf..9065615db3 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,19 +1,17 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "byFile": { - "packages/engine/src/self-healing.ts": 107, - "packages/engine/src/executor.ts": 15, + "packages/engine/src/self-healing.ts": 97, + "packages/engine/src/executor.ts": 12, "packages/engine/src/scheduler.ts": 12, "packages/core/src/task-store/async-comments-attachments.ts": 9, "packages/dashboard/app/components/TaskContextMenu.tsx": 9, "packages/dashboard/app/components/Column.tsx": 7, "packages/dashboard/app/components/ListView.tsx": 6, - "packages/engine/src/agent-tools.ts": 5, "packages/engine/src/notification/notification-service.ts": 5, "packages/engine/src/restart-recovery-coordinator.ts": 5, "packages/dashboard/app/components/TaskDetailModal.tsx": 4, "packages/engine/src/replan-target.ts": 4, - "packages/engine/src/triage.ts": 4, "packages/core/src/async-mission-store-queries.ts": 3, "packages/core/src/task-store/async-merge-coordination.ts": 3, "packages/core/src/task-store/task-artifacts-ops.ts": 3, @@ -26,9 +24,7 @@ "packages/dashboard/app/utils/worktreeGrouping.ts": 3, "packages/dashboard/src/chat.ts": 3, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3, - "packages/engine/src/gridlock-detector.ts": 3, "packages/engine/src/planner-overseer.ts": 3, - "packages/engine/src/worktree-pool.ts": 3, "packages/core/src/agent-store.ts": 2, "packages/core/src/async-mission-store.ts": 2, "packages/core/src/node-override-guard.ts": 2, @@ -53,7 +49,7 @@ "packages/engine/src/auto-merge-finalization.ts": 2, "packages/engine/src/cli-agent/state-machine.ts": 2, "packages/engine/src/merger-scope-auto-widen.ts": 2, - "packages/engine/src/mission-execution-loop.ts": 2, + "packages/engine/src/worktree-pool.ts": 2, "packages/core/src/eval-automation.ts": 1, "packages/core/src/eval-signal-collector.ts": 1, "packages/core/src/in-review-stall.ts": 1, @@ -100,7 +96,8 @@ "packages/engine/src/merger.ts": 1, "packages/engine/src/plugin-runner.ts": 1, "packages/engine/src/pr-comment-handler.ts": 1, - "packages/engine/src/runtimes/in-process-runtime.ts": 1 + "packages/engine/src/runtimes/in-process-runtime.ts": 1, + "packages/engine/src/triage.ts": 1 }, "deliberateByFile": { "packages/dashboard/src/reliability-metrics.ts\u0000in-review": 4,