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:
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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] : [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user