fix(core): restore title-redirect planning hold and stamp workflow-create partition

Full-suite repair, core cluster. Two real product regressions caught by
the red tests: (1) the wave-18 refactor dropped the task.title argument
FN-8840 added to isDuplicateRedirectOnlyPrompt inside
isTaskAwaitingPlanning, so a title-only DUPLICATE:<ID> redirect read as
executable Ready instead of awaiting planning; (2) FN-8998 scoped every
workflow-definition read by layer.projectId but left the INSERT on the
session-GUC default, so a JS-bound layer created workflow rows it could
never read back — the insert now stamps the bound projectId like the
FN-8997 workflowSteps insert. Test-lag fixes: analytics renamed-lanes
harnesses now bind projectId 'p1' to match FN-8957/FN-8998 scoping,
project-ownership-runtime-scope replaces a hardcoded expired approval
date, schema-applier learns migrations 0059/0060 (115 project tables,
baseline 0060), and builtin-workflow-settings-triage learns FN-8932's
memoryConsolidationEnabled with position-independent lookups.
Verified: 10 files / 128 tests green, core typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-15 15:22:33 -07:00
parent 13525fc0f0
commit f11bb2e899
11 changed files with 118 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix title-only duplicate redirects showing as Ready and workflows created invisible to their own project.
category: fix
dev: Restores the `task.title` argument to `isDuplicateRedirectOnlyPrompt` in `isTaskAwaitingPlanning` (dropped by a refactor after FN-8840) and stamps `layer.projectId` on the `project.workflows` INSERT so FN-8998's project-scoped reads see a bound layer's own create.

View File

