Drift review (unowned): auto-claim candidacy resolves hold + completion roles — three literals, two opposite failures (#2565)

> **Based on `main`** — independent of my U7 stack and of #2561; merges
in any order.

Third unowned drift-review site. `isRunnableAutoClaimCandidate` is the
single source of truth for *"may an agent claim this task?"* (FN-6873),
and it carried **three** lifecycle literals that fail in **opposite
directions**.

## The two failures

**`column === "todo"` gated candidacy** on the hold role. Keyed on the
literal, a renamed workflow's candidate set was **permanently empty** —
agents were never offered its work, and nothing anywhere reported it.
Silence, not an error.

**`dependency?.column === "done" || "archived"` gated dependency
satisfaction**, and this is the more dangerous half: a dependency that
finished in a renamed **complete** column was never recognised as done,
so the dependent stayed **blocked forever**.

One makes work invisible; the other makes it permanently ineligible.
Both are silent.

## Roles resolve per task, not per pass

The non-obvious part: **a dependency may sit on a different workflow
from the claimant.** A single per-pass answer is wrong for one of them
on any mixed board — so the map is keyed by task id, and the dependency
check reads the *dependency's* roles, not the claimant's.

Asserted directly: a dependency completed in `done` (default vocabulary)
satisfying a claimant waiting in `drafting` (renamed).

## Shape

Both callers already have the store and are async, so they resolve for
real rather than taking the injected-lane fallback the *synchronous*
predicates needed (#2551). The predicate itself stays synchronous — a
resolved-roles map is passed in — because it runs inside two
`filter`/`flatMap` bodies.

Tasks absent from the map keep the legacy ids, so a partially-resolvable
board degrades to today's behavior instead of silently emptying the
candidate set.

**Type narrowing preserved.** The two callers take `Pick<TaskStore,
"listTasks">`, which is what makes them testable without a real store.
Rather than widening to the whole `TaskStore`, they now take
`Pick<TaskStore, "listTasks"> & WorkflowIrResolverStore` — the minimal
additional shape resolution needs.

## Revert proofs, isolated per literal

| Restored | Result |
|---|---|
| hold literal only | **3 of 6 fail** |
| dependency-completion literals only | **1 of 6 fails** |

The three default-vocabulary cases pass under both. Splitting the proof
matters here: it confirms the two halves are **independently**
load-bearing rather than one masking the other — a single combined
revert would have shown 3 failures and told me nothing about the
dependency half.

## Convergence

Measured on `main`, comment-stripped scan of `column === / !== "todo" |
"triage"` in `packages/*/src` excluding tests:

- this file alone: **103 → 102**
- with #2561: **103 → 100**

The `done` / `archived` literals fixed here sit outside that pattern and
are not counted — same caveat as #2561's gridlock `active` filter. Two
PRs now where the real fix is larger than the metric shows.

## Verification

| Check | Result |
|---|---|
| new suite | 6/6 |
| pre-existing auto-claim suite | 17/17, **no expectation edits** |
| `tsc --noEmit` (engine) | clean |
| `pnpm lint` | clean |
| `pnpm test:gate` | green (414 + 10 + 71) |
| `pnpm check:changesets` | clean |

## Remaining unowned in my area

`mission-feature-sync.ts` (1, a planning-lane check) and
`notification-service.ts` (1, *"has progressed past"* — a different
semantic needing its own thinking, not a mechanical swap). Taking those
next unless claimed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 10:01:14 -07:00
committed by GitHub
parent 8578a1d27d
commit 41031dbe2c
3 changed files with 228 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Agents can auto-claim work on boards with renamed columns, and dependencies finished there now count as done.
category: fix
dev: U7 / R3 — unowned drift-review site. `isRunnableAutoClaimCandidate` carried three lifecycle literals: `column === "todo"` gated candidacy (a renamed workflow's candidate set was permanently empty, silently), and `dependency.column === "done" || "archived"` gated dependency satisfaction (a dependency finished in a renamed complete column was never recognised, blocking the dependent forever). Roles are resolved PER TASK because a dependency may sit on a different workflow from the claimant; both callers are async with store access so they resolve for real. Tasks absent from the resolved map keep the legacy ids, so a partially-resolvable board degrades to today's behavior instead of emptying.

View File

@@ -0,0 +1,139 @@
/*
FNXC:AutoClaimResolvedColumns 2026-07-29-14:40 (U7 / R3, R12 — workflow-owned lifecycle):
`isRunnableAutoClaimCandidate` is the single source of truth for "may an agent claim
this task?" (FN-6873). It carried THREE lifecycle-column literals:
`column === "todo"` — the candidate gate, i.e. the HOLD role
`dependency?.column === "done" || === "archived"` — dependency satisfaction, i.e.
the COMPLETE and ARCHIVED roles of the DEPENDENCY'S
own workflow, which need not be the claimant's
On a renamed workflow the first makes the candidate set permanently EMPTY — agents
are simply never offered work, with no error anywhere. The second is the more
dangerous direction: a dependency that finished in a renamed complete column is not
recognised as done, so the blocked task stays blocked forever; and in a mixed board
the dependency's workflow may differ from the claimant's, which is why the roles are
resolved PER TASK rather than once for the pass.
Both callers already have the store and are async, so this resolves for real rather
than taking the injected-lane fallback the synchronous predicates needed (#2551).
Tasks absent from the resolved map keep the legacy answer, so a partially-resolvable
board degrades to today's behavior instead of silently emptying.
*/
import { describe, expect, it, vi } from "vitest";
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
import { resolveFreshAutoClaimCandidates } from "../auto-claim-snapshot.js";
const DEFAULT_NAMES = { hold: "todo", complete: "done" };
const RENAMED = { hold: "drafting", complete: "shipped" };
function ir(id: string, names: { hold: string; complete: string }): WorkflowIr {
return {
version: "v2",
id,
name: id,
columns: [
{ id: names.hold, name: "Hold", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "wip", name: "Wip", traits: [{ trait: "wip" }] },
{ id: names.complete, name: "Complete", traits: [{ trait: "complete" }] },
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
],
nodes: [],
edges: [],
} as unknown as WorkflowIr;
}
function task(over: Partial<Task> = {}): Task {
return {
id: "FN-1",
title: "t",
description: "",
column: "todo",
status: null,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
...over,
} as Task;
}
/** A store where each task may sit on a DIFFERENT workflow, which is the mixed-board case. */
function storeWith(tasks: Task[], workflowByTask: Record<string, WorkflowIr>): TaskStore {
return {
listTasks: vi.fn(async () => tasks),
getTask: vi.fn(async (id: string) => tasks.find((t) => t.id === id)),
getTaskWorkflowSelection: vi.fn((id: string) => ({ workflowId: workflowByTask[id]?.id ?? "wf-default", stepIds: [] })),
getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => ({ workflowId: workflowByTask[id]?.id ?? "wf-default", stepIds: [] })),
getWorkflowDefinition: vi.fn(async (id: string) => {
const found = Object.values(workflowByTask).find((w) => (w as unknown as { id: string }).id === id);
return { ir: found ?? ir("wf-default", DEFAULT_NAMES) };
}),
} as unknown as TaskStore;
}
async function claimable(tasks: Task[], workflowByTask: Record<string, WorkflowIr>): Promise<string[]> {
const store = storeWith(tasks, workflowByTask);
const resolved = await resolveFreshAutoClaimCandidates(
store,
tasks.map((t) => ({ id: t.id, title: t.title ?? "", ageHours: 0 })) as never,
() => 1_000_000,
);
return resolved.map((c) => c.id).sort();
}
describe("auto-claim candidacy resolves the hold and completion roles", () => {
it("offers a card waiting in the DEFAULT hold column (no-regression half)", async () => {
const wf = ir("wf-default", DEFAULT_NAMES);
expect(await claimable([task({ id: "FN-A", column: "todo" })], { "FN-A": wf })).toEqual(["FN-A"]);
});
it("offers a card waiting in a RENAMED hold column", async () => {
// Pre-conversion the candidate set was permanently EMPTY for this workflow —
// agents were never offered its work, with no error anywhere.
const wf = ir("wf-renamed", RENAMED);
expect(await claimable([task({ id: "FN-A", column: "drafting" })], { "FN-A": wf })).toEqual(["FN-A"]);
});
it("never offers a card that is not in its own hold column", async () => {
const wf = ir("wf-renamed", RENAMED);
expect(await claimable([task({ id: "FN-A", column: "wip" })], { "FN-A": wf })).toEqual([]);
});
it("treats a dependency finished in a RENAMED complete column as satisfied", async () => {
// The dangerous direction: unrecognised completion blocks the dependent forever.
const wf = ir("wf-renamed", RENAMED);
const done = task({ id: "FN-DEP", column: "shipped" });
const blocked = task({ id: "FN-A", column: "drafting", dependencies: ["FN-DEP"] });
expect(await claimable([blocked, done], { "FN-A": wf, "FN-DEP": wf })).toContain("FN-A");
});
it("still blocks on a dependency that has NOT completed", async () => {
// The other side, so "always satisfied" cannot pass for "correctly resolved".
const wf = ir("wf-renamed", RENAMED);
const running = task({ id: "FN-DEP", column: "wip" });
const blocked = task({ id: "FN-A", column: "drafting", dependencies: ["FN-DEP"] });
expect(await claimable([blocked, running], { "FN-A": wf, "FN-DEP": wf })).not.toContain("FN-A");
});
it("resolves each task's roles from ITS OWN workflow on a mixed board", async () => {
/*
The claimant and its dependency may sit on different workflows, so a single
per-pass answer would be wrong for one of them. Here the dependency completed in
`done` (default vocabulary) while the claimant waits in `drafting` (renamed).
*/
const renamed = ir("wf-renamed", RENAMED);
const standard = ir("wf-default", DEFAULT_NAMES);
const done = task({ id: "FN-DEP", column: "done" });
const blocked = task({ id: "FN-A", column: "drafting", dependencies: ["FN-DEP"] });
expect(await claimable([blocked, done], { "FN-A": renamed, "FN-DEP": standard })).toContain("FN-A");
});
});

View File

@@ -1,4 +1,5 @@
import type { Task, TaskStore } from "@fusion/core"; import type { Task, TaskStore, WorkflowIr, WorkflowIrResolverStore } from "@fusion/core";
import { resolveTaskLifecycleColumns } from "@fusion/core";
import { createLogger, type Logger } from "./logger.js"; import { createLogger, type Logger } from "./logger.js";
/** /**
@@ -22,7 +23,7 @@ export interface AutoClaimSnapshot {
} }
interface AutoClaimSnapshotManagerOptions { interface AutoClaimSnapshotManagerOptions {
taskStore: Pick<TaskStore, "listTasks">; taskStore: Pick<TaskStore, "listTasks"> & WorkflowIrResolverStore;
ttlMs?: number; ttlMs?: number;
logger?: Logger; logger?: Logger;
now?: () => number; now?: () => number;
@@ -37,18 +38,88 @@ Auto-claim runnability must have one source of truth so the snapshot rebuild and
FNXC:AutoClaim 2026-06-21-16:09: FNXC:AutoClaim 2026-06-21-16:09:
FN-6873 pins `column === "todo"` as the candidate gate after FN-6872 appeared in a heartbeat prompt while archived from a stale cache. Archived, done, triage, in-progress, in-review, soft-deleted, paused, assigned, checked-out, and dependency-blocked rows can satisfy dependencies where allowed, but must never be surfaced or claimed as auto-claim candidates. FN-6873 pins `column === "todo"` as the candidate gate after FN-6872 appeared in a heartbeat prompt while archived from a stale cache. Archived, done, triage, in-progress, in-review, soft-deleted, paused, assigned, checked-out, and dependency-blocked rows can satisfy dependencies where allowed, but must never be surfaced or claimed as auto-claim candidates.
*/ */
export function isRunnableAutoClaimCandidate(task: Task, tasksById: ReadonlyMap<string, Task>): boolean { /**
return task.column === "todo" * FNXC:AutoClaimResolvedColumns 2026-07-29-14:40 (U7 / R3):
* Lifecycle roles per task id, so this predicate can stay SYNCHRONOUS while still
* answering per-workflow. Absent entries fall back to the legacy ids, so a
* partially-resolvable board degrades to today's behavior rather than silently
* emptying the candidate set.
*/
export interface AutoClaimLifecycleRoles {
hold?: string;
complete?: string;
archived?: string;
}
const LEGACY_AUTO_CLAIM_ROLES: Required<AutoClaimLifecycleRoles> = {
hold: "todo",
complete: "done",
archived: "archived",
};
const rolesFor = (
taskId: string,
rolesByTask?: ReadonlyMap<string, AutoClaimLifecycleRoles>,
): Required<AutoClaimLifecycleRoles> => {
const resolved = rolesByTask?.get(taskId);
return {
hold: resolved?.hold ?? LEGACY_AUTO_CLAIM_ROLES.hold,
complete: resolved?.complete ?? LEGACY_AUTO_CLAIM_ROLES.complete,
archived: resolved?.archived ?? LEGACY_AUTO_CLAIM_ROLES.archived,
};
};
/**
* FNXC:AutoClaimResolvedColumns 2026-07-29-14:40 (U7 / R3):
* THREE lifecycle literals lived here, and they fail in opposite directions:
*
* `column === "todo"` gated candidacy on the HOLD role. Keyed on the literal, a
* renamed workflow's candidate set was permanently EMPTY — agents were never
* offered its work, with no error anywhere to say so.
*
* `dependency?.column === "done" || "archived"` is dependency SATISFACTION, and it
* is the more dangerous half: a dependency that finished in a renamed complete
* column was not recognised as done, so the dependent stayed blocked forever.
*
* Roles are resolved PER TASK, not once per pass, because a dependency may sit on a
* DIFFERENT workflow from the claimant — a mixed board makes a single per-pass
* answer wrong for one of them.
*/
export function isRunnableAutoClaimCandidate(
task: Task,
tasksById: ReadonlyMap<string, Task>,
rolesByTask?: ReadonlyMap<string, AutoClaimLifecycleRoles>,
): boolean {
return task.column === rolesFor(task.id, rolesByTask).hold
&& task.paused !== true && task.paused !== true
&& !task.assignedAgentId && !task.assignedAgentId
&& !task.checkedOutBy && !task.checkedOutBy
&& !task.deletedAt && !task.deletedAt
&& task.dependencies.every((dependencyId) => { && task.dependencies.every((dependencyId) => {
const dependency = tasksById.get(dependencyId); const dependency = tasksById.get(dependencyId);
return dependency?.column === "done" || dependency?.column === "archived"; if (!dependency) return false;
// The DEPENDENCY's own roles, which need not be the claimant's.
const depRoles = rolesFor(dependencyId, rolesByTask);
return dependency.column === depRoles.complete || dependency.column === depRoles.archived;
}); });
} }
/** Resolve lifecycle roles for every task in one pass, sharing a single IR cache. */
export async function resolveAutoClaimLifecycleRoles(
taskStore: WorkflowIrResolverStore,
tasks: readonly Task[],
): Promise<Map<string, AutoClaimLifecycleRoles>> {
const irCache = new Map<string, WorkflowIr>();
const roles = new Map<string, AutoClaimLifecycleRoles>();
for (const task of tasks) {
const resolved = await resolveTaskLifecycleColumns(taskStore, task.id, irCache);
if (resolved) {
roles.set(task.id, { hold: resolved.hold, complete: resolved.complete, archived: resolved.archived });
}
}
return roles;
}
export function toAutoClaimCandidate(task: Task, now: number): AutoClaimCandidate { export function toAutoClaimCandidate(task: Task, now: number): AutoClaimCandidate {
const reference = task.columnMovedAt ?? task.createdAt; const reference = task.columnMovedAt ?? task.createdAt;
const ageMs = Math.max(0, now - Date.parse(reference)); const ageMs = Math.max(0, now - Date.parse(reference));
@@ -76,7 +147,7 @@ FNXC:AutoClaim 2026-06-21-16:09:
The fresh slim list intentionally includes archived rows by default so the shared predicate, not storage filtering, proves archived-while-cached rows are dropped before heartbeat prompt rendering or winner selection. The fresh slim list intentionally includes archived rows by default so the shared predicate, not storage filtering, proves archived-while-cached rows are dropped before heartbeat prompt rendering or winner selection.
*/ */
export async function resolveFreshAutoClaimCandidates( export async function resolveFreshAutoClaimCandidates(
taskStore: Pick<TaskStore, "listTasks">, taskStore: Pick<TaskStore, "listTasks"> & WorkflowIrResolverStore,
candidates: ReadonlyArray<AutoClaimCandidate>, candidates: ReadonlyArray<AutoClaimCandidate>,
now: () => number = Date.now, now: () => number = Date.now,
): Promise<AutoClaimCandidate[]> { ): Promise<AutoClaimCandidate[]> {
@@ -86,10 +157,11 @@ export async function resolveFreshAutoClaimCandidates(
const allTasks = await taskStore.listTasks({ slim: true }); const allTasks = await taskStore.listTasks({ slim: true });
const tasksById = new Map(allTasks.map((task) => [task.id, task])); const tasksById = new Map(allTasks.map((task) => [task.id, task]));
const rolesByTask = await resolveAutoClaimLifecycleRoles(taskStore, allTasks);
const resolvedAt = now(); const resolvedAt = now();
return candidates.flatMap((candidate) => { return candidates.flatMap((candidate) => {
const canonicalTask = tasksById.get(candidate.id); const canonicalTask = tasksById.get(candidate.id);
if (!canonicalTask || !isRunnableAutoClaimCandidate(canonicalTask, tasksById)) { if (!canonicalTask || !isRunnableAutoClaimCandidate(canonicalTask, tasksById, rolesByTask)) {
return []; return [];
} }
return [toAutoClaimCandidate(canonicalTask, resolvedAt)]; return [toAutoClaimCandidate(canonicalTask, resolvedAt)];
@@ -97,7 +169,7 @@ export async function resolveFreshAutoClaimCandidates(
} }
export class AutoClaimSnapshotManager { export class AutoClaimSnapshotManager {
private readonly taskStore: Pick<TaskStore, "listTasks">; private readonly taskStore: Pick<TaskStore, "listTasks"> & WorkflowIrResolverStore;
private readonly ttlMs: number; private readonly ttlMs: number;
private readonly logger: Logger; private readonly logger: Logger;
private readonly now: () => number; private readonly now: () => number;
@@ -144,10 +216,11 @@ export class AutoClaimSnapshotManager {
private async rebuild(): Promise<AutoClaimSnapshot> { private async rebuild(): Promise<AutoClaimSnapshot> {
const allTasks = await this.taskStore.listTasks({ slim: true }); const allTasks = await this.taskStore.listTasks({ slim: true });
const tasksById = new Map(allTasks.map((candidate) => [candidate.id, candidate])); const tasksById = new Map(allTasks.map((candidate) => [candidate.id, candidate]));
const rolesByTask = await resolveAutoClaimLifecycleRoles(this.taskStore, allTasks);
const now = this.now(); const now = this.now();
const tasks = allTasks const tasks = allTasks
.filter((candidate) => isRunnableAutoClaimCandidate(candidate, tasksById)) .filter((candidate) => isRunnableAutoClaimCandidate(candidate, tasksById, rolesByTask))
.sort((a, b) => { .sort((a, b) => {
const aSortAt = a.columnMovedAt ?? a.createdAt; const aSortAt = a.columnMovedAt ?? a.createdAt;
const bSortAt = b.columnMovedAt ?? b.createdAt; const bSortAt = b.columnMovedAt ?? b.createdAt;