batch-engine tail: re-land the ASYNC half; the sync-resolved half was inert (engine −15) (#2785)

Tail of `batch-engine` (#2773). That PR merged as a squash while later
engine work was still in flight, so `self-healing.ts`, `executor.ts` and
`worktree-pool.ts` landed at their pre-conversion counts. This re-lands
**only the half that is real**, and the reason the other half is not
here is the substance of this PR.

## Census, per file (measured, `--strict` verified)

| file | main | here |
| --- | ---: | ---: |
| `engine/src/self-healing.ts` | 107 | 97 |
| `engine/src/executor.ts` | 15 | 12 |
| `engine/src/worktree-pool.ts` | 3 | 2 |
| `engine/src/ephemeral-worker-manager.ts` | 1 | 0 |
| `engine/src/agent-tools.ts` | 5 | **0** |
| `engine/src/gridlock-detector.ts` | 3 | **0** |
| `engine/src/triage.ts` | 4 | 1 |
| `engine/src/mission-execution-loop.ts` | 2 | **0** |
| **net** | | **−28** |

Baseline re-recorded; `--strict` tightened exactly these 4 entries and
no others.

## Finding: a whole class of conversions in this program is INERT, and
the census scores it as progress

`resolveTaskWorkflowIrSync` returns the **default** workflow IR for
every task in production. The sync selection reader behind it is a
PostgreSQL-cutover stub:

```ts
// packages/core/src/task-store/workflow-definitions.ts:505
export function getTaskWorkflowSelectionImpl(_store, _taskId) {
  return undefined;   // "Backend mode cannot synchronously read PostgreSQL"
}
```

So a guard written as
`resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(id))?.hold`
resolves an IR, asks for a trait, and answers **from the default
workflow for every custom board** — silently. It reads as converted and
the census counts it as converted. `main` gained
`sync-workflow-ir-callsite-allowlist.test.ts` for exactly this after my
branch point; it is what caught me.

I had built three sync resolvers on that reader — `resolveMoveLanesSync`
(self-healing, executor) and a widened `resolveTaskParkedColumnsSync`
(scheduler) — reasoning that a *synchronous* `task:moved` listener needs
a *synchronous* reader. That reasoning was sound about the shape and
never checked whether the reader reads anything.

**Dropped from this PR, deliberately, and NOT re-landed anywhere:**

- `scheduler.ts` 12 → 1 (the widening; the pre-existing narrow helper on
main is untouched)
- the executor `task:moved` handler, incl. the Move-Task hard-cancel
lane comparison
- self-healing's `task:moved` fan-out,
`classifyPausedAbortWorkflowRecovery`, `reconcileInReviewBranchRebind`,
`recoverWedgedActiveMerge`, `recoverPausedAbortFailures`, and 12
single-row lane conversions

Those sites are back to their literals. The allow-list's own guidance is
the standard I applied:

> An unconverted `=== "todo"` is strictly better, because it is at least
honest about being a literal.

I did not add my call sites to the allow-list. Six entries would have
turned the gate green in two minutes and buried the defect; the list's
contract requires proving the async resolver is genuinely unreachable,
and for a fire-and-forget listener it is not — the listener can `void`
an async lane resolution the same way `NotificationService` already
does. That is the correct fix and it is a behaviour-shaped change, so it
is out of scope here.

**Fleet-wide consequence:** any conversion routed through
`resolveTaskWorkflowIrSync` is fake progress, and the census cannot see
the difference. `pnpm test:gate` can: the allow-list test is the
detector. Its passing here (161/161) is this PR's evidence that nothing
inert survived the split.

## What IS in this PR — all async-resolved

1. **`self-healing.clearStaleBlockedBy`** — lanes resolved per
**REFERENCED** task, not per iterated task. A blocker's own workflow
decides whether it is still blocking.
2. **`executor` dependency satisfaction** — resolved per **DEPENDENCY**
via `columnsWithFlag`. Preserves the load-bearing asymmetry that a
dependency in *review* already satisfies a dependent; a bulk sweep
flattens that to complete-only and deadlocks the board.
3. **`agent-tools` — the agent task tools listed FINISHED cards as
active.** `fn_task_list` says it lists "tasks that aren't done or
archived"; `fn_task_search` offers `includeDone: false`. Both filtered
on `task.column !== "done"`, so a renamed complete lane returned
finished cards as outstanding work **to an agent**, which then reasons
and acts on them. `includeArchived` was always enforced by the QUERY and
survived a rename; `"done"` was only ever a TS predicate, which is why
exactly that half broke.

Plus the two **dedup** guards in the same file. The cross-parent
diagnostic filter kept a *shipped* card as a candidate on a renamed
board, so the guard adopted it as canonical and returned `wasDuplicate:
true` — absorbing new diagnostic work into a task nobody is working on
(the eval-followup defect shape again). The defined-feature bootstrap
preflight is **not** the query-filter class: its query passes
`includeArchived: true`, so the TS predicate is the *only* archived
guard there; 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.

Both dedup invariants **already had tests** — asserted against the
legacy ids only, so both passed for the very comparison being replaced.
Extended in place into vocabulary differentials rather than added as
parallel files. Two helpers rather than one parameterised one: "is this
finished?" and "is this archived?" are different questions, and merging
them would make the archived-only guard also reject completed rows.

The list/search half re-landed **with the test it originally shipped
without.** No suite exercised either tool, so the original commit's
"304/304 green" said nothing about the change — the optional-flags
failure mode exactly. Both call sites are covered; converting two copies
and testing one is the Surface Enumeration failure this program has
already hit twice.

4. **`gridlock-detector` — FALSE dependency alarms.** The gate compared
each blocker against `done`/`in-review`/`archived`; on a renamed board
all three are true for a *finished* blocker, so no dependency ever
counted as met and the detector reported dependency gridlock for tasks
that are not blocked — `notifyGridlock` then pages the operator.
Resolved per dependency using the **same five flags** as the executor's
gate (`complete`, `archived`, `mergeOrchestration`, `mergeBlocker`,
`humanReview`) — `review` is not a trait, and two gates answering "is
this dependency satisfied?" differently is a split brain. Every
pre-existing case in that file omits a workflow, so none could detect
the change; added the renamed case plus a non-vacuous companion.

5. **`triage` — its OWN copies of the same two tools.**
`createTriageTools` carries a `fn_task_list` and `fn_task_search`
byte-identical in intent to the agent-tools pair, plus a third site
filtering duplicate candidates. Same defect on all three. Reused the
(now exported) agent-tools helper rather than adding a third copy —
deliberately stronger than the two-parallel-tests reading of Surface
Enumeration, since the copies now share one implementation and cannot
drift. **Not claiming call-site coverage:** `createTriageTools` is
private and not drivable without standing up a TriageAgent; the helper
is revert-proofed, those two call sites are covered only through it.

6. **`mission-execution-loop` — a finished fix task read as LIVE,
stalling remediation.** The comment above that line states the rule it
implements: *only an open task makes duplicate triage safe to suppress.*
On a renamed board the rule inverts — a finished fix task is not
`done`/`archived`, so it reads as live, remediation for a fresh
validation failure is suppressed indefinitely, and the mission stalls
with no error surfaced.

**Not revert-proven, and I am not claiming it is.** No test reaches the
`hasLiveFixTask` branch, and the only case that mints a fix feature is
git-gated and heavyweight; building that fixture is larger than the
conversion. The change strictly *widens* the finished set (resolved
roles ∪ the two legacy ids), so default boards are byte-identical — that
is the argument for shipping it unproven, not a substitute for coverage.

7. **Four census-invisible membership guards**, each inverted on a
renamed board — `worktree-pool` (merger-managed branch reclaim could
delete a branch out from under an in-flight merge), `agent-assignment`
(assignment load counted nothing), `ephemeral-worker-manager`
(`isAgentIdle` inverted on both sides), and the dead constants their
conversion orphaned. These are `SET.has(task.column)` shapes the census
does not count, so the −15 understates them.

## Revert results (measured, each run)

| conversion | reverted → |
| --- | --- |
| `clearStaleBlockedBy` per-referenced lanes | renamed-vocabulary case
fails; stale `blockedBy` never clears |
| executor dependency satisfaction | dependent never unblocks on a
renamed review lane |
| `worktree-pool` merger-managed set | reclaim proceeds against an
in-flight merge |
| `ephemeral-worker-manager.isAgentIdle` | idle agent reads busy on a
renamed board |
| `fn_task_list` terminal filter | RENAMED case fails — shipped card
listed as active |
| `fn_task_search` terminal filter | RENAMED case fails — same,
independently |
| cross-parent diagnostic dedup | RENAMED case fails — `wasDuplicate:
true`, new work absorbed |
| bootstrap preflight archived guard | RENAMED case fails — `validate`
called with the archived sibling |
| gridlock dependency gate | RENAMED case fails — false gridlock raised
for an unblocked task |

`agent-assignment`'s widened `taskStore` type is compile-time; its
revert is a tsc failure, not a test failure — stated rather than claimed
as coverage.

## Verification

- `pnpm test:gate` — 161 + 487 + 13 + 71, all green (161 includes
`sync-workflow-ir-callsite-allowlist`)
- `npx tsc -p packages/engine/tsconfig.json --noEmit` — clean
- `pnpm lint` — clean

One commit is a pure import restore: `columnsWithFlag` arrived in a
sibling commit that built on the inert resolver and was left behind. The
engine tsconfig excludes `src/__tests__/**`, so the gate was green while
tsc was not — worth knowing that on this package a green gate is not a
green build.


## Verified NOT a gap — measured, so the next worker does not re-open
them

- **`restart-recovery-coordinator` (5 counted).** Four already take an
optional `reviewColumns` set and the counted literals are the documented
**fallback** arm, which must stay for the same reason `columnRoles.ts`
keeps its id fallback. The sole production caller
(`self-healing.ts:12151-12154`) already passes the resolved set. The
fifth is documented at the site as a re-assertion behind a `listTasks({
column: "in-progress" })` query filter. Nothing to convert.
- **`notification/notification-service` (5 counted).** Already
documented in-file as deliberately counted with no exemption marker: the
wedge-episode site needs per-task serialisation of wedge handling (a
delivery-semantics change to operator notifications), and
`isManualMergeHold` needs a pre-resolved `LifecycleColumns` threaded
through `handleTaskUpdated`, which would pay resolution on every task
update. Both are behaviour/placement judgements, not conversions.
- **`planner-overseer` (3 counted).** `resolveWatchedStage`'s two
literals are fed by `pollPlannerOverseer`, which calls `listTasks({
column: "in-progress" })` and `{ column: "in-review" }` — hardcoded
**query** filters. On a renamed board those queries return no rows, so
the predicate never sees a renamed column. Converting it alone would
drop 3 from the census and change nothing an operator can observe. The
real fix is at the query layer; that is the tracked query-filter-bounded
class, not this PR.
- **`triage:695`** reads `resolvePlannerLanes` → the allow-listed sync
IR reader. Left as an honest literal per the rule above.

**Still open in `packages/engine`, deliberately not in this PR:**
`self-healing.ts` (97, of which ~31 are the query-filter-bounded class
and the rest need per-site classification in a 13k-line file),
`scheduler.ts` (12, blocked on the sync reader above), `executor.ts`
(12), and a tail of ~13 more copies of the "is this task finished?"
question across eight small files (`agent-reflection`,
`auto-merge-finalization`, `merger-scope-auto-widen`,
`backlog-pressure-reporter`, `merger-orphan-rehome`,
`merger-integration-worktree`, `plugin-runner`, `cli-agent/*`). That
tail is a clean follow-up: one question, eight call sites, and the
exported `resolveTerminalColumnsForTasks` helper already exists for it.

That is the same discipline as the sync-resolver finding: a census
number that drops without a behaviour change is not progress, and four
of these files would have handed over exactly that.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 10:47:44 -07:00
committed by GitHub
parent 4184fde08d
commit 90f6319b79
11 changed files with 615 additions and 83 deletions

View File

@@ -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> = {}): 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<void> });
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<void> });
/* 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[] = [];

View File

@@ -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<readonly [string, Vocabulary]> = [
["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");
});
});

View File

@@ -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> = {}): 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" }),

View File

@@ -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<string, Awaited<ReturnType<typeof fusionCore.resolveWorkflowIrForTask>>>();
const terminalByTaskId = new Map<string, ReadonlySet<string>>();
for (const task of tasks) {
if (terminalByTaskId.has(task.id)) continue;
const columns = new Set<string>(["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<string, Awaited<ReturnType<typeof fusionCore.resolveWorkflowIrForTask>>>();
const archivedByTaskId = new Map<string, ReadonlySet<string>>();
for (const task of tasks) {
if (archivedByTaskId.has(task.id)) continue;
const columns = new Set<string>(["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] : [],

View File

@@ -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<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const satisfiedByDep = new Map<string, ReadonlySet<string>>();
for (const depId of task.dependencies) {
if (satisfiedByDep.has(depId)) continue;
const satisfied = new Set<string>(["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) {

View File

@@ -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<string, "dependency" | "overlap"> = {};
const blockingTaskIds = new Set<string>();
/*
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<string, ReadonlySet<string>>();
for (const task of tasks) {
const columns = new Set<string>(["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) {

View File

@@ -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<string>(["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 {

View File

@@ -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<string, string[]>();
/*
@@ -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<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const lanesById = new Map<string, { complete: Set<string>; archived: Set<string>; hold: Set<string>; review: Set<string> }>();
const referencedIds = new Set<string>();
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<string>(["done"]),
archived: new Set<string>(["archived"]),
hold: new Set<string>(["todo"]),
review: new Set<string>(["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)
) {

View File

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

View File

@@ -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<ColumnId> = new Set<ColumnId>(["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<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
const poolLanes = new Map<string, { managed: Set<string>; archived: Set<string> }>();
for (const task of tasks) {
if (poolLanes.has(task.id)) continue;
const managed = new Set<string>(["in-review", "done"]);
const archived = new Set<string>(["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<string>();
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));
}

View File

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