test: bind a project partition in the PG harness for built-in agent provisioning

FN-8764 made AgentStore.init() unconditionally provision the four durable
built-in workflow-owner agents, and that provisioning needs a bound
asyncLayer.projectId — backendProjectId rejects the empty/unbound partition so a
shared cluster cannot mix ownership. Every AgentStore-backed PG test therefore
threw in init(); without this change all 10 cases in agent-instructions.pg.test
fail at agent-store.ts:526.

createTaskStoreForTest / createSharedPgTaskStoreTestHarness gain an OPT-IN
projectId. Undefined keeps the historical project-agnostic harness (RLS bypass,
empty-string partition) that the rest of the core suite relies on. When set, the
connection GUC `fusion.project_id`, the AsyncDataLayer, and the seeded config row
all share one partition — so agents (explicit project_id) and their config
revisions (GUC-default project_id) land together and the
(project_id, agent_id) FK on agent_config_revisions holds.

Also folds in two already-merged consequences: serve.test expects the
consumerId: "engine" that serve.ts:326 already passes (FN-8685), and the
auto-generated Fusion skill docs pick up fn_workflow_step_resume, the roles /
max_workflow_sessions agent fields, and the deprecated singular role.

Uncommitted in the working tree; reviewed, verified against the real database,
and committed as-is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-07 17:33:56 -07:00
parent dd40691ca2
commit 0fda8b203b
10 changed files with 77 additions and 8 deletions

View File

@@ -28,7 +28,7 @@ Mission → Milestone → Slice → Feature → Task
**Tool categories:**
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_logs_read`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_bypass_review`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan`
- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select`
- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select`, `fn_workflow_step_resume`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show`

View File

