diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index eac8f44eac..43ac50c368 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -28,7 +28,7 @@ Mission → Milestone → Slice → Feature → Task **Tool categories:** - **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` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 581bc7ba53..012256acdb 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -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 diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index cf65053e32..37514025d1 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -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. | diff --git a/packages/cli/src/__tests__/pg-extension-harness.ts b/packages/cli/src/__tests__/pg-extension-harness.ts index f2a8335dcb..4093584bdb 100644 --- a/packages/cli/src/__tests__/pg-extension-harness.ts +++ b/packages/cli/src/__tests__/pg-extension-harness.ts @@ -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, diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 12af1abb7a..528fb3c4a8 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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); diff --git a/packages/core/src/__test-utils__/pg-test-harness.ts b/packages/core/src/__test-utils__/pg-test-harness.ts index 162fa76ff9..d4a6d48802 100644 --- a/packages/core/src/__test-utils__/pg-test-harness.ts +++ b/packages/core/src/__test-utils__/pg-test-harness.ts @@ -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 { 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()`, ), ); diff --git a/packages/core/src/__tests__/agent-store-pause-marker-clear.test.ts b/packages/core/src/__tests__/agent-store-pause-marker-clear.test.ts index f75954f7ee..56d44ba813 100644 --- a/packages/core/src/__tests__/agent-store-pause-marker-clear.test.ts +++ b/packages/core/src/__tests__/agent-store-pause-marker-clear.test.ts @@ -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; diff --git a/packages/core/src/__tests__/agent-store-routing-policy.test.ts b/packages/core/src/__tests__/agent-store-routing-policy.test.ts index 0d75dd8986..86ab3e779e 100644 --- a/packages/core/src/__tests__/agent-store-routing-policy.test.ts +++ b/packages/core/src/__tests__/agent-store-routing-policy.test.ts @@ -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(); diff --git a/packages/core/src/__tests__/postgres/agent-instructions.pg.test.ts b/packages/core/src/__tests__/postgres/agent-instructions.pg.test.ts index 8e4d7b479b..ce99fb8b7d 100644 --- a/packages/core/src/__tests__/postgres/agent-instructions.pg.test.ts +++ b/packages/core/src/__tests__/postgres/agent-instructions.pg.test.ts @@ -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; diff --git a/packages/core/src/__tests__/postgres/agent-wake-getagent.pg.test.ts b/packages/core/src/__tests__/postgres/agent-wake-getagent.pg.test.ts index 5e80fbf2a6..be74f8f503 100644 --- a/packages/core/src/__tests__/postgres/agent-wake-getagent.pg.test.ts +++ b/packages/core/src/__tests__/postgres/agent-wake-getagent.pg.test.ts @@ -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;