fix(engine,core): role resolution was half-done in two shared lifecycle predicates (surfacing family + file-scope leases) (#2975)

The three surfacing sweeps stopped reporting anything for a card resting
in a board's **second** review or hold column.

A lifecycle role is a **trait**, and any number of columns may carry it.
The shared runner resolved it with `resolveLifecycleColumns()[role]` —
**first match** — then gated on it:

```ts
const roleColumn = lifecycle?.[spec.role];        // FIRST column carrying the trait
if (task.column !== resolved.roleColumn) continue; // everything else dropped
```

A workflow that splits human sign-off from the merge lane has two review
columns; one that parks dependency-blocked cards separately has two hold
columns. Cards in the second got **no stale-paused-todo, no
stale-paused-review, no in-review-stalled** diagnostic — silently, with
no error, on all three sweeps at once.

## The second bug hiding inside the fix for the first

Resolving membership but still reading `roleColumns[0]`'s declared
`recovery` applies the **merge lane's** threshold to a card sitting in
the **sign-off** lane. Each card's policy now comes from its own column,
and one of the new cases fails if it doesn't: the first role column
declares a policy that suppresses the signal, the card's own column
declares one that fires.

## Reverted

| | |
|---|---|
| **6 of 12** new cases fail | `fires for a card in the SECOND column
carrying its role` and `reads the recovery policy of the card's OWN role
column` — × 3 sweeps |
| the other 6 pass either way | non-regression halves: still fires for
the FIRST role column, still does **not** fire for a card outside every
role column. Membership must widen the gate, not move it. |

The pre-existing 45 cases were all green throughout — the
single-role-column fixture could not express the case, which is why the
table-driven file that exists to stop these three sweeps drifting apart
never caught it.

## Verification

`pnpm test:gate` 161 + 13 + 487 + 71 · surfacing family 57 · core
stale-paused 20 · lint · census `--strict` · sql-literals · fnxc-dates ·
lane-wiring · changesets — all green.

## Note

`holdColumns` was missing from the lane-wiring vocabulary, so the gate
could not see that argument dropped. Added in the same commit.

While reviewing, I found and measured **two problems in #2974** (comment
posted there): six of its newly-visible sites are `satisfies`-wrapped
false positives, and baselining them means deleting a real
`reviewColumns` argument keeps the count unchanged and the gate green;
and its baseline predates #2970, re-opening the slot that PR closed.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved stale-card detection across all applicable review and hold
columns.
* Cards are now surfaced using the policies configured for their
specific lifecycle column.
* Cards outside matching lifecycle columns are no longer incorrectly
surfaced.
* Preserved existing fallback behavior when no lifecycle columns are
configured.

* **Tests**
  * Added coverage for workflows with split review and hold columns.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Second commit: the same predicate, half-converted
(`shouldHoldActiveFileScopeLease`)

Folded in here rather than stacked — same file, same class, and a
stacked PR on an unmerged base is not mergeable. Reversible; say the
word and I'll split it.

`shouldHoldActiveFileScopeLease` is the **scheduler's** lease predicate,
shared with the self-healing repair paths deliberately so the two cannot
disagree about who holds a file-scope lease. Its two role answers are
optional parameters defaulting to the legacy ids. The scheduler's own
call sites were converted to pass resolved answers; self-healing's two
were not:

```ts
const isWipColumn    = options?.isWipColumn    ?? task.column === "in-progress";
const isReviewColumn = options?.isReviewColumn ?? task.column === "in-review";
```

On a renamed board neither branch matches, so the predicate returns
`false` for every card. The scheduler kept the lease; self-healing saw
none, cleared `overlapBlockedBy`, and **released a dependent to edit
files another agent still holds** — the outcome `groupOverlappingFiles`
exists to prevent.

Membership comes from the wip/review sets each sweep already resolved a
few lines above, so this adds no reads.

**Reverted:** both new cases fail with `overlapBlockedBy` = `null` — the
release itself, not a proxy. The pre-existing legacy-column case in the
same file passes either way, because `in-progress` satisfies the literal
default; that is exactly why it never caught this.

