FN-9037: add indexed recommendation task lookups

Keep recommendation task creation responsive without changing duplicate-intake semantics.

- Add project-scoped proposal claim and source-lineage persistence reads with supporting indexes.
- Use bounded lookups for recommendation replay and same-agent duplicate intake.
- Resolve terminal lanes from each candidate's workflow and cover the indexed query paths.

Files changed:
 .changeset/fn-9037-recommendation-create-perf.md   |   7 ++
 docs/storage.md                                    |   4 +
 .../intake-duplicate-terminal-lanes.test.ts        |   3 +-
 .../postgres/proposal-claim-lookup.pg.test.ts      | 106 +++++++++++++++++++++
 .../__tests__/same-agent-duplicate-intake.test.ts  |  14 ++-
 .../0059_fn_9037_tasks_source_agent_index.sql      |   3 +
 packages/core/src/postgres/schema-applier.ts       |  12 ++-
 packages/core/src/postgres/schema/project.ts       |   3 +
 packages/core/src/store.ts                         |   8 +-
 .../core/src/task-store/async/async-persistence.ts |  40 ++++++++
 packages/core/src/task-store/reads.ts              |  18 +++-
 packages/core/src/task-store/task-creation.ts      |  10 +-
 .../__tests__/task-recommendation-routes.test.ts   |  38 +++++++-
 .../src/routes/register-task-workflow-routes.ts    |  20 ++--
 14 files changed, 259 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-9037
Fusion-Task-Lineage: ea44d65c-98cc-4a5b-a116-b3c28f14c292
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 17:13:56 -07:00
parent 2dcead4c8f
commit bebb46ff41
14 changed files with 259 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Creating a task from an Insights recommendation is no longer slow on large boards.
category: performance
dev: Adds indexed findTaskByProposalClaimId and listTasksBySourceLineage reads, removes near-duplicate fullRows hydration, and registers migration 0059.

View File

@@ -841,3 +841,7 @@ Revision listing defaults to 100 rows and clamps `limit` to 1–500. The API acc
Project-scoped structured recall records for durable decisions, preferences, and solutions. The table uses the composite `(project_id, id)` key, row-level security, created-at indexes, and a named `(project_id, kind, content_hash)` exact-hash backstop. `graph_node_ids` stores graph cross-references; Memory Keeper merges new IDs under a per-record advisory transaction lock, so identifiers only grow and an unchanged union does not update the row.
- Knowledge-graph artifact: `<rootDir>/.fusion-knowledge/graph/` (`nodes.json`, `edges.json`, and `manifest.json`). This is deliberately outside ignored `.fusion` and may be committed at the operator's discretion.
### Bounded task-intake lookups
Recommendation proposal claims use the indexed `findTaskByProposalClaimId` read (`uqTasksProjectProposalClaimId`), and same-agent intake reads only matching source lineage (`idxTasksProjectSourceAgentId` and `idxTasksSourceParentTaskId`). Do not replace either read with a `listTasks()` scan. Workflow terminal flags for intake duplicate checks are derived from workflow definitions, not board rows. Guarded-intake near-duplicate checks must remain bounded to their candidates (the fallback is `limit: 50`) and must not hydrate the full board.

View File

