From f11bb2e899196932b061974b75dbf3fbe3df33a1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 15:22:33 -0700 Subject: [PATCH] fix(core): restore title-redirect planning hold and stamp workflow-create partition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- ...e-redirect-and-workflow-partition-fixes.md | 7 +++++ .../builtin-workflow-settings-triage.test.ts | 21 ++++++++++--- .../issue-analytics-renamed-lanes.pg.test.ts | 8 +++++ ...ctivity-analytics-renamed-lanes.pg.test.ts | 8 +++++ ...project-ownership-runtime-scope.pg.test.ts | 8 ++++- .../__tests__/postgres/schema-applier.test.ts | 31 ++++++++++++++++--- .../team-analytics-renamed-lanes.pg.test.ts | 14 +++++++-- ...orkflow-analytics-renamed-lanes.pg.test.ts | 8 +++++ .../postgres/workflow-create.pg.test.ts | 8 +++++ .../core/src/mesh/mesh-task-replication.ts | 8 ++++- .../core/src/task-store/project-store-ops.ts | 9 ++++++ 11 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 .changeset/kb-title-redirect-and-workflow-partition-fixes.md diff --git a/.changeset/kb-title-redirect-and-workflow-partition-fixes.md b/.changeset/kb-title-redirect-and-workflow-partition-fixes.md new file mode 100644 index 0000000000..5a37d14e30 --- /dev/null +++ b/.changeset/kb-title-redirect-and-workflow-partition-fixes.md @@ -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. diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index 9051dd1352..1fb1a43682 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -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", diff --git a/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts index a9b7acd1ca..7171170897 100644 --- a/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts +++ b/packages/core/src/__tests__/postgres/issue-analytics-renamed-lanes.pg.test.ts @@ -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); diff --git a/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts index 4c536568b5..02b3807d79 100644 --- a/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts +++ b/packages/core/src/__tests__/postgres/productivity-analytics-renamed-lanes.pg.test.ts @@ -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); diff --git a/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts b/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts index f2e3ff0151..0b67c7dd84 100644 --- a/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts +++ b/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts @@ -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); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 90e800336c..94187b1f47 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -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, ]); }); }); diff --git a/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts index 3279b21738..84d0801bd2 100644 --- a/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts +++ b/packages/core/src/__tests__/postgres/team-analytics-renamed-lanes.pg.test.ts @@ -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( diff --git a/packages/core/src/__tests__/postgres/workflow-analytics-renamed-lanes.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-analytics-renamed-lanes.pg.test.ts index 4a021e04eb..a579c9f662 100644 --- a/packages/core/src/__tests__/postgres/workflow-analytics-renamed-lanes.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workflow-analytics-renamed-lanes.pg.test.ts @@ -52,8 +52,16 @@ const LEGACY_FLAGS = new Map { + /* + 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); diff --git a/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts index 0e95a7a62f..95c70a5df1 100644 --- a/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts @@ -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: "", diff --git a/packages/core/src/mesh/mesh-task-replication.ts b/packages/core/src/mesh/mesh-task-replication.ts index 344fcf7e23..484fcc4ab6 100644 --- a/packages/core/src/mesh/mesh-task-replication.ts +++ b/packages/core/src/mesh/mesh-task-replication.ts @@ -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: ` 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); } diff --git a/packages/core/src/task-store/project-store-ops.ts b/packages/core/src/task-store/project-store-ops.ts index b7a1ea2e1e..9780665cd6 100644 --- a/packages/core/src/task-store/project-store-ops.ts +++ b/packages/core/src/task-store/project-store-ops.ts @@ -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,