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:
7
.changeset/auto-claim-resolved-columns.md
Normal file
7
.changeset/auto-claim-resolved-columns.md
Normal 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.
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user