Lane-wiring baseline re-recorded `9 -> 7` in the same commit (the
ratchet refused a stale allowance, as intended).

**Verification:** gate 161 + 13 + 487 + 71 · surfacing 57 · overlap-seam
+ scheduler-lease + query-blindness 79 · core stale-paused 20 · lint ·
census `--strict` · sql-literals · fnxc-dates · changesets — green.
This commit is contained in:
gsxdsm
2026-07-30 23:13:34 -07:00
committed by GitHub
parent 2411699756
commit 16921fc518
9 changed files with 317 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stale-dependency cleanup no longer releases a task to edit files another agent still holds, on renamed boards.
category: fix
dev: The two `shouldHoldActiveFileScopeLease` call sites in self-healing now pass resolved `isWipColumn`/`isReviewColumn` from the wip/review sets those sweeps already resolve, matching the scheduler's own call sites.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stale-card diagnostics now cover every review and hold column, not just the first of each.
category: fix
dev: `runSurfacingSweep`'s role gate resolves membership (`resolveReviewColumns` / `columnsWithFlag(ir,"hold")`) instead of `resolveLifecycleColumns()[role]`; signals receive the column SET, and each card's recovery policy is read from its own column. Adds `StalePausedTodoContext.holdColumns`.

View File

@@ -26,6 +26,16 @@ export interface StalePausedTodoContext {
`resolveLifecycleColumns(ir).hold` instead.
*/
holdColumn?: string;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the lane seam, MEMBERSHIP not one column):
`holdColumn` is `resolveLifecycleColumns().hold` — the FIRST column carrying the hold trait. A
board declaring more than one hold lane (a capacity wait beside a blocked-on-dependency park) has
several, and a card resting in the second read as not-on-hold and was never surfaced.
Mirrors `reviewColumns` in in-review-stalled.ts / stale-paused-review.ts. Optional, with today's
behaviour preserved as the fallback, so a caller that does not pass it is byte-identical.
*/
holdColumns?: ReadonlySet<string>;
now?: number;
thresholdMs?: number;
engineActiveSinceMs?: number;
@@ -38,8 +48,11 @@ export function getStalePausedTodoSignal(
task: Pick<Task, "column" | "paused" | "columnMovedAt" | "updatedAt" | "pausedReason" | "pausedByAgentId">,
context: StalePausedTodoContext = {},
): StalePausedTodoSignal | undefined {
const holdColumn = context.holdColumn ?? "todo";
if (task.column !== holdColumn || task.paused !== true) return undefined;
const onHoldLane = context.holdColumns
? context.holdColumns.has(task.column)
/* DELIBERATE-LITERAL — the no-metadata fallback; a supplied set always wins. */
: task.column === (context.holdColumn ?? "todo");
if (!onHoldLane || task.paused !== true) return undefined;
const thresholdMs = context.thresholdMs ?? DEFAULT_STALE_PAUSED_TODO_THRESHOLD_MS;
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return undefined;

View File

@@ -135,3 +135,120 @@ describe("SelfHealingManager fake TaskStore overlap seam", () => {
manager.stop();
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:25 (the shared lease predicate was HALF-converted):
`shouldHoldActiveFileScopeLease` is the scheduler's predicate, shared with self-healing on purpose so
the two cannot disagree about who holds a file-scope lease. Its role answers are optional parameters
defaulting to the legacy ids; the scheduler passes resolved answers and this sweep did not, so on a
renamed board the scheduler kept a lease that this sweep saw as absent — and released a dependent to
edit files another agent still holds.
The board below is renamed but otherwise identical to the legacy case above, which is the point: the
existing test passes either way because `in-progress` satisfies the literal default.
*/
const RENAMED_BOARD_IR = {
version: "v2",
id: "custom:renamed",
nodes: [],
edges: [],
columns: [
{ id: "drafting", name: "drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "checking", name: "checking", traits: [{ trait: "merge" }] },
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
],
};
describe("SelfHealingManager stale-blocker cleanup on a RENAMED board", () => {
function createRenamedBoardStore(seed: Task[]) {
const tasks = new Map(seed.map((task) => [task.id, task]));
const settings = {
globalPause: false,
enginePaused: false,
mergeRequestContractShadowEnabled: true,
} as Settings;
const store = {
getSettings: vi.fn().mockResolvedValue(settings),
listTasks: vi.fn().mockImplementation(async (opts?: { column?: Task["column"] }) => {
const all = [...tasks.values()];
return opts?.column ? all.filter((task) => task.column === opts.column) : all;
}),
getTask: vi.fn().mockImplementation(async (id: string) => tasks.get(id) ?? null),
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<Task>) => {
const current = tasks.get(id);
if (!current) throw new Error(`Task ${id} missing`);
const next = { ...current, ...patch } as Task;
tasks.set(id, next);
return next;
}),
logEntry: vi.fn().mockResolvedValue(undefined),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue(["packages/engine/src/self-healing.ts"]),
getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null),
listWorkflowDefinitions: vi.fn().mockResolvedValue([{ ir: RENAMED_BOARD_IR }]),
/* A real renamed board has a SELECTION; without it every card resolves to the built-in
workflow and `shipped` is not recognised as complete, so the sweep finds nothing to do. */
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: RENAMED_BOARD_IR.id, stepIds: [] })),
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: RENAMED_BOARD_IR.id, stepIds: [] })),
getWorkflowDefinition: vi.fn(async () => ({ ir: RENAMED_BOARD_IR })),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
return { store, tasks };
}
it("preserves an overlap blocker resting in a RENAMED wip column", async () => {
const staleBlocker = makeTask("FN-DONE-BLOCKER", { column: "shipped" });
const overlapBlocker = makeTask("FN-ACTIVE-OVERLAP", { column: "building" });
const dependent = makeTask("FN-DEPENDENT", {
column: "drafting",
status: "queued",
blockedBy: staleBlocker.id,
overlapBlockedBy: overlapBlocker.id,
dependencies: [staleBlocker.id],
});
const { store, tasks } = createRenamedBoardStore([staleBlocker, overlapBlocker, dependent]);
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
getExecutingTaskIds: () => new Set<string>(),
});
await expect(manager.clearStaleBlockedBy()).resolves.toBe(1);
/* The lease is still held, so the overlap blocker survives the stale-dependency clear. */
expect(tasks.get(dependent.id)?.overlapBlockedBy).toBe(overlapBlocker.id);
expect(store.logEntry).toHaveBeenCalledWith(
dependent.id,
expect.stringContaining(`still blocked by file scope overlap with ${overlapBlocker.id}`),
);
manager.stop();
});
it("preserves an overlap blocker resting in a RENAMED review column", async () => {
/* The review half of the predicate, which takes a different branch (worktree + status). */
const staleBlocker = makeTask("FN-DONE-BLOCKER", { column: "shipped" });
const overlapBlocker = makeTask("FN-ACTIVE-OVERLAP", {
column: "checking",
worktree: "/tmp/wt-active",
});
const dependent = makeTask("FN-DEPENDENT", {
column: "drafting",
status: "queued",
blockedBy: staleBlocker.id,
overlapBlockedBy: overlapBlocker.id,
dependencies: [staleBlocker.id],
});
const { store, tasks } = createRenamedBoardStore([staleBlocker, overlapBlocker, dependent]);
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
getExecutingTaskIds: () => new Set<string>(),
});
await expect(manager.clearStaleBlockedBy()).resolves.toBe(1);
expect(tasks.get(dependent.id)?.overlapBlockedBy).toBe(overlapBlocker.id);
manager.stop();
});
});