@@ -142,7 +142,14 @@ describe("workflow-native built-in workflow settings", () => {
const movedIds = new Set(BUILTIN_MOVED_WORKFLOW_SETTINGS.map((setting) => setting.id));
const movedKeyIds = new Set(MOVED_SETTINGS_KEYS);
/*
FNXC:MemoryAgent 2026-08-15-22:10:
FN-8932 declares memory consolidation workflow-native (it resolves through the default workflow
on a no-task heartbeat, like patrol), so it belongs in this catalog and stays out of
moved/project settings like every other key asserted below.
*/
expect(BUILTIN_OVERSIGHT_SETTINGS.map((setting) => setting.id)).toEqual([
"memoryConsolidationEnabled",
"plannerOversightLevel",
"plannerOversightNotificationLevel",
"plannerOverseerExecutorStuckAfterMs",
@@ -156,7 +163,13 @@ describe("workflow-native built-in workflow settings", () => {
type: "boolean",
default: false,
});
const oversight = BUILTIN_OVERSIGHT_SETTINGS[0];
/* FNXC:MemoryAgent 2026-08-15-22:10: resolve by id, not position — FN-8932 prepended
memoryConsolidationEnabled to this catalog and positional reads silently drifted. */
const memoryConsolidation = BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "memoryConsolidationEnabled");
expect(memoryConsolidation).toMatchObject({ type: "boolean", default: true });
expect(movedIds.has("memoryConsolidationEnabled")).toBe(false);
expect(movedKeyIds.has("memoryConsolidationEnabled")).toBe(false);
const oversight = BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOversightLevel")!;
expect(oversight).toMatchObject({
type: "enum",
default: "autonomous",
@@ -180,7 +193,7 @@ describe("workflow-native built-in workflow settings", () => {
"plannerOversightLevel should not be in MOVED_SETTINGS_KEYS",
).toBe(false);
const notificationLevel = BUILTIN_OVERSIGHT_SETTINGS[1];
const notificationLevel = BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOversightNotificationLevel")!;
expect(notificationLevel).toMatchObject({
id: "plannerOversightNotificationLevel",
type: "enum",
@@ -213,7 +226,7 @@ describe("workflow-native built-in workflow settings", () => {
// FN-7743: executor-stall recovery threshold, declared alongside the other
// workflow-native oversight settings.
const executorStuckAfterMs = BUILTIN_OVERSIGHT_SETTINGS[2];
const executorStuckAfterMs = BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerExecutorStuckAfterMs")!;
expect(executorStuckAfterMs).toMatchObject({
id: "plannerOverseerExecutorStuckAfterMs",
type: "number",
@@ -232,7 +245,7 @@ describe("workflow-native built-in workflow settings", () => {
"plannerOverseerExecutorStuckAfterMs should not be in MOVED_SETTINGS_KEYS",
).toBe(false);
const heartbeatPatrol = BUILTIN_OVERSIGHT_SETTINGS[6];
const heartbeatPatrol = BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerHeartbeatPatrolEnabled")!;
expect(heartbeatPatrol).toMatchObject({
id: "plannerHeartbeatPatrolEnabled",
type: "boolean",

View File

@@ -30,8 +30,16 @@ const IN_RANGE = "2026-06-15T12:00:00.000Z";
const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z" };
pgDescribe("issue analytics under a renamed board vocabulary", () => {
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
FN-8957 scoped analytics task reads to `layer.projectId`. This file aggregates under projectId
"p1", so the harness binds the same project: an unbound harness writes store rows under the GUC
default partition and the scoped aggregate reads zero rows — an isolation false-negative, not the
lane-vocabulary defect this file pins.
*/
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_issue_analytics_lanes",
projectId: "p1",
});
beforeAll(h.beforeAll);

View File

@@ -28,8 +28,16 @@ const IN_RANGE = "2026-06-15T12:00:00.000Z";
const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z" };
pgDescribe("productivity analytics under a renamed board vocabulary", () => {
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
FN-8957 scoped analytics task reads to `layer.projectId`. This file aggregates under projectId
"p1", so the harness binds the same project: an unbound harness writes store rows under the GUC
default partition and the scoped aggregate reads zero rows — an isolation false-negative, not the
lane-vocabulary defect this file pins.
*/
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_productivity_lanes",
projectId: "p1",
});
beforeAll(h.beforeAll);

View File

@@ -24,7 +24,13 @@ pgDescribe("project ownership runtime scope", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_project_ownership_runtime_scope",
});
const now = "2026-08-12T14:15:00.000Z";
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
Real wall-clock, not a literal: `decideApprovalRequest` rejects a pending request older than
APPROVAL_REQUEST_PENDING_TTL_MS against Date.now(), so a hardcoded seed date expired the day
after this file was written and turned the whole isolation case into a time bomb.
*/
const now = new Date().toISOString();
const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
beforeAll(h.beforeAll);

View File

@@ -101,6 +101,8 @@ import {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -135,7 +137,11 @@ describe("schema-applier: immutable migration identities", () => {
expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056");
expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057");
expect(MESSAGE_ARCHIVE_SCHEMA_VERSION).toBe("0058");
expect(SCHEMA_BASELINE_VERSION).toBe("0058");
/* FNXC:PgSchemaApplier 2026-08-15-22:10: 0059 (FN-9037 recommendation source-agent index) and
0060 (FN-9059 workspace coordination leases/intents) advance the baseline to 0060. */
expect(TASK_SOURCE_AGENT_INDEX_VERSION).toBe("0059");
expect(WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION).toBe("0060");
expect(SCHEMA_BASELINE_VERSION).toBe("0060");
});
it("keeps monitor and approval isolation assigned to version 0003", () => {
@@ -771,10 +777,11 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
outbox tables; 0041 adds 4 lifecycle consumer tables; 0043 adds the durable unplanned-dispatch
refusal marker (100 → 105); later baseline additions bring the count to 106; and 0048 adds
GitHub check state (106 → 107); 0049 adds the agent-activity outbox and counter (→ 109);
0050 adds immutable lock, evidence, and report history (109 → 112); 0052 adds recall records (→ 113). Plugin tables are added separately
0050 adds immutable lock, evidence, and report history (109 → 112); 0052 adds recall records (→ 113);
0060 adds workspace coordination leases and land intents (→ 115). Plugin tables are added separately
by the schema-init hook and are excluded here.
*/
expect(bySchema.project).toBe(113);
expect(bySchema.project).toBe(115);
/*
FNXC:CapacityModel 2026-07-29-08:10 (drop the cross-project cap — table half):
17, not 18: `central.global_concurrency` is dropped by migration 0037. A fresh
@@ -1644,7 +1651,13 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
updated_at text NOT NULL
);
/* FNXC:GitHubImportTranslate 2026-07-16-23:30: Later durable-task migrations run after this historical 0000 fixture, so retain their required task table surface. */
CREATE TABLE project.tasks (id text PRIMARY KEY);
/*
FNXC:PgSchemaApplier 2026-08-15-22:10:
Migration 0059 (FN-9037) builds a partial index on tasks(project_id, source_agent_id).
Real 0000 databases have source_agent_id (baseline since the PG cutover), so this
historical fixture must retain it; project_id arrives via the 0006 ownership migration.
*/
CREATE TABLE project.tasks (id text PRIMARY KEY, source_agent_id text);
/*
FNXC:Ideation 2026-07-18-13:25:
FN-8295 migration 0022 FKs ideation rows to missions/mission_features on (project_id, id).
@@ -1809,6 +1822,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1893,6 +1908,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
]);
});
@@ -2110,6 +2127,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
]);
});
@@ -2208,6 +2227,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
]);
});
@@ -2306,6 +2327,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
MESSAGE_ARCHIVE_SCHEMA_VERSION,
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
]);
});
});

View File

@@ -32,8 +32,18 @@ const IN_RANGE = "2026-06-15T12:00:00.000Z";
const RANGE = { from: "2026-06-01T00:00:00.000Z", to: "2026-06-30T23:59:59.999Z", now: Date.parse(IN_RANGE) };
pgDescribe("team analytics under a renamed board vocabulary", () => {
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
FN-8957 scoped every agent/task analytics read to `layer.projectId`. This file aggregates under
projectId "p1", so the harness must BIND that same project: an unbound harness writes store rows
under the GUC default partition ('__legacy_unscoped__' / '') and the scoped aggregate then reads
zero rows — an isolation false-negative, not the lane-vocabulary defect this file pins.
Raw adminDb seeds below carry an explicit project_id = 'p1' for the same reason (adminDb bypasses
the bound GUC, so column defaults would land the row in the legacy partition).
*/
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_team_analytics_lanes",
projectId: "p1",
});
beforeAll(h.beforeAll);
@@ -71,8 +81,8 @@ pgDescribe("team analytics under a renamed board vocabulary", () => {
const store = h.store();
const adminDb = h.adminDb();
await adminDb.execute(sql`
INSERT INTO project.agents (id, name, role, state, created_at, updated_at)
VALUES ('agent-1', 'Agent One', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE})`);
INSERT INTO project.agents (id, project_id, name, role, state, created_at, updated_at)
VALUES ('agent-1', 'p1', 'Agent One', 'executor', 'idle', ${IN_RANGE}, ${IN_RANGE})`);
for (const [id, lane] of [["KB-DONE", completeLane], ["KB-WIP", wipLane]] as const) {
await store.createTaskWithReservedId(

View File

@@ -52,8 +52,16 @@ const LEGACY_FLAGS = new Map<string, { countsTowardWip?: boolean; humanReview?:
]);
pgDescribe("workflow analytics under a renamed board vocabulary", () => {
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
FN-8957 scoped analytics task reads to `layer.projectId`. This file aggregates under projectId
"p1", so the harness binds the same project: an unbound harness writes store rows under the GUC
default partition and the scoped aggregate reads zero rows — an isolation false-negative, not the
lane-vocabulary defect this file pins.
*/
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_workflow_analytics_lanes",
projectId: "p1",
});
beforeAll(h.beforeAll);

View File

@@ -107,7 +107,15 @@ pgTest("workflow definition create (PostgreSQL backend mode)", () => {
const projectA = "proj_workflow_allocator_a";
const projectB = "proj_workflow_allocator_b";
const now = new Date().toISOString();
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
FN-8998 gave workflows composite (project_id, id) identity and project-scoped reads. The
occupied row must therefore be stamped into project A's partition explicitly — adminDb has no
bound GUC, so omitting project_id would strand the row in '__legacy_unscoped__' where the bound
project-A read below could never prove it survived.
*/
await h.adminDb().insert(schema.project.workflows).values({
projectId: projectA,
id: "WF-002",
name: "project A occupied workflow",
description: "",

View File

@@ -108,7 +108,13 @@ export function isTaskAwaitingPlanning(
FNXC:DuplicateIntake 2026-08-01-19:24:
A duplicate-only PROMPT is unplanned for execution — badge and triage must agree with
scheduler filesystem validation so the card shows "Queued to plan", not Ready.
FNXC:DuplicateIntake 2026-08-15-22:10:
`task.title` is a REQUIRED argument here, not an optional nicety: FN-8840 recognizes an exact
`DUPLICATE: <ID>` redirect declared in the task TITLE even when PROMPT.md carries a real plan.
A refactor once dropped the argument and title-only redirects silently read as executable
("Ready") on the badge/triage surface — do not remove it again.
*/
if (isDuplicateRedirectOnlyPrompt(promptContent)) return true;
if (isDuplicateRedirectOnlyPrompt(promptContent, task.title)) return true;
return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description);
}

View File

@@ -828,7 +828,16 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work
try {
await workflowDefinitionBeforeInsertForTesting?.(id, store.backendMode);
/*
FNXC:MultiProjectIsolation 2026-08-15-22:10:
Stamp the bound layer's project explicitly, like the FN-8997 workflowSteps insert does.
FN-8998 scopes every workflow-definition READ by `layer.projectId`; leaving the INSERT to
the session GUC default splits the write/read authority, so a layer bound in JS over a
bypass connection creates a row it can never read back ('' normalizes to the GUC/legacy
partition via the fusion_assign_project_id trigger, preserving unbound behavior).
*/
await store.asyncLayer!.db.insert(schema.project.workflows).values({
projectId: store.asyncLayer!.projectId?.trim() ?? "",
id: definition.id,
name: definition.name,
description: definition.description,