@@ -93,8 +93,7 @@ describe("the intake path actually forwards the resolved flags", () => {
const source = readFileSync(new URL("../task-store/task-creation.ts", import.meta.url), "utf8");
expect(source).toContain("columnFlagsByColumnId: await resolveIntakeDuplicateColumnFlags(store, allCandidates)");
// Scoped to distinct columns, not one IR read per candidate row.
expect(source).toContain("if (seenColumns.has(candidate.column)) continue;");
expect(source).toContain("resolveTaskLifecycleColumns(store, candidate.id, irCache)");
});
it("mirrors the auto-archive into the row using the resolved archived lane", () => {

View File

@@ -0,0 +1,106 @@
/*
FNXC:TaskRecommendations 2026-08-13-22:39:
Recommendation replay must use the indexed project-scoped claim lookup rather than a board scan.
These PostgreSQL assertions cover the actual Drizzle predicate, including forensic tombstones and
project isolation that a route mock cannot prove.
*/
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
import * as schema from "../../postgres/schema/index.js";
import { TaskStore } from "../../store.js";
import { insertTaskRow, softDeleteTaskRow } from "../../task-store/async/async-persistence.js";
pgDescribe("findTaskByProposalClaimId PostgreSQL persistence", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_proposal_claim_lookup",
});
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function createClaimedTask(
id: string,
claim: string,
overrides: Record<string, unknown> = {},
) {
return h.store().createTaskWithReservedId({
id,
description: `Claim holder ${id}`,
column: "todo",
proposalClaimId: claim,
sourceMetadata: { fileScope: ["packages/core/src/store.ts"] },
...overrides,
} as never, {
taskId: id,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
applyDefaultWorkflowSteps: false,
});
}
it("returns persisted live rows, rejects blank or unknown claims, and keeps archived lanes live", async () => {
const store = h.store();
await createClaimedTask("FN-CLAIM", "recommendation:parent:one");
await h.layer().db.update(schema.project.tasks).set({
sourceMetadata: { fileScope: ["packages/core/src/store.ts"] },
}).where(eq(schema.project.tasks.id, "FN-CLAIM"));
await createClaimedTask("FN-ARCHIVED-LANE", "recommendation:parent:archived", { column: "archived" });
await expect(store.findTaskByProposalClaimId("recommendation:parent:one")).resolves.toMatchObject({
id: "FN-CLAIM",
column: "todo",
sourceMetadata: { fileScope: ["packages/core/src/store.ts"] },
});
await expect(store.findTaskByProposalClaimId("missing")).resolves.toBeNull();
await expect(store.findTaskByProposalClaimId(" ")).resolves.toBeNull();
await expect(store.findTaskByProposalClaimId("recommendation:parent:archived")).resolves.toMatchObject({
id: "FN-ARCHIVED-LANE",
column: "archived",
});
});
it("hides soft-deleted claim holders by default and returns them only for forensic replay", async () => {
const store = h.store();
await createClaimedTask("FN-DELETED", "recommendation:parent:deleted");
await softDeleteTaskRow(h.layer(), "FN-DELETED", "2026-08-14T01:00:00.000Z");
await expect(store.findTaskByProposalClaimId("recommendation:parent:deleted")).resolves.toBeNull();
await expect(store.findTaskByProposalClaimId("recommendation:parent:deleted", { includeDeleted: true }))
.resolves.toMatchObject({ id: "FN-DELETED", deletedAt: "2026-08-14T01:00:00.000Z" });
});
it("is project-scoped and never consults cold archive snapshots", async () => {
const layerFor = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
const storeA = new TaskStore(h.rootDir(), undefined, { asyncLayer: layerFor("project-a") });
const storeB = new TaskStore(h.rootDir(), undefined, { asyncLayer: layerFor("project-b") });
const claim = "recommendation:shared:claim";
const row = (id: string) => ({
id,
description: id,
column: "todo",
currentStep: 0,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
proposalClaimId: claim,
});
await insertTaskRow(layerFor("project-a"), row("FN-PROJECT-A"), { lineageId: "lineage-a" });
await expect(storeB.findTaskByProposalClaimId(claim)).resolves.toBeNull();
await expect(storeA.findTaskByProposalClaimId(claim)).resolves.toMatchObject({ id: "FN-PROJECT-A" });
const archived = await createClaimedTask("FN-COLD", "recommendation:cold:snapshot");
await h.store().archiveTask(archived.id, { cleanup: false });
// A cold-only snapshot has no live row for the indexed reader to match.
await h.adminDb().delete(schema.project.tasks).where(eq(schema.project.tasks.id, archived.id));
await expect(h.store().findTaskByProposalClaimId("recommendation:cold:snapshot", { includeDeleted: true }))
.resolves.toBeNull();
});
});

View File