View File

@@ -32,6 +32,8 @@ const FAMILY = [
role: "hold" as const,
column: "todo",
renamedColumn: "drafting",
/* The SECOND column carrying this row's role in `irSplitRole`. */
splitColumn: "blocked",
task: (over: Partial<Task> = {}) => ({ paused: true, pausedReason: "manual-hold", ...over }),
},
{
@@ -41,6 +43,7 @@ const FAMILY = [
role: "review" as const,
column: "in-review",
renamedColumn: "checking",
splitColumn: "signoff",
task: (over: Partial<Task> = {}) => ({ paused: true, pausedReason: "manual-hold", ...over }),
},
{
@@ -50,6 +53,7 @@ const FAMILY = [
role: "review" as const,
column: "in-review",
renamedColumn: "checking",
splitColumn: "signoff",
// This one watches ACTIVE review work, so the card must NOT be paused.
task: (over: Partial<Task> = {}) => ({ paused: false, ...over }),
},
@@ -92,6 +96,29 @@ function ir(hold: string, review: string): WorkflowIr {
} as unknown as WorkflowIr;
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:50 (a role is a TRAIT, so several columns may carry it):
A board that parks dependency-blocked cards in their own hold lane, and splits human sign-off from
the merge lane, carries each role TWICE. `resolveLifecycleColumns()[role]` answers with the first
only, which is what made the surfacing family skip every card resting in the second.
*/
function irSplitRole(): WorkflowIr {
return {
version: "v2",
id: WF,
nodes: [],
edges: [],
columns: [
{ id: "todo", name: "todo", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "blocked", name: "blocked", traits: [{ trait: "hold", config: { release: "dependency" } }] },
{ id: "building", name: "building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "in-review", name: "in-review", traits: [{ trait: "merge" }] },
{ id: "signoff", name: "signoff", traits: [{ trait: "human-review" }] },
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
function harness(tasks: Task[], settings: Record<string, unknown>, workflowIr: WorkflowIr) {
/* Appends to the card's log, because the at-most-once dedup READS that log —
a mock that only records calls cannot observe suppression at all. */
@@ -190,6 +217,71 @@ describe("surfacing family — shared invariants (one row per sweep)", () => {
expect(await run(h.manager)).toBe(0);
});
it("fires for a card in the SECOND column carrying its role", async () => {
/*
The defect this replaces: the role gate compared `task.column` against the FIRST column
carrying the role, so a card resting in a board's second hold/review lane was skipped
entirely — no diagnostic, no error, and every other assertion in this file still green
because the single-role-column fixture could not express the case.
*/
const h = harness(
[makeTask(spec.splitColumn, spec.task())],
{ [spec.thresholdKey]: CUSTOMIZED_MS },
irSplitRole(),
);
expect(await run(h.manager)).toBe(1);
expect(h.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining(spec.logPrefix));
});
it("still fires for a card in the FIRST role column when the role is split", async () => {
/* Membership must WIDEN the gate, not move it. */
const h = harness(
[makeTask(spec.column, spec.task())],
{ [spec.thresholdKey]: CUSTOMIZED_MS },
irSplitRole(),
);
expect(await run(h.manager)).toBe(1);
});
it("reads the recovery policy of the card's OWN role column, not the first one", async () => {
/*
A second first-match bug hides inside the fix for the first: resolving membership but still
reading `roleColumns[0]`'s declared policy applies the merge lane's threshold to a card in the
sign-off lane. Here the FIRST role column declares a policy that would suppress the signal and
the card's own column declares one that fires — so reading the wrong column returns 0.
*/
const workflow = irSplitRole() as unknown as { columns: Array<{ id: string; recovery?: unknown }> };
workflow.columns.find((c) => c.id === spec.column)!.recovery = {
stalenessMs: BUILTIN_DEFAULT_MS,
onStale: { action: "surface", code: spec.logPrefix },
};
workflow.columns.find((c) => c.id === spec.splitColumn)!.recovery = {
stalenessMs: CUSTOMIZED_MS,
onStale: { action: "surface", code: spec.logPrefix },
};
const h = harness(
[makeTask(spec.splitColumn, spec.task())],
{ [spec.thresholdKey]: BUILTIN_DEFAULT_MS },
workflow as unknown as WorkflowIr,
);
expect(await run(h.manager)).toBe(1);
});
it("does NOT fire for a card outside every column carrying its role", async () => {
/* Membership must still narrow: `building` carries neither role. */
const h = harness(
[makeTask("building", spec.task())],
{ [spec.thresholdKey]: CUSTOMIZED_MS },
irSplitRole(),
);
expect(await run(h.manager)).toBe(0);
});
it("is AT-MOST-ONCE: a card already surfaced inside the window is not re-reported", async () => {
/* The safeguard that lives outside the policy table. Without it a stale
card is re-reported every poll, which trains operators to ignore the log. */

View File

@@ -4735,11 +4735,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const hasActiveFileScopeOverlapBlocker = async (dependent: Task, blockerId: string | null | undefined): Promise<boolean> => {
if (!blockerId) return false;
const blocker = taskById.get(blockerId);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:20 (shared predicate, HALF-converted):
`shouldHoldActiveFileScopeLease` is the scheduler's lease predicate, shared with this path on
purpose so stale-blocker cleanup cannot preserve blockers the scheduler would ignore — or, as
here, RELEASE blockers the scheduler still honours. Its two role answers are optional
parameters defaulting to the legacy ids; the scheduler's own call sites pass resolved answers
and these did not, so on a renamed board the two disagreed: the scheduler kept the lease while
this sweep saw `false` for every card, cleared `overlapBlockedBy`, and released a dependent to
edit files another agent still holds. Membership comes from the sets this sweep already
resolved a few lines above.
*/
if (!blocker || !shouldHoldActiveFileScopeLease(blocker, allTasks, {
mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled,
handoffAccepted: settings.mergeRequestContractShadowEnabled === true
? (await this.store.getCompletionHandoffAcceptedMarker(blocker.id)) !== null
: false,
isWipColumn: completedWipColumns.has(blocker.column),
isReviewColumn: completedReviewColumns.has(blocker.column),
})) return false;
const dependentScope = await getFilteredFileScope(dependent.id);
if (dependentScope.length === 0 || isCoordinationOnlyTask(dependent, dependentScope)) return false;
@@ -5793,11 +5806,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const hasActiveFileScopeOverlapBlocker = async (task: Task, blockerId: string | null | undefined): Promise<boolean> => {
if (!blockerId) return false;
const blocker = taskById.get(blockerId);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-22:20 (shared predicate, HALF-converted):
`shouldHoldActiveFileScopeLease` is the scheduler's lease predicate, shared with this path on
purpose so stale-blocker cleanup cannot preserve blockers the scheduler would ignore — or, as
here, RELEASE blockers the scheduler still honours. Its two role answers are optional
parameters defaulting to the legacy ids; the scheduler's own call sites pass resolved answers
and these did not, so on a renamed board the two disagreed: the scheduler kept the lease while
this sweep saw `false` for every card, cleared `overlapBlockedBy`, and released a dependent to
edit files another agent still holds. Membership comes from the sets this sweep already
resolved a few lines above.
*/
if (!blocker || !shouldHoldActiveFileScopeLease(blocker, allTasks, {
mergeRequestContractShadowEnabled: settings.mergeRequestContractShadowEnabled,
handoffAccepted: settings.mergeRequestContractShadowEnabled === true
? (await this.store.getCompletionHandoffAcceptedMarker(blocker.id)) !== null
: false,
isWipColumn: blockedWipColumns.has(blocker.column),
isReviewColumn: blockedReviewColumns.has(blocker.column),
})) return false;
const taskScope = await getFilteredFileScope(task.id);
@@ -8702,8 +8728,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
logPrefix: "Stale paused todo surfaced",
role: "hold",
isEligible: (task) => task.paused === true,
evaluate: (task, thresholdMs, now, activation, roleColumn) =>
getStalePausedTodoSignal(task, { now, thresholdMs, holdColumn: roleColumn, ...activation }),
evaluate: (task, thresholdMs, now, activation, roleColumns) =>
getStalePausedTodoSignal(task, { now, thresholdMs, holdColumns: roleColumns, ...activation }),
describe: (_task, signal, thresholdMs) =>
`paused ${hours(signal.ageMs)}h beyond ${hours(thresholdMs)}h threshold; disposition options — unpause, move to triage, archive, or create follow-up task. pausedReason=${(_task as { pausedReason?: string }).pausedReason ?? "none"}`,
},
@@ -8719,8 +8745,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
logPrefix: "Stale paused review surfaced",
role: "review",
isEligible: (task) => task.paused === true,
evaluate: (task, thresholdMs, now, activation, roleColumn) =>
getStalePausedReviewSignal(task, { now, thresholdMs, reviewColumn: roleColumn, ...activation }),
evaluate: (task, thresholdMs, now, activation, roleColumns) =>
getStalePausedReviewSignal(task, { now, thresholdMs, reviewColumns: roleColumns, ...activation }),
describe: (_task, signal) =>
`paused ${hours(signal.ageMs)}h; disposition options — unpause, retry, archive, or create follow-up task. pausedReason=${(_task as { pausedReason?: string }).pausedReason ?? "none"}`,
},
@@ -8754,14 +8780,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
task.id !== activeMergeTaskId &&
!executingTaskIds.has(task.id),
isEligibleAsync: async (task) => !(await this.isMergeLaneOwned(task.id)),
evaluate: (task, thresholdMs, now, activation, roleColumn) => {
evaluate: (task, thresholdMs, now, activation, roleColumns) => {
const signal = getInReviewStalledSignal(task, {
now,
thresholdMs,
autoMerge: true,
activeMergeTaskId,
executingTaskIds,
reviewColumn: roleColumn,
reviewColumns: roleColumns,
...activation,
});
return signal ? { ...signal, code: signal.code, ageMs: signal.quietMs } : undefined;

View File

@@ -34,7 +34,8 @@ import {
type ResolvedRecoveryPolicy,
} from "./recovery-reconciler.js";
import {
resolveLifecycleColumns,
columnsWithFlag,
resolveReviewColumns,
resolveWorkflowIrForTask,
type Settings,
type Task,
@@ -86,10 +87,11 @@ export interface SurfacingSpec {
thresholdMs: number,
cycleStartMs: number,
activation: { engineActiveSinceMs?: number; engineActivationGraceMs?: number },
/* The RESOLVED role column. Signals take it so their own column check stays
real — passing `task.column` would make that check tautological and quietly
delete it. */
roleColumn: string,
/* The RESOLVED role column SET. Signals take it so their own column check
stays real — passing `task.column` would make that check tautological and
quietly delete it. A SET rather than one id because a role is a trait many
columns may carry; see `resolveRoleColumns`. */
roleColumns: ReadonlySet<string>,
): SurfacingSignal | undefined;
/** The operator-facing sentence following `logPrefix [code]: `. */
describe(task: Task, signal: SurfacingSignal, thresholdMs: number): string;
@@ -128,21 +130,42 @@ export interface SurfacingRunnerDeps {
* Returns `undefined` when neither supplies an actionable threshold — including
* when the operator set a non-positive value, which means DISABLED.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:45 (surfacing family watched only the FIRST role column):
A lifecycle role is a TRAIT, and any number of a board's columns may carry it — a workflow with a
merge lane beside a human-review lane has two review columns. `resolveLifecycleColumns()[role]`
answers with the FIRST one only, so the role gate below (`task.column !== roleColumn`) dropped every
card resting in any other column carrying the same role: no stale-paused-review, no in-review-stalled,
no stale-paused-todo diagnostic for those cards, silently and with no error.
Membership, not first-match. `review` is the union of the three review roles (the same answer
`resolveReviewColumns` gives every other converted reader) so a board splitting merge orchestration
from human review is covered by both.
*/
function resolveRoleColumns(ir: WorkflowIr, role: SurfacingSpec["role"]): string[] {
return role === "review" ? resolveReviewColumns(ir) : columnsWithFlag(ir, "hold");
}
async function resolveTaskThreshold(
store: TaskStore,
task: Task,
spec: SurfacingSpec,
inheritedThresholdMs: number,
irCache: Map<string, WorkflowIr>,
): Promise<{ policy: ResolvedRecoveryPolicy; roleColumn: string } | undefined> {
): Promise<{ policy: ResolvedRecoveryPolicy; roleColumns: ReadonlySet<string> } | undefined> {
let declared;
let roleColumn: string | undefined;
let roleColumns: string[] = [];
try {
const ir = await resolveWorkflowIrForTask(store, task.id, irCache);
const lifecycle = resolveLifecycleColumns(ir);
roleColumn = lifecycle?.[spec.role];
if (roleColumn && ir.version === "v2") {
declared = ir.columns.find((c) => c.id === roleColumn)?.recovery;
roleColumns = resolveRoleColumns(ir, spec.role);
if (roleColumns.length > 0 && ir.version === "v2") {
/*
The card's OWN column declares its recovery policy when it carries the role. Reading the
first role column's policy instead would apply the merge lane's threshold to a card sitting
in the human-review lane — a second first-match bug hiding inside the fix for the first.
*/
const policyColumn = roleColumns.includes(task.column) ? task.column : roleColumns[0];
declared = ir.columns.find((c) => c.id === policyColumn)?.recovery;
}
} catch {
// Unresolvable workflow: fall through to the inherited setting.
@@ -156,11 +179,14 @@ async function resolveTaskThreshold(
/*
A workflow that cannot resolve the role (v1 / no column vocabulary) keeps the
LEGACY id, so an unresolvable workflow behaves exactly as before this migration
rather than losing the sweep entirely. Never `undefined`, so the role gate in
the runner is ALWAYS active — leaving it optional made the gate silently absent
for exactly the workflows whose role failed to resolve.
rather than losing the sweep entirely. Never empty, so the role gate in the
runner is ALWAYS active — leaving it optional made the gate silently absent for
exactly the workflows whose role failed to resolve.
*/
return { policy, roleColumn: roleColumn ?? LEGACY_ROLE_COLUMN[spec.role] };
return {
policy,
roleColumns: new Set(roleColumns.length > 0 ? roleColumns : [LEGACY_ROLE_COLUMN[spec.role]]),
};
}
/**
@@ -200,12 +226,12 @@ export async function runSurfacingSweep(spec: SurfacingSpec, deps: SurfacingRunn
if (!resolved) continue;
const thresholdMs = resolved.policy.stalenessMs;
/* The role gate: only cards resting in THIS task's own role column. */
if (task.column !== resolved.roleColumn) continue;
/* The role gate: only cards resting in one of THIS task's own role columns. */
if (!resolved.roleColumns.has(task.column)) continue;
if (spec.isEligibleAsync && !(await spec.isEligibleAsync(task))) continue;
const signal = spec.evaluate(task, thresholdMs, deps.cycleStartMs, deps.activation, resolved.roleColumn);
const signal = spec.evaluate(task, thresholdMs, deps.cycleStartMs, deps.activation, resolved.roleColumns);
if (!signal) continue;
/* A row written during this cycle is re-read next pass, so reporting it

View File

@@ -8,7 +8,6 @@
"packages/engine/src/project-engine.ts": 1,
"packages/engine/src/runtimes/in-process-runtime.ts": 1,
"packages/engine/src/scheduler.ts": 1,
"packages/engine/src/self-healing.ts": 4,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,
"packages/dashboard/app/components/ExecutorStatusBar.tsx": 1,
"packages/dashboard/app/components/Lane.tsx": 1,

View File

@@ -42,6 +42,9 @@ export const LANE_ARGUMENT_NAMES = new Set([
"isReviewColumn",
"isWipColumn",
"holdColumn",
/* FNXC:WorkflowLifecycleColumns 2026-07-30-22:00: the MEMBERSHIP form, added with the surfacing
family's split-role fix — without it the gate cannot see a dropped `holdColumns`. */
"holdColumns",
]);
function parse(file) {