@@ -305,6 +305,16 @@ Assign a workflow definition to a task by workflow ID.
| `workflow_id` | string | ✓ | The workflow definition ID to select (e.g. 'WF-003', or a 'builtin:*' id). Use fn_workflow_list to discover available IDs. |
| `task_id` | string | — | Task to assign the workflow to. Defaults to the current task. |
### fn_workflow_step_resume
Resume a stuck pending workflow step on an in-review or in-progress Fusion task (operator-only, mandatory reason, audit-logged). When a prompt node (like code-review) is dispatched but never receives a verdict callback (Runfusion/Fusion#1946), the step stays in 'pending' status indefinitely. This tool transitions it to 'failed', enabling the existing fn_task_bypass_review escape hatch to clear the merge blocker. Requires a mandatory reason and step ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID (e.g. FN-001) |
| `stepId` | string | ✓ | Workflow step ID to resume (e.g. 'code-review', 'plan-review') |
| `reason` | string | ✓ | Mandatory justification for resuming the step (audit-logged) |
## GitHub Tools
### fn_task_import_github
@@ -577,7 +587,8 @@ Create a new non-ephemeral agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | ✓ | Agent name |
| `role` | union | ✓ | Agent role/capability |
| `role` | union | — | Deprecated singular role; use roles for multi-role agents. |
| `roles` | array | — | Canonical permanent-agent role tags. |
| `soul` | string | — | Agent personality/identity text |
| `instructions_text` | string | — | Inline custom instructions |
| `instructions_path` | string | — | Path to instructions markdown |
@@ -585,6 +596,7 @@ Create a new non-ephemeral agent.
| `heartbeat_interval_ms` | number | — | |
| `heartbeat_timeout_ms` | number | — | |
| `max_concurrent_runs` | number | — | |
| `max_workflow_sessions` | number | — | Max concurrent workflow sessions, independent of heartbeat runs |
| `message_response_mode` | union | — | |
### fn_agent_update
@@ -595,7 +607,8 @@ Update editable configuration for an existing non-ephemeral agent. Agent callers
|-----------|------|----------|-------------|
| `agent_id` | string | ✓ | Target agent ID or name to update |
| `name` | string | — | New display name |
| `role` | union | — | Agent role/capability |
| `role` | union | — | Deprecated singular role; replaces roles for compatibility. |
| `roles` | array | — | Canonical permanent-agent role tags. |
| `title` | string | — | Optional title shown for the agent |
| `icon` | string | — | Optional compact icon/emoji |
| `soul` | string | — | Agent personality/identity text |
@@ -606,6 +619,7 @@ Update editable configuration for an existing non-ephemeral agent. Agent callers
| `heartbeat_interval_ms` | number | — | Heartbeat polling interval in ms |
| `heartbeat_timeout_ms` | number | — | Heartbeat timeout in ms |
| `max_concurrent_runs` | number | — | Max concurrent heartbeat runs |
| `max_workflow_sessions` | number | — | Max concurrent workflow sessions, independent of heartbeat runs |
| `message_response_mode` | union | — | How agent responds to messages |
### fn_agent_set_instructions

View File

@@ -31,6 +31,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_unpause` | Unpause a task — resumes automated agent and scheduler interaction. |
| `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. |
| `fn_task_bypass_review` | Policy-gated escape hatch for an in-review task stranded solely by a failed pre-merge review lane (leading real-world cause: the Runfusion/Fusion#1946 '(no feedback captured)' no-verdict dispatch defect), not a real REVISE. Rewrites the latest failed pre-merge WorkflowStepResult to a terminal non-blocking status with explicit bypass audit metadata (who/when/why/prior status) — it never fabricates a reviewer verdict. Requires a mandatory reason and is audit-logged. Clears ONLY the failed-pre-merge-step merge blocker; paused, incomplete-step, blocking-status, and still-pending conditions still block, and an autoMerge:false task is not force-merged. |
| `fn_workflow_step_resume` | Resume a stuck pending workflow step on an in-review or in-progress Fusion task (operator-only, mandatory reason, audit-logged). When a prompt node (like code-review) is dispatched but never receives a verdict callback (Runfusion/Fusion#1946), the step stays in 'pending' status indefinitely. This tool transitions it to 'failed', enabling the existing fn_task_bypass_review escape hatch to clear the merge blocker. Requires a mandatory reason and step ID. |
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed. |

View File

@@ -120,7 +120,19 @@ export interface PgExtensionHarness {
* entries never leak across tests.
*/
export function createPgExtensionHarness(prefix: string): PgExtensionHarness {
const pg = createSharedPgTaskStoreTestHarness({ prefix });
/*
FNXC:WorkflowAgentRouting 2026-08-07-18:40:
FN-8764 made AgentStore.init() unconditionally provision the four durable built-in
workflow-owner agents, and that provisioning requires a bound asyncLayer.projectId
(backendProjectId rejects the empty/unbound partition to avoid mixing ownership on a
shared PG cluster). Bind this CLI-extension harness to a real projectId end-to-end so the
connection GUC `fusion.project_id`, the layer's projectId, and the seeded config row all
agree: agents (explicit project_id) and their config revisions (GUC-default project_id)
land in the SAME partition, so the (project_id, agent_id) FK on agent_config_revisions holds.
A project-agnostic bind (projectId "") would split those writes across partitions and
reintroduce the FN-8764 provisioning throw.
*/
const pg = createSharedPgTaskStoreTestHarness({ prefix, projectId: `ext_${prefix}` });
return {
rootDir: pg.rootDir,
store: pg.store,

View File

@@ -983,8 +983,12 @@ describe("runServe", () => {
// FN-8399: serve now passes an onMigrationProgress callback so the holding
// server can expose incomplete migration status on the dashboard.
// FN-8685: serve also tags the shared store with the durable engine consumer
// identity (buildConsumerId("engine") === "engine") so its deletion cursor
// survives restart and cross-process deletes reach runtime observers.
expect(mocks.createTaskStoreForBackendMock).toHaveBeenCalledWith({
rootDir: "/repo",
consumerId: "engine",
onMigrationProgress: expect.any(Function),
});
expect(mocks.taskStoreCtor).toHaveBeenCalledTimes(1);

View File

@@ -693,9 +693,20 @@ export async function createTaskStoreForTest(options?: {
readonly poolMax?: number;
readonly prefix?: string;
readonly copyFromGolden?: boolean;
/*
FNXC:WorkflowAgentRouting 2026-08-07-18:40:
Opt-in project binding. Default (undefined) preserves the historical project-agnostic
harness that runs with RLS bypass and writes/reads the empty-string partition. When set,
the runtime connection is created with `fusion.project_id` (enforced RLS, no bypass) and
the AsyncDataLayer carries the same projectId, so explicit-project writes, GUC-default
writes, and reads all agree on one partition — required by FN-8764 built-in workflow-owner
provisioning during AgentStore.init().
*/
readonly projectId?: string;
}): Promise<PgTestHarness> {
const poolMax = options?.poolMax ?? 5;
const prefix = options?.prefix ?? "fusion_test";
const projectId = options?.projectId;
const dbName = uniqueDbName(prefix);
@@ -772,8 +783,9 @@ export async function createTaskStoreForTest(options?: {
const connections = await createConnectionSetFromUrl(schemaBackend, {
poolMax,
connectTimeoutSeconds: 5,
projectId,
});
const layer = createAsyncDataLayer(connections);
const layer = createAsyncDataLayer(connections, projectId ? { projectId } : undefined);
// Admin connection for direct row inspection/seeding in tests.
const adminSql = postgres(testUrl, {
@@ -936,7 +948,16 @@ const TRUNCATE_ALL_SQL = `TRUNCATE TABLE ${ALL_APPLICATION_TABLES.join(", ")} RE
export function createSharedPgTaskStoreTestHarness(options?: {
readonly poolMax?: number;
readonly prefix?: string;
/*
FNXC:WorkflowAgentRouting 2026-08-07-18:40:
Opt-in project binding threaded to createTaskStoreForTest and the config re-seed below.
Default undefined keeps the project-agnostic (projectId "") harness every existing core
test relies on; the CLI extension harness sets it so FN-8764 built-in workflow-owner
provisioning during AgentStore.init() has a bound projectId.
*/
readonly projectId?: string;
}): SharedPgTaskStoreHarness {
const boundProjectId = options?.projectId ?? "";
let harness: PgTestHarness | null = null;
let store: TaskStore | null = null;
// Lazily import DEFAULT_PROJECT_SETTINGS to avoid pulling the full types
@@ -1008,6 +1029,7 @@ export function createSharedPgTaskStoreTestHarness(options?: {
...options,
prefix: options?.prefix ?? "fusion_shared",
copyFromGolden: true,
projectId: options?.projectId,
});
store = harness.store;
},
@@ -1027,9 +1049,11 @@ export function createSharedPgTaskStoreTestHarness(options?: {
sql.raw(
// FNXC:MultiProjectIsolation 2026-07-11: config is keyed per-project on
// project_id (the PK) — id is no longer unique, so the upsert arbiter
// must be project_id. Harness stores run project-agnostic (projectId '').
// must be project_id. Harness stores run project-agnostic (projectId '')
// unless a bound projectId was requested (FNXC:WorkflowAgentRouting 2026-08-07-18:40),
// in which case the config row and all other writes share that partition.
`INSERT INTO ${PROJECT_SCHEMA}.config (id, project_id, next_id, next_workflow_step_id, settings, workflow_steps, updated_at)
VALUES (1, '', 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now())
VALUES (1, '${boundProjectId.replace(/'/g, "''")}', 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now())
ON CONFLICT (project_id) DO UPDATE SET next_id = 1, next_workflow_step_id = 1, settings = EXCLUDED.settings, workflow_steps = '[]'::jsonb, updated_at = now()`,
),
);

View File

@@ -7,8 +7,11 @@ import {
} from "../__test-utils__/pg-test-harness.js";
pgDescribe("AgentStore pause marker resume cleanup (FN-8569)", () => {
// FNXC:WorkflowAgentRouting 2026-08-07-18:40: bind a real projectId so FN-8764 built-in
// workflow-owner provisioning in AgentStore.init() has a partition.
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_pause_marker_clear",
projectId: "proj_pause_marker_clear",
});
let agentStore: AgentStore;

View File

@@ -23,8 +23,12 @@ import {
const pgTest = pgDescribe;
pgTest("task→agent routing policy (issue #2015)", () => {
// FNXC:WorkflowAgentRouting 2026-08-07-18:40: bind a real projectId so FN-8764 built-in
// workflow-owner provisioning in AgentStore.init() has a partition. The cross-project
// isolation case below uses a DISTINCT projectId so the two stores are genuinely separate.
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_agent_routing",
projectId: "proj_agent_routing",
});
beforeAll(h.beforeAll);
@@ -298,7 +302,7 @@ pgTest("task→agent routing policy (issue #2015)", () => {
describe("project isolation", () => {
it("an agent registered in another project's store can never be bound to this project's tasks", async () => {
const otherHarness = await createTaskStoreForTest({ prefix: "fusion_agent_routing_other" });
const otherHarness = await createTaskStoreForTest({ prefix: "fusion_agent_routing_other", projectId: "proj_agent_routing_other" });
const otherAgentStore = new AgentStore({ rootDir: otherHarness.rootDir, asyncLayer: otherHarness.layer, taskStore: otherHarness.store });
await otherAgentStore.init();

View File

@@ -23,8 +23,12 @@ import { AgentStore } from "../../agents/agent-store.js";
const pgTest = pgDescribe;
pgTest("AgentStore instructions fields (PostgreSQL)", () => {
// FNXC:WorkflowAgentRouting 2026-08-07-18:40: bind a real projectId so FN-8764 built-in
// workflow-owner provisioning in AgentStore.init() has a partition and agent config-revision
// writes (GUC-default project_id) share it with the explicit agent writes.
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_agent_instr",
projectId: "proj_agent_instr",
});
let agentStore: AgentStore;

View File

@@ -22,8 +22,11 @@ import { AgentStore } from "../../agents/agent-store.js";
const pgTest = pgDescribe;
pgTest("AgentStore.getAgent backs the async wake hook (PostgreSQL)", () => {
// FNXC:WorkflowAgentRouting 2026-08-07-18:40: bind a real projectId so FN-8764 built-in
// workflow-owner provisioning in AgentStore.init() has a partition.
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_agent_wake_getagent",
projectId: "proj_agent_wake",
});
let agentStore: AgentStore;