@@ -39,6 +39,8 @@ function createStore(overrides: Record<string, unknown> = {}) {
taskCache: new Map(),
getSettings: vi.fn().mockResolvedValue({ autoArchiveDuplicateTasksEnabled: false, tombstoneStickyWindowDays: 7 }),
listTasks: vi.fn().mockResolvedValue([]),
listTasksBySourceLineage: vi.fn().mockResolvedValue([]),
listWorkflowDefinitions: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
recordActivity: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue(undefined),
@@ -61,6 +63,7 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
await resolveSameAgentDuplicateIntake(store as any, noProvenance as any, noProvenance as any);
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.listTasksBySourceLineage).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
@@ -68,7 +71,7 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
it("flags the new live duplicate in place and never deletes its sibling by default", async () => {
const sibling = task("FN-SIBLING", { createdAt: new Date(Date.now() - 60_000).toISOString() });
const created = task("FN-NEW");
const store = createStore({ listTasks: vi.fn().mockResolvedValue([created, sibling]) });
const store = createStore({ listTasksBySourceLineage: vi.fn().mockResolvedValue([created, sibling]) });
/*
FNXC:SameAgentDuplicateIntake 2026-07-19-16:33:
@@ -77,6 +80,9 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
*/
await _maybeAutoArchiveSameAgentDuplicateBackendImpl(store as any, created as any, created as any);
// Pre-fix performed a board scan; provenance creates must use the narrow lineage read.
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.listTasksBySourceLineage).toHaveBeenCalledWith({ sourceAgentId: "agent-intake", sourceParentTaskId: null });
expect(store.updateTask).toHaveBeenCalledWith("FN-NEW", {
sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-SIBLING" }),
});
@@ -94,7 +100,7 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
const created = task("FN-NEW");
const store = createStore({
getSettings: vi.fn().mockResolvedValue({ autoArchiveDuplicateTasksEnabled: true, tombstoneStickyWindowDays: 7 }),
listTasks: vi.fn().mockResolvedValue([created, sibling]),
listTasksBySourceLineage: vi.fn().mockResolvedValue([created, sibling]),
});
await resolveSameAgentDuplicateIntake(store as any, created as any, created as any);
@@ -109,7 +115,7 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
const deletedAt = new Date(Date.now() - 60_000).toISOString();
const tombstone = task("FN-TOMBSTONE", { deletedAt, allowResurrection: false });
const created = task("FN-NEW");
const store = createStore({ backendMode: true, listTasks: vi.fn().mockResolvedValue([created, tombstone]) });
const store = createStore({ backendMode: true, listTasksBySourceLineage: vi.fn().mockResolvedValue([created, tombstone]) });
await expect(resolveSameAgentDuplicateIntake(store as any, created as any, created as any))
.rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
@@ -119,7 +125,7 @@ describe("same-agent duplicate intake policy (FN-8401)", () => {
Soft deletes move to `archived`; sticky tombstones require both flags so
same-agent recreation is rejected on every persistence backend.
*/
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: true, includeDeleted: true });
expect(store.listTasksBySourceLineage).toHaveBeenCalledWith({ sourceAgentId: "agent-intake", sourceParentTaskId: null });
expect(recordRunAuditEventAsync).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
taskId: "FN-NEW", mutationType: "intake:resurrection-blocked",
}));

View File

@@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS "idxTasksProjectSourceAgentId"
ON project.tasks USING btree ("project_id", "source_agent_id")
WHERE "source_agent_id" IS NOT NULL;

View File

@@ -63,7 +63,8 @@ capacity-model table drop that landed while this PR was open.
/* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */
/* FNXC:MultiProjectIsolation 2026-08-11-10:25: schema startup must register project-local agent ratings before bound stores scope their mutations. */
/* FNXC:MessageArchive 2026-08-12-22:14: 0058 persists non-destructive mailbox archival on upgrades. */
export const SCHEMA_BASELINE_VERSION = "0058";
/* FNXC:TaskRecommendations 2026-08-13-22:23: upgrades must install the source-agent index before duplicate intake queries it. */
export const SCHEMA_BASELINE_VERSION = "0059";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -222,6 +223,7 @@ export const PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION = "0056";
export const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION = "0057";
/** FNXC:MessageArchive 2026-08-12-22:14: explicit registration prevents the archived-message migration from being skipped. */
export const MESSAGE_ARCHIVE_SCHEMA_VERSION = "0058";
export const TASK_SOURCE_AGENT_INDEX_VERSION = "0059";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -454,6 +456,7 @@ const AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH = join(MIGRATIONS_DIR, "005
const PROJECT_OWNERSHIP_DECLARATION_DRIFT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0056_fn_8997_project_ownership_declaration_drift.sql");
const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0057_fn_9004_project_ownership_default_reconciliation.sql");
const MESSAGE_ARCHIVE_SCHEMA_MIGRATION_PATH = join(MIGRATIONS_DIR, "0058_fn_9014_message_archive.sql");
const TASK_SOURCE_AGENT_INDEX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0059_fn_9037_tasks_source_agent_index.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -582,6 +585,7 @@ export async function applySchemaBaseline(
const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION);
const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION);
const messageArchiveSchemaAlreadyApplied = applied.includes(MESSAGE_ARCHIVE_SCHEMA_VERSION);
const taskSourceAgentIndexAlreadyApplied = applied.includes(TASK_SOURCE_AGENT_INDEX_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1282,6 +1286,12 @@ export async function applySchemaBaseline(
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MESSAGE_ARCHIVE_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!taskSourceAgentIndexAlreadyApplied) {
const migrationSql = await readFile(TASK_SOURCE_AGENT_INDEX_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_SOURCE_AGENT_INDEX_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -316,6 +316,9 @@ export const tasks = projectSchema.table("tasks", {
the gate is a full tasks-table scan. Sparse: most rows have NULL parent.
*/
index("idxTasksSourceParentTaskId").on(t.sourceParentTaskId),
/* FNXC:TaskRecommendations 2026-08-13-22:23: duplicate intake filters source
* lineage on recommendation/agent creates; this sparse index avoids a task-table scan. */
index("idxTasksProjectSourceAgentId").on(t.projectId, t.sourceAgentId).where(sql`${t.sourceAgentId} IS NOT NULL`),
// FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: proposal retries share one stable key, so the database—not a read-before-create race—enforces at-most-once materialization.
uniqueIndex("uqTasksProjectProposalClaimId").on(t.projectId, t.proposalClaimId).where(sql`${t.proposalClaimId} IS NOT NULL`),
/*

View File

@@ -150,7 +150,7 @@ import { deleteTaskImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task
import type { TaskDeleteAuditContext } from "./task-delete-attribution.js";
import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js";
import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.js";
import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl, getTaskVerificationRequestAsyncImpl, listTaskRecommendationsImpl } from "./task-store/reads.js";
import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl, getTaskVerificationRequestAsyncImpl, listTaskRecommendationsImpl, findTaskByProposalClaimIdImpl, listTasksBySourceLineageImpl } from "./task-store/reads.js";
import { updateTaskUnlockedImpl } from "./task-store/task-update.js";
import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js";
import { declaresAnyLifecycleTrait, resolveReviewColumns, resolveTaskLifecycleColumns, type LifecycleColumns } from "./workflows/workflow-lifecycle-traits.js";
@@ -1495,6 +1495,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async getTaskColumns(ids: string[]): Promise<Map<string, Column>> {
return getTaskColumnsImpl(this, ids);
}
async findTaskByProposalClaimId(proposalClaimId: string, options?: { includeDeleted?: boolean }): Promise<Task | null> {
return findTaskByProposalClaimIdImpl(this, proposalClaimId, options);
}
async listTasksBySourceLineage(input: { sourceAgentId?: string | null; sourceParentTaskId?: string | null }): Promise<Task[]> {
return listTasksBySourceLineageImpl(this, input);
}
async listTasks(options?: { limit?: number; offset?: number; /** When false, exclude tasks in the `archived` column. Default: true (backward compatible). */ includeArchived?: boolean; /** When true, omit heavy fields (log, comments, steps, workflowStepResults, steeringComments) * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). * Widened to {@link ColumnId} (#1403) so custom-column filters are accepted. */ column?: ColumnId; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; /** Forensic read: surface soft-deleted tasks (deletedAt IS NOT NULL). * VAL-DATA-006 — only admin/forensic surfaces should set this. */ includeDeleted?: boolean; }): Promise<Task[]> {
return listTasksImpl(this, options);
}

View File

@@ -356,6 +356,46 @@ export async function readTaskRow(
* @param id The task id to read.
* @param options Optional: includeDeleted surfaces soft-deleted rows.
*/
/**
* FNXC:TaskRecommendations 2026-08-13-22:23:
* Claim replay used to scan the whole task table, making recommendation creation hang.
* The partial (project_id, proposal_claim_id) unique index permits at most one match;
* project scope is load-bearing because task identity is composite across projects.
*/
export async function readTaskRowByProposalClaimId(
layer: AsyncDataLayer,
proposalClaimId: string,
options?: { includeDeleted?: boolean },
): Promise<Record<string, unknown> | undefined> {
const conditions = [eq(schema.project.tasks.proposalClaimId, proposalClaimId)];
if (!options?.includeDeleted) conditions.push(ACTIVE_TASK_FILTER);
const projectScope = taskProjectScope(layer);
if (projectScope) conditions.push(projectScope);
const rows = await layer.db.select().from(schema.project.tasks).where(and(...conditions));
return rows[0];
}
/** Read source-lineage candidates without a board-wide scan; tombstones intentionally participate. */
export async function readTaskRowsBySourceLineage(
layer: AsyncDataLayer,
input: { sourceAgentId?: string | null; sourceParentTaskId?: string | null },
): Promise<Record<string, unknown>[]> {
const predicates = [
input.sourceAgentId ? eq(schema.project.tasks.sourceAgentId, input.sourceAgentId) : undefined,
input.sourceParentTaskId ? eq(schema.project.tasks.sourceParentTaskId, input.sourceParentTaskId) : undefined,
].filter((predicate): predicate is SQL => predicate !== undefined);
if (predicates.length === 0) return [];
/* FNXC:TaskRecommendations 2026-08-13-22:23: SQL pre-narrowing is equivalent to the
* existing JS filter; cold archives have no lineage fields and never matched old scans. */
const scope = taskProjectScope(layer);
return layer.db.select({
id: schema.project.tasks.id, title: schema.project.tasks.title, description: schema.project.tasks.description,
column: schema.project.tasks.column, createdAt: schema.project.tasks.createdAt,
sourceAgentId: schema.project.tasks.sourceAgentId, sourceParentTaskId: schema.project.tasks.sourceParentTaskId,
deletedAt: schema.project.tasks.deletedAt, allowResurrection: schema.project.tasks.allowResurrection,
}).from(schema.project.tasks).where(and(scope, sql`(${predicates.reduce((left, right) => sql`${left} OR ${right}`)})`));
}
export async function readTaskRowInTransaction(
tx: DbTransaction,
id: string,

View File

@@ -128,7 +128,7 @@ function hasFreshAgentLogActivitySinceTaskUpdate(
}
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {readTaskRow, readLiveTaskRows} from "./async/async-persistence.js";
import {readTaskRow, readLiveTaskRows, readTaskRowByProposalClaimId, readTaskRowsBySourceLineage} from "./async/async-persistence.js";
import {searchTasksTsvector, searchTasksLike} from "./async/async-search.js";
import {
getArchivedTask,
@@ -201,6 +201,22 @@ async function resolveReviewColumnsForTask(
}
/**
* FNXC:TaskRecommendations 2026-08-13-22:23:
* Claim replay is a one-row indexed lookup and intentionally skips cold storage: archive
* snapshots have no proposalClaimId. It also skips board-derived signal hydration because replay needs only persisted data.
*/
export async function findTaskByProposalClaimIdImpl(store: TaskStore, proposalClaimId: string, options?: { includeDeleted?: boolean }): Promise<Task | null> {
if (proposalClaimId.trim().length === 0) return null;
const row = await readTaskRowByProposalClaimId(store.asyncLayer!, proposalClaimId, options);
return row ? store.rowToTask(store.pgRowToTaskRow(row)) : null;
}
export async function listTasksBySourceLineageImpl(store: TaskStore, input: { sourceAgentId?: string | null; sourceParentTaskId?: string | null }): Promise<Task[]> {
const rows = await readTaskRowsBySourceLineage(store.asyncLayer!, input);
return rows.map((row) => store.rowToTask(store.pgRowToTaskRow(row)));
}
export async function getTaskImpl(store: TaskStore, id: string, options?: { activityLogLimit?: number; includeDeleted?: boolean }): Promise<TaskDetail> {
return store.withTaskLock(id, async () => {
// FNXC:RuntimePersistenceAsync 2026-06-24-10:50:

View File

@@ -159,7 +159,7 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
successful prior proposal into a failed retry or emit a second owner signal.
*/
if (input.proposalClaimId) {
const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId);
const existing = await store.findTaskByProposalClaimId(input.proposalClaimId);
if (existing) {
options?.onProposalClaimConflict?.(existing);
return existing;
@@ -845,7 +845,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
workflow materialization. Other unique violations remain task-ID errors.
*/
if (input.proposalClaimId && isTaskIdConflictError(error)) {
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
const existing = await store.findTaskByProposalClaimId(input.proposalClaimId);
if (existing) {
options?.onProposalClaimConflict?.(existing);
return existing;
@@ -916,7 +916,7 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
to its project-scoped partial index, so this replay read must not touch the
removed SQLite backend before the shared insert boundary handles a race.
*/
const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId);
const existing = await store.findTaskByProposalClaimId(input.proposalClaimId);
if (existing) return existing;
}
@@ -1044,7 +1044,7 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
// materialized above would orphan with no task/selection pointing at them.
await store.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
if (input.proposalClaimId && isTaskIdConflictError(err)) {
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
const existing = await store.findTaskByProposalClaimId(input.proposalClaimId);
if (existing) return existing;
}
throw err;
@@ -1339,7 +1339,7 @@ export async function resolveSameAgentDuplicateIntake(store: TaskStore, task: Ta
const nowMs = Date.now();
const settings = await store.getSettings();
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
const allCandidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
const allCandidates = await store.listTasksBySourceLineage({ sourceAgentId, sourceParentTaskId });
const matches = findSameAgentDuplicates(
{ title: input.title ?? task.title, description: input.description, sourceParentTaskId },
allCandidates.flatMap<SameAgentDuplicateCandidate>((candidate) => {

View File

@@ -35,6 +35,9 @@ function buildApp(seed: Task[], projectId = "project-a") {
listTasks: vi.fn(async (options?: { includeDeleted?: boolean }) =>
tasks.filter((item) => options?.includeDeleted || !item.deletedAt),
),
findTaskByProposalClaimId: vi.fn(async (claimId: string, options?: { includeDeleted?: boolean }) =>
tasks.find((item) => item.proposalClaimId === claimId && (options?.includeDeleted || !item.deletedAt)) ?? null,
),
listTaskRecommendations: vi.fn(async (options?: { completeColumns?: ReadonlySet<string>; limit?: number; offset?: number }) => {
const rows = tasks.filter((item) => !item.deletedAt && !!item.recommendations?.length && (options?.completeColumns ?? new Set(["done"])).has(item.column))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id));
@@ -252,6 +255,39 @@ describe("recommendation task creation route", () => {
expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBe(children[0]?.id);
});
it("keeps recommendation creation free of unbounded listTasks reads when search returns candidates (pre-fix violated this invariant)", async () => {
const { app, store } = buildApp([parent()]);
const response = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined);
expect(response.status).toBe(201);
expect(store.findTaskByProposalClaimId).toHaveBeenCalledWith("recommendation:FN-1:rec-1", { includeDeleted: true });
expect(store.listTasks).not.toHaveBeenCalled();
});
it("uses only the bounded limit-50 fallback when recommendation search has no candidates (pre-fix violated this invariant)", async () => {
const { app, store } = buildApp([parent({
recommendations: [{
id: "rec-1",
title: "Export task records",
description: "Add packages/dashboard/app/api/tasks/export.ts outside this task's scope.",
category: "feature",
}],
})]);
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const response = await performRequest(app, "POST", "/api/tasks/FN-1/recommendations/rec-1/create", undefined);
expect(response.status).toBe(201);
expect(store.listTasks).toHaveBeenCalledTimes(1);
expect(store.listTasks).toHaveBeenCalledWith(expect.objectContaining({
limit: 50,
}));
for (const [options] of (store.listTasks as ReturnType<typeof vi.fn>).mock.calls) {
expect(typeof options?.limit).toBe("number");
expect(options.limit).toBeLessThanOrEqual(50);
}
});
/*
FNXC:TaskRecommendations 2026-08-08-08:10:
Recommendation parent and row IDs are only project-scoped identities. A slow create in one
@@ -371,7 +407,7 @@ describe("recommendation task creation route", () => {
expect(response.status).toBe(409);
expect(store.createTask).not.toHaveBeenCalled();
expect(tasks[0]?.recommendations?.[0]?.createdTaskId).toBeUndefined();
expect(store.listTasks).toHaveBeenCalledWith(expect.objectContaining({ includeDeleted: true }));
expect(store.findTaskByProposalClaimId).toHaveBeenCalledWith(expect.any(String), { includeDeleted: true });
});
it("returns the normal duplicate conflict shape when post-create reconciliation loses a race", async () => {

View File

@@ -2013,14 +2013,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (candidateRows.length === 0) {
candidateRows = await scopedStore.listTasks({ slim: true, includeArchived: false, limit: 50 });
}
const fullRows = await scopedStore.listTasks({ slim: false, includeArchived: false });
const byId = new Map(fullRows.map((row) => [row.id, row]));
/* FNXC:TaskIntakeDedup 2026-08-13-22:23: slim candidates retain every field this
* guard reads; they are live non-archived rows, so the old full-board byId lookup always hit. */
const candidateMap = new Map<string, NearDuplicateCandidate>();
const classifiedRows = await Promise.all(candidateRows.map(async (row) => {
const full = byId.get(row.id);
return { row, full, blocker: full ? await classifyDuplicateBlocker(full) : true };
}));
for (const { row, full, blocker } of classifiedRows) {
const classifiedRows = await Promise.all(candidateRows.map(async (row) => ({ row, blocker: await classifyDuplicateBlocker(row) })));
for (const { row, blocker } of classifiedRows) {
if (acknowledgedDuplicateIds.includes(row.id)) {
continue;
}
@@ -2032,9 +2029,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
title: row.title ?? "",
description: row.description ?? "",
column: row.column,
createdAt: full?.createdAt ? Date.parse(full.createdAt) : undefined,
fileScope: Array.isArray(full?.sourceMetadata?.fileScope)
? full.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string")
createdAt: row.createdAt ? Date.parse(row.createdAt) : undefined,
fileScope: Array.isArray(row.sourceMetadata?.fileScope)
? row.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string")
: undefined,
});
}
@@ -2439,8 +2436,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
from colliding with the unique claim and manufacturing a second child. Read tombstones here
only to return a conflict; they are never relinked or exposed as live recommendation tasks.
*/
const existing = (await scopedStore.listTasks({ slim: false, includeArchived: true, includeDeleted: true }))
.find((task) => task.proposalClaimId === proposalClaimId);
const existing = await scopedStore.findTaskByProposalClaimId(proposalClaimId, { includeDeleted: true });
const existingArchiveColumns = existing
? await archivedColumnsForTask(scopedStore, existing.id)
: new Set<string>();