FN-8830: backfill built-in workflow agent identities
Provision complete workflow-agent identities while preserving operator-owned configuration. - Seed instructions, souls, and managed setup mirrors for canonical workflow owners. - Repair sparse default owners and demote duplicate provenance without losing agent data. - Cover provisioning recovery and identity preservation with PostgreSQL and engine tests. Files changed: .changeset/fn-8830-workflow-agent-identities.md | 7 + docs/agents.md | 10 + ...nt-store-builtin-role-provisioning-pool.test.ts | 41 ++++ .../postgres/agent-instructions.pg.test.ts | 233 +++++++++++++++++++++ packages/core/src/agents/agent-store.ts | 167 ++++++++++++--- .../src/agents/workflow-role-agent-defaults.ts | 57 +++++ packages/core/src/index.ts | 6 + .../src/__tests__/agent-instructions.test.ts | 32 ++- 8 files changed, 526 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-8830 Fusion-Task-Lineage: bdf75989-3282-4833-a8b6-8c2997af8363 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8830-workflow-agent-identities.md
Normal file
7
.changeset/fn-8830-workflow-agent-identities.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Backfill detailed identities for built-in workflow agents.
|
||||
category: fix
|
||||
dev: Seeds non-destructive instructions, souls, and managed Markdown files while reconciling duplicate built-in provenance.
|
||||
@@ -969,6 +969,16 @@ fn agent mailbox AGENT-001
|
||||
|
||||
Worked manager/IC/message/blocked/no-task scenarios live in [Permanent Agent Heartbeat Playbooks](./agents-playbooks.md). Prefer those examples over re-deriving tick behavior from engine source.
|
||||
|
||||
## Built-in workflow owner identities
|
||||
|
||||
At startup, Fusion provisions one provenance-marked durable owner for each built-in workflow role: **Workflow Planner** (`triage`), **Workflow Executor**, **Workflow Reviewer**, and **Workflow Merger**. Each receives role-specific inline `instructionsText` and `soul` values. Those persisted fields are the runtime authority used when the engine builds an agent prompt.
|
||||
|
||||
Fusion also creates a managed setup mirror under each owner’s agent directory: `AGENTS.md` mirrors the default instruction text and `soul.md` mirrors the default soul. The files are intentionally not assigned to `instructionsPath`, so the same default identity is not composed twice at runtime. They are safe operator editing starting points, not an additional runtime source.
|
||||
|
||||
Provisioning is idempotent and non-destructive. A trimmed non-empty inline instruction, `instructionsPath`, external bundle, non-canonical managed bundle, non-empty soul, or non-empty managed mirror is operator-owned and is preserved. Sparse canonical owners receive only missing default fields/files; an incomplete default bundle is repaired without overwriting non-empty files. Files are materialized after the database transaction commits, so a filesystem failure is retryable on the next startup without duplicating owners.
|
||||
|
||||
If legacy data contains several provenance-marked owners for a supported role, Fusion retains the earliest valid `createdAt` row (then lexicographically smallest ID as a tie-breaker). It removes only the built-in provenance keys from the other rows, preserving them as ordinary durable agents with their names, roles, identity, policies, settings, metadata, and files intact. Agents with missing or unsupported provenance roles, and same-role agents without built-in provenance, are never adopted or changed by this repair.
|
||||
|
||||
## Heartbeat Prompt Composition and Autonomous Run Behavior
|
||||
|
||||
Heartbeat runs are composed from multiple prompt layers so each wake has full identity and operating context:
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { AgentStore } from "../agents/agent-store.js";
|
||||
import { getCanonicalAgentInstructionsBundleDirName } from "../types.js";
|
||||
import {
|
||||
BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG,
|
||||
BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST,
|
||||
} from "../agents/workflow-role-agent-defaults.js";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../__test-utils__/pg-test-harness.js";
|
||||
|
||||
/*
|
||||
@@ -50,7 +57,14 @@ pgDescribe("AgentStore built-in workflow role provisioning under a saturated poo
|
||||
"triage",
|
||||
]);
|
||||
for (const agent of agents) {
|
||||
const definition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === agent.metadata?.workflowRole);
|
||||
expect(agent.metadata?.builtInWorkflowRole).toBe(true);
|
||||
expect(definition).toBeDefined();
|
||||
expect(agent.instructionsText).toBe(definition!.instructionsText);
|
||||
expect(agent.soul).toBe(definition!.soul);
|
||||
expect(agent.instructionsPath).toBeUndefined();
|
||||
expect(agent.bundleConfig).toEqual(BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG);
|
||||
expect(await agentStore.listBundleFiles(agent.id)).toEqual(["AGENTS.md", "soul.md"]);
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
@@ -68,6 +82,33 @@ pgDescribe("AgentStore built-in workflow role provisioning under a saturated poo
|
||||
expect(durable).toHaveLength(4);
|
||||
}, 20_000);
|
||||
|
||||
it("retries managed mirror materialization after a post-transaction filesystem failure", async () => {
|
||||
const first = await agentStore.provisionBuiltinWorkflowRoleAgents();
|
||||
const planner = first.find((agent) => agent.metadata?.workflowRole === "triage")!;
|
||||
const bundleDir = join(
|
||||
harness.rootDir,
|
||||
"agents",
|
||||
getCanonicalAgentInstructionsBundleDirName(planner.name, planner.id),
|
||||
);
|
||||
const entryPath = join(bundleDir, "AGENTS.md");
|
||||
|
||||
// FNXC:WorkflowAgentIdentities 2026-08-08-06:27: A directory at the entry-file path
|
||||
// deterministically fails the post-commit write without adding sleeps or mock-only behavior.
|
||||
await rm(entryPath, { force: true });
|
||||
await mkdir(entryPath);
|
||||
await expect(agentStore.provisionBuiltinWorkflowRoleAgents()).rejects.toThrow();
|
||||
|
||||
const durableAfterFailure = (await agentStore.listAgents({ includeEphemeral: true })).filter(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true,
|
||||
);
|
||||
expect(durableAfterFailure).toHaveLength(4);
|
||||
|
||||
await rm(entryPath, { recursive: true, force: true });
|
||||
const retried = await agentStore.provisionBuiltinWorkflowRoleAgents();
|
||||
expect(retried.map((agent) => agent.id).sort()).toEqual(first.map((agent) => agent.id).sort());
|
||||
expect(await agentStore.listBundleFiles(planner.id)).toEqual(["AGENTS.md", "soul.md"]);
|
||||
}, 20_000);
|
||||
|
||||
it("serializes concurrent callers without deadlocking or duplicating owners", async () => {
|
||||
// The original failure needed >1 in-flight caller. Even serialized behind a
|
||||
// single connection, concurrent callers must converge on one set of owners.
|
||||
|
||||
@@ -13,12 +13,18 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as postgresSchema from "../../postgres/schema/index.js";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import { AgentStore } from "../../agents/agent-store.js";
|
||||
import { getCanonicalAgentInstructionsBundleDirName } from "../../types.js";
|
||||
import { BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST } from "../../agents/workflow-role-agent-defaults.js";
|
||||
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
@@ -55,6 +61,233 @@ pgTest("AgentStore instructions fields (PostgreSQL)", () => {
|
||||
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("backfills sparse built-in workflow owners during startup without changing custom sources", async () => {
|
||||
const planner = (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.workflowRole === "triage",
|
||||
)!;
|
||||
await agentStore.updateAgent(planner.id, { instructionsText: "", soul: "" });
|
||||
await agentStore.init();
|
||||
const repaired = await agentStore.getAgent(planner.id);
|
||||
const definition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "triage")!;
|
||||
expect(repaired?.instructionsText).toBe(definition.instructionsText);
|
||||
expect(repaired?.soul).toBe(definition.soul);
|
||||
|
||||
const executor = (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.workflowRole === "executor",
|
||||
)!;
|
||||
await agentStore.updateAgent(executor.id, { instructionsText: "operator instructions" });
|
||||
await agentStore.init();
|
||||
expect((await agentStore.getAgent(executor.id))?.instructionsText).toBe("operator instructions");
|
||||
});
|
||||
|
||||
it("deterministically demotes duplicate built-in provenance without losing identity", async () => {
|
||||
const original = (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.workflowRole === "reviewer",
|
||||
)!;
|
||||
const duplicate = await agentStore.createAgent({
|
||||
name: "Legacy reviewer duplicate",
|
||||
role: "reviewer",
|
||||
instructionsText: "preserve this duplicate instruction",
|
||||
soul: "preserve this duplicate soul",
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: "reviewer", retained: "yes" },
|
||||
runtimeConfig: { enabled: false, custom: true },
|
||||
});
|
||||
await agentStore.init();
|
||||
const agents = await agentStore.listAgents({ includeEphemeral: true });
|
||||
const owners = agents.filter((agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === "reviewer");
|
||||
expect(owners).toEqual([expect.objectContaining({ id: original.id })]);
|
||||
expect(await agentStore.getAgent(duplicate.id)).toMatchObject({
|
||||
instructionsText: "preserve this duplicate instruction",
|
||||
soul: "preserve this duplicate soul",
|
||||
metadata: { retained: "yes" },
|
||||
runtimeConfig: { enabled: false, custom: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs startup defaults while preserving custom, duplicate, and malformed provenance rows", async () => {
|
||||
const ownerFor = async (role: string) => (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === role,
|
||||
)!;
|
||||
const bundleDirFor = (agent: { id: string; name: string }) => join(
|
||||
h.rootDir(),
|
||||
"agents",
|
||||
getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id),
|
||||
);
|
||||
const planner = await ownerFor("triage");
|
||||
const executor = await ownerFor("executor");
|
||||
const reviewer = await ownerFor("reviewer");
|
||||
const merger = await ownerFor("merger");
|
||||
const plannerDefinition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "triage")!;
|
||||
const mergerDefinition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "merger")!;
|
||||
|
||||
// A blank, partial default-owned bundle is eligible for startup repair.
|
||||
await agentStore.updateAgent(planner.id, {
|
||||
instructionsText: " ",
|
||||
soul: "\t",
|
||||
bundleConfig: { mode: "managed", entryFile: "AGENTS.md", files: ["AGENTS.md"] },
|
||||
});
|
||||
await rm(bundleDirFor(planner), { recursive: true, force: true });
|
||||
|
||||
// A custom path remains authoritative and must not receive competing default mirrors.
|
||||
await agentStore.updateAgent(executor.id, { instructionsText: "", instructionsPath: "operator-executor.md" });
|
||||
await rm(bundleDirFor(executor), { recursive: true, force: true });
|
||||
|
||||
// External bundles and edited managed files are operator-owned and survive startup byte-for-byte.
|
||||
await agentStore.updateAgent(reviewer.id, {
|
||||
instructionsText: "operator reviewer instructions",
|
||||
bundleConfig: { mode: "external", entryFile: "team-review.md", files: ["team-review.md"] },
|
||||
});
|
||||
await agentStore.writeBundleFile(merger.id, "AGENTS.md", "managed operator instructions");
|
||||
await agentStore.writeBundleFile(merger.id, "soul.md", "managed operator soul");
|
||||
await agentStore.updateAgent(merger.id, { soul: "" });
|
||||
|
||||
const duplicate = await agentStore.createAgent({
|
||||
name: "Legacy duplicate reviewer",
|
||||
roles: ["reviewer"],
|
||||
instructionsText: "duplicate instructions",
|
||||
soul: "duplicate soul",
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: "reviewer", retained: "metadata" },
|
||||
runtimeConfig: { enabled: false, retained: true },
|
||||
});
|
||||
const malformed = await agentStore.createAgent({
|
||||
name: "Malformed workflow owner",
|
||||
roles: ["reviewer"],
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: "not-a-supported-role", retained: "malformed" },
|
||||
});
|
||||
const customReviewer = await agentStore.createAgent({
|
||||
name: "Ordinary reviewer",
|
||||
roles: ["reviewer"],
|
||||
instructionsText: "ordinary reviewer instructions",
|
||||
metadata: { workflowRole: "reviewer", retained: "ordinary" },
|
||||
});
|
||||
|
||||
await agentStore.init();
|
||||
|
||||
const repairedPlanner = await agentStore.getAgent(planner.id);
|
||||
expect(repairedPlanner).toMatchObject({
|
||||
instructionsText: plannerDefinition.instructionsText,
|
||||
soul: plannerDefinition.soul,
|
||||
bundleConfig: { mode: "managed", entryFile: "AGENTS.md", files: ["AGENTS.md", "soul.md"] },
|
||||
});
|
||||
await expect(readFile(join(bundleDirFor(planner), "AGENTS.md"), "utf8")).resolves.toBe(plannerDefinition.instructionsText);
|
||||
await expect(readFile(join(bundleDirFor(planner), "soul.md"), "utf8")).resolves.toBe(plannerDefinition.soul);
|
||||
|
||||
expect(await agentStore.getAgent(executor.id)).toMatchObject({ instructionsText: "", instructionsPath: "operator-executor.md" });
|
||||
expect(await agentStore.listBundleFiles(executor.id)).toEqual([]);
|
||||
expect(await agentStore.getAgent(reviewer.id)).toMatchObject({
|
||||
instructionsText: "operator reviewer instructions",
|
||||
bundleConfig: { mode: "external", entryFile: "team-review.md", files: ["team-review.md"] },
|
||||
});
|
||||
expect(await readFile(join(bundleDirFor(merger), "AGENTS.md"), "utf8")).toBe("managed operator instructions");
|
||||
expect(await readFile(join(bundleDirFor(merger), "soul.md"), "utf8")).toBe("managed operator soul");
|
||||
expect((await agentStore.getAgent(merger.id))?.soul).toBe(mergerDefinition.soul);
|
||||
|
||||
const reviewerOwners = (await agentStore.listAgents({ includeEphemeral: true })).filter(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === "reviewer",
|
||||
);
|
||||
expect(reviewerOwners).toEqual([expect.objectContaining({ id: reviewer.id })]);
|
||||
expect(await agentStore.getAgent(duplicate.id)).toMatchObject({
|
||||
instructionsText: "duplicate instructions",
|
||||
soul: "duplicate soul",
|
||||
metadata: { retained: "metadata" },
|
||||
runtimeConfig: { enabled: false, retained: true },
|
||||
});
|
||||
expect((await agentStore.getAgent(malformed.id))?.metadata).toMatchObject({
|
||||
builtInWorkflowRole: true, workflowRole: "not-a-supported-role", retained: "malformed",
|
||||
});
|
||||
expect(await agentStore.getAgent(customReviewer.id)).toMatchObject({
|
||||
instructionsText: "ordinary reviewer instructions",
|
||||
metadata: { workflowRole: "reviewer", retained: "ordinary" },
|
||||
});
|
||||
|
||||
// A rerun is a stable no-op after reconciliation and repair.
|
||||
await agentStore.init();
|
||||
expect(await agentStore.getAgent(duplicate.id)).toMatchObject({ metadata: { retained: "metadata" } });
|
||||
expect(await agentStore.listBundleFiles(executor.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a canonical owner's customized managed bundle during startup", async () => {
|
||||
const merger = (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === "merger",
|
||||
)!;
|
||||
const customBundle = { mode: "managed" as const, entryFile: "operator.md", files: ["operator.md"] };
|
||||
await agentStore.updateAgent(merger.id, {
|
||||
instructionsText: "",
|
||||
soul: "",
|
||||
bundleConfig: customBundle,
|
||||
});
|
||||
await agentStore.writeBundleFile(merger.id, "operator.md", "operator-owned managed instructions");
|
||||
|
||||
await agentStore.init();
|
||||
|
||||
const definition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "merger")!;
|
||||
expect(await agentStore.getAgent(merger.id)).toMatchObject({
|
||||
instructionsText: "",
|
||||
soul: definition.soul,
|
||||
bundleConfig: customBundle,
|
||||
});
|
||||
await expect(agentStore.readBundleFile(merger.id, "operator.md")).resolves.toBe("operator-owned managed instructions");
|
||||
});
|
||||
|
||||
it("upgrades sparse owners and deterministically demotes reverse-inserted provenance duplicates", async () => {
|
||||
const existingReviewer = (await agentStore.listAgents({ includeEphemeral: true })).find(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === "reviewer",
|
||||
)!;
|
||||
await agentStore.deleteAgent(existingReviewer.id);
|
||||
|
||||
// Insert the newer row first so database insertion/query order cannot select the winner.
|
||||
const newer = await agentStore.createAgent({
|
||||
name: "newer reviewer owner",
|
||||
roles: ["reviewer"],
|
||||
instructionsText: "keep newer inline identity",
|
||||
soul: "keep newer soul",
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: "reviewer", retained: "newer" },
|
||||
runtimeConfig: { enabled: false, retainedRuntime: true },
|
||||
});
|
||||
const older = await agentStore.createAgent({
|
||||
name: "older reviewer owner",
|
||||
roles: ["reviewer"],
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: "reviewer", retained: "older" },
|
||||
});
|
||||
// FNXC:WorkflowAgentIdentities 2026-08-08-06:38: The persisted indexed timestamp,
|
||||
// rather than insertion/query order, defines the canonical provenance owner.
|
||||
await h.layer().db.update(postgresSchema.project.agents)
|
||||
.set({ createdAt: "2026-01-02T00:00:00.000Z" })
|
||||
.where(and(eq(postgresSchema.project.agents.projectId, "proj_agent_instr"), eq(postgresSchema.project.agents.id, newer.id)));
|
||||
await h.layer().db.update(postgresSchema.project.agents)
|
||||
.set({ createdAt: "2026-01-01T00:00:00.000Z" })
|
||||
.where(and(eq(postgresSchema.project.agents.projectId, "proj_agent_instr"), eq(postgresSchema.project.agents.id, older.id)));
|
||||
|
||||
// This is the production upgrade entry point, not a helper-only reconciliation test.
|
||||
await agentStore.init();
|
||||
|
||||
const reviewerDefinition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "reviewer")!;
|
||||
const owners = (await agentStore.listAgents({ includeEphemeral: true })).filter(
|
||||
(agent) => agent.metadata?.builtInWorkflowRole === true && agent.metadata?.workflowRole === "reviewer",
|
||||
);
|
||||
expect(owners).toEqual([expect.objectContaining({ id: older.id })]);
|
||||
expect(await agentStore.getAgent(older.id)).toMatchObject({
|
||||
instructionsText: reviewerDefinition.instructionsText,
|
||||
soul: reviewerDefinition.soul,
|
||||
bundleConfig: { mode: "managed", entryFile: "AGENTS.md", files: ["AGENTS.md", "soul.md"] },
|
||||
});
|
||||
expect(await agentStore.listBundleFiles(older.id)).toEqual(["AGENTS.md", "soul.md"]);
|
||||
expect(await agentStore.getAgent(newer.id)).toMatchObject({
|
||||
name: "newer reviewer owner",
|
||||
instructionsText: "keep newer inline identity",
|
||||
soul: "keep newer soul",
|
||||
metadata: { retained: "newer" },
|
||||
runtimeConfig: { enabled: false, retainedRuntime: true },
|
||||
});
|
||||
|
||||
// The repeat startup is a no-op: demotion is not retried or allowed to change identity data.
|
||||
await agentStore.init();
|
||||
expect(await agentStore.getAgent(newer.id)).toMatchObject({
|
||||
instructionsText: "keep newer inline identity",
|
||||
metadata: { retained: "newer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an agent with instructionsText", async () => {
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "instr-text-agent",
|
||||
|
||||
@@ -103,9 +103,41 @@ import {
|
||||
} from "../async-stores/async-agent-store.js";
|
||||
import { createLogger } from "../process/logger.js";
|
||||
import { FsWatchPollController } from "../process/fs-watch-poll-controller.js";
|
||||
import {
|
||||
BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG,
|
||||
BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST,
|
||||
type BuiltinWorkflowRole,
|
||||
} from "./workflow-role-agent-defaults.js";
|
||||
|
||||
const agentStoreLog = createLogger("agent-store");
|
||||
|
||||
/*
|
||||
FNXC:WorkflowAgentIdentities 2026-08-08-06:11:
|
||||
Only a unique subset of the two managed mirror names is an incomplete default bundle eligible for
|
||||
repair. Extra or duplicate inventory entries are operator-owned configuration and must not be
|
||||
silently normalized during startup.
|
||||
*/
|
||||
function isCanonicalOrPartialBuiltinWorkflowBundle(config: InstructionsBundleConfig | undefined): boolean {
|
||||
if (config?.mode !== "managed" || config.entryFile !== "AGENTS.md") return false;
|
||||
return config.files.every((file) => (file === "AGENTS.md" || file === "soul.md")
|
||||
&& config.files.indexOf(file) === config.files.lastIndexOf(file));
|
||||
}
|
||||
|
||||
function isCompleteBuiltinWorkflowBundle(config: InstructionsBundleConfig | undefined): boolean {
|
||||
return isCanonicalOrPartialBuiltinWorkflowBundle(config)
|
||||
&& config!.files.length === BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG.files.length;
|
||||
}
|
||||
|
||||
function compareBuiltinWorkflowOwnerAge(a: Agent, b: Agent): number {
|
||||
const aTime = Date.parse(a.createdAt);
|
||||
const bTime = Date.parse(b.createdAt);
|
||||
const aValid = Number.isFinite(aTime);
|
||||
const bValid = Number.isFinite(bTime);
|
||||
if (aValid && bValid && aTime !== bTime) return aTime - bTime;
|
||||
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
/** Database row shape returned by SELECT on agentRatings. */
|
||||
interface AgentRatingRow {
|
||||
id: string;
|
||||
@@ -1983,37 +2015,77 @@ export class AgentStore extends EventEmitter {
|
||||
*/
|
||||
async provisionBuiltinWorkflowRoleAgents(): Promise<Agent[]> {
|
||||
const provision = async (executor?: QueryHandle): Promise<Agent[]> => {
|
||||
const definitions: ReadonlyArray<{ role: AgentCapability; name: string; title: string }> = [
|
||||
{ role: "triage", name: "Workflow Planner", title: "Built-in workflow planning owner" },
|
||||
{ role: "executor", name: "Workflow Executor", title: "Built-in workflow execution owner" },
|
||||
{ role: "reviewer", name: "Workflow Reviewer", title: "Built-in workflow review owner" },
|
||||
{ role: "merger", name: "Workflow Merger", title: "Built-in workflow merge owner" },
|
||||
];
|
||||
const existing = await this.listAgents({ includeEphemeral: true }, executor);
|
||||
const builtins = new Map(
|
||||
existing
|
||||
.filter((agent) => agent.metadata?.builtInWorkflowRole === true)
|
||||
.map((agent) => [agent.metadata.workflowRole as AgentCapability, agent]),
|
||||
);
|
||||
const result: Agent[] = [];
|
||||
for (const definition of definitions) {
|
||||
const present = builtins.get(definition.role);
|
||||
if (present) {
|
||||
result.push(present);
|
||||
continue;
|
||||
const supportedRoles = new Set<BuiltinWorkflowRole>(BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.map(({ role }) => role));
|
||||
const groups = new Map<BuiltinWorkflowRole, Agent[]>();
|
||||
for (const agent of existing) {
|
||||
const role = agent.metadata?.workflowRole;
|
||||
if (agent.metadata?.builtInWorkflowRole === true && typeof role === "string" && supportedRoles.has(role as BuiltinWorkflowRole)) {
|
||||
const group = groups.get(role as BuiltinWorkflowRole) ?? [];
|
||||
group.push(agent);
|
||||
groups.set(role as BuiltinWorkflowRole, group);
|
||||
}
|
||||
result.push(await this.createAgent({
|
||||
name: definition.name,
|
||||
roles: [definition.role],
|
||||
title: definition.title,
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: definition.role },
|
||||
// Disabled scheduling does not make the agent unavailable for graph sessions.
|
||||
runtimeConfig: { enabled: false },
|
||||
}, executor));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowAgentIdentities 2026-08-08-06:11:
|
||||
Corrupt legacy data can contain multiple provenance owners. Select by creation time then id,
|
||||
not query order, and demote only the two built-in provenance keys so every losing durable
|
||||
agent remains usable with its operator-owned identity and policy intact.
|
||||
*/
|
||||
const winners = new Map<BuiltinWorkflowRole, Agent>();
|
||||
for (const [role, group] of groups) {
|
||||
group.sort(compareBuiltinWorkflowOwnerAge);
|
||||
winners.set(role, group[0]!);
|
||||
for (const loser of group.slice(1)) {
|
||||
const { builtInWorkflowRole: _builtIn, workflowRole: _role, ...metadata } = loser.metadata;
|
||||
await this.writeAgent({ ...loser, metadata, updatedAt: new Date().toISOString() }, executor);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Agent[] = [];
|
||||
for (const definition of BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST) {
|
||||
let agent = winners.get(definition.role);
|
||||
if (!agent) {
|
||||
agent = await this.createAgent({
|
||||
name: definition.name,
|
||||
roles: [definition.role],
|
||||
title: definition.title,
|
||||
metadata: { builtInWorkflowRole: true, workflowRole: definition.role },
|
||||
runtimeConfig: { enabled: false },
|
||||
instructionsText: definition.instructionsText,
|
||||
soul: definition.soul,
|
||||
bundleConfig: { ...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG, files: [...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG.files] },
|
||||
}, executor);
|
||||
} else {
|
||||
const canonicalOrPartialBundle = isCanonicalOrPartialBuiltinWorkflowBundle(agent.bundleConfig);
|
||||
const customInstructions = Boolean(agent.instructionsPath?.trim())
|
||||
|| agent.bundleConfig?.mode === "external"
|
||||
|| (agent.bundleConfig !== undefined && !canonicalOrPartialBundle)
|
||||
|| (Boolean(agent.instructionsText?.trim()) && agent.instructionsText !== definition.instructionsText);
|
||||
const updates: Partial<Agent> = {};
|
||||
if (!customInstructions && !agent.instructionsText?.trim()) updates.instructionsText = definition.instructionsText;
|
||||
if (!agent.soul?.trim()) updates.soul = definition.soul;
|
||||
// FNXC:WorkflowAgentIdentities 2026-08-08-06:38: Do not rewrite complete canonical
|
||||
// rows on every startup; partial default-owned inventories converge once, while
|
||||
// noncanonical inventories remain operator-owned.
|
||||
if (!customInstructions && !isCompleteBuiltinWorkflowBundle(agent.bundleConfig)) {
|
||||
updates.bundleConfig = { ...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG, files: [...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG.files] };
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
agent = { ...agent, ...updates, updatedAt: new Date().toISOString() };
|
||||
await this.writeAgent(agent, executor);
|
||||
}
|
||||
}
|
||||
result.push(agent);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
if (!this.asyncLayer) return provision();
|
||||
if (!this.asyncLayer) {
|
||||
const agents = await provision();
|
||||
await Promise.all(agents.map((agent) => this.materializeBuiltinWorkflowRoleBundle(agent)));
|
||||
return agents;
|
||||
}
|
||||
/*
|
||||
* FNXC:WorkflowAgentRouting 2026-08-07-07:16:
|
||||
* Startup and onboarding can run in separate engine processes. Serialize the
|
||||
@@ -2032,10 +2104,18 @@ export class AgentStore extends EventEmitter {
|
||||
* the lock, and every later query — i.e. every DB-backed API route — queues
|
||||
* forever behind an exhausted pool. Keep the lock and the work on one connection.
|
||||
*/
|
||||
return this.asyncLayer.transactionImmediate(async (tx) => {
|
||||
const agents = await this.asyncLayer.transactionImmediate(async (tx) => {
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${this.backendProjectId}), hashtext('builtin-workflow-role-provisioning'))`);
|
||||
return provision(tx);
|
||||
});
|
||||
/*
|
||||
FNXC:WorkflowAgentIdentities 2026-08-08-06:11:
|
||||
Commit durable ownership before writing managed mirrors. A filesystem failure leaves a complete,
|
||||
retryable row state; later provisioning can converge without holding an agent file lock while it
|
||||
waits for the project advisory lock.
|
||||
*/
|
||||
await Promise.all(agents.map((agent) => this.materializeBuiltinWorkflowRoleBundle(agent)));
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2827,6 +2907,41 @@ export class AgentStore extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Seed only missing or blank default-owned mirror files after the provisioning transaction commits. */
|
||||
private async materializeBuiltinWorkflowRoleBundle(agent: Agent): Promise<void> {
|
||||
// FNXC:WorkflowAgentIdentities 2026-08-08-06:38: Take the per-agent file lock only after
|
||||
// the advisory transaction commits, serializing default seeding with dashboard file edits
|
||||
// without creating inverse DB/file waits.
|
||||
return this.withLock(agent.id, async () => {
|
||||
const role = agent.metadata?.workflowRole;
|
||||
const definition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((candidate) => candidate.role === role);
|
||||
if (!definition || agent.metadata?.builtInWorkflowRole !== true) return;
|
||||
const config = agent.bundleConfig;
|
||||
if (!isCanonicalOrPartialBuiltinWorkflowBundle(config)) return;
|
||||
/*
|
||||
FNXC:WorkflowAgentIdentities 2026-08-08-06:27:
|
||||
A non-default inline body or any path is an operator-owned instruction source, even when a
|
||||
legacy canonical mirror config remains on the row. Do not create default mirror files beside
|
||||
that source: startup may repair default-owned rows, but it must never inject a competing setup.
|
||||
*/
|
||||
if (agent.instructionsPath?.trim()
|
||||
|| (agent.instructionsText?.trim() && agent.instructionsText !== definition.instructionsText)) return;
|
||||
// FNXC:WorkflowAgentIdentities 2026-08-08-06:38: Follow the public bundle APIs'
|
||||
// legacy/display-name compatibility rule so upgrades never strand existing managed files
|
||||
// in a second directory.
|
||||
const bundleDir = await this.resolveCompatibleBundleDir(agent.id, true);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
for (const [file, content] of [["AGENTS.md", definition.instructionsText], ["soul.md", definition.soul]] as const) {
|
||||
const path = join(bundleDir, file);
|
||||
let current = "";
|
||||
try { current = await readFile(path, "utf-8"); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
if (!current.trim()) await writeFile(path, content, "utf-8");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getCanonicalBundleDir(agent: Agent): string {
|
||||
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id));
|
||||
}
|
||||
|
||||
57
packages/core/src/agents/workflow-role-agent-defaults.ts
Normal file
57
packages/core/src/agents/workflow-role-agent-defaults.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AgentCapability, InstructionsBundleConfig } from "../types.js";
|
||||
|
||||
export type BuiltinWorkflowRole = Extract<AgentCapability, "triage" | "executor" | "reviewer" | "merger">;
|
||||
|
||||
export interface WorkflowRoleAgentDefault {
|
||||
readonly role: BuiltinWorkflowRole;
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly instructionsText: string;
|
||||
readonly soul: string;
|
||||
}
|
||||
|
||||
export const BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG: Readonly<InstructionsBundleConfig> = {
|
||||
mode: "managed",
|
||||
entryFile: "AGENTS.md",
|
||||
files: ["AGENTS.md", "soul.md"],
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:WorkflowAgentIdentities 2026-08-08-06:11:
|
||||
Built-in workflow principals need useful, role-specific identities on first creation and when an
|
||||
older project has sparse provenance-marked owners. Runtime authority remains the persisted inline
|
||||
instructionsText and soul fields; these stable AGENTS.md and soul.md files are editable managed
|
||||
setup mirrors and must not be assigned as instructionsPath, which would duplicate the prompt.
|
||||
*/
|
||||
export const BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULTS: Readonly<Record<BuiltinWorkflowRole, WorkflowRoleAgentDefault>> = {
|
||||
triage: {
|
||||
role: "triage",
|
||||
name: "Workflow Planner",
|
||||
title: "Built-in workflow planning owner",
|
||||
instructionsText: "You are the Workflow Planner. Turn incoming work into clear, bounded, testable plans. Identify the user outcome, constraints, affected surfaces, and acceptance evidence before release. Keep planning distinct from implementation: do not claim code is complete, invent decisions, or bypass approval gates. Communicate concisely, make uncertainty explicit, and leave an executable plan that another agent can follow.",
|
||||
soul: "Calm, structured, and curious. You reduce ambiguity without adding ceremony, respect operator intent, and prefer concrete evidence over confident guesses.",
|
||||
},
|
||||
executor: {
|
||||
role: "executor",
|
||||
name: "Workflow Executor",
|
||||
title: "Built-in workflow execution owner",
|
||||
instructionsText: "You are the Workflow Executor. Implement the approved scope with production-quality, maintainable changes. Read the task specification, preserve existing behavior outside scope, and verify the smallest relevant checks before reporting completion. Do not redesign workflow policy, broaden permissions, or conceal blockers; record concrete evidence and surface a genuine dependency when work cannot proceed. Communicate changes and verification precisely.",
|
||||
soul: "Methodical, practical, and accountable. You favor small safe steps, protect user work, and let tests and observable behavior guide your confidence.",
|
||||
},
|
||||
reviewer: {
|
||||
role: "reviewer",
|
||||
name: "Workflow Reviewer",
|
||||
title: "Built-in workflow review owner",
|
||||
instructionsText: "You are the Workflow Reviewer. Independently assess whether the submitted work satisfies its specification, preserves system invariants, and has credible verification. Focus on correctness, regressions, security, maintainability, and missing evidence rather than rewriting the implementation. State actionable findings with severity and rationale; approve only when the evidence supports it. Do not perform implementation or override workflow gates.",
|
||||
soul: "Independent, exacting, and fair. You are skeptical of unsupported claims, respectful in feedback, and clear about what would make the work safe to accept.",
|
||||
},
|
||||
merger: {
|
||||
role: "merger",
|
||||
name: "Workflow Merger",
|
||||
title: "Built-in workflow merge owner",
|
||||
instructionsText: "You are the Workflow Merger. Integrate only work that has met the project’s configured completion and review requirements. Protect the target branch, inspect merge state and conflicts carefully, and preserve unrelated work. Do not substitute judgment for missing approval, bypass safeguards, or silently discard changes. Report the integration result, any conflicts, and the evidence used to make the decision.",
|
||||
soul: "Deliberate, conservative, and transparent. You optimize for repository integrity, treat uncertainty as a reason to stop, and communicate operational state plainly.",
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST: readonly WorkflowRoleAgentDefault[] = Object.values(BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULTS);
|
||||
@@ -40,6 +40,12 @@ export type {
|
||||
} from "./tasks/symbol-lock-lineage-approval.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError, PLANNER_AGENT_ROLE} from "./types.js";
|
||||
export { WEDGE_RENOTIFY_COOLDOWN_MS, normalizeAgentRoles } from "./types.js";
|
||||
export {
|
||||
BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG,
|
||||
BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULTS,
|
||||
BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST,
|
||||
} from "./agents/workflow-role-agent-defaults.js";
|
||||
export type { BuiltinWorkflowRole, WorkflowRoleAgentDefault } from "./agents/workflow-role-agent-defaults.js";
|
||||
export {
|
||||
resolveEntryPointBranchAssignment,
|
||||
sanitizeBranchSegment,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Agent, AgentRating, AgentRatingSummary, AgentStore } from "@fusion/core";
|
||||
import { BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST, type Agent, type AgentRating, type AgentRatingSummary, type AgentStore } from "@fusion/core";
|
||||
import {
|
||||
resolveAgentInstructions,
|
||||
resolveAgentInstructionsWithRatings,
|
||||
@@ -88,6 +88,22 @@ describe("resolveAgentInstructions", () => {
|
||||
expect(result).toBe("## Soul\n\nBe thorough and analytical.");
|
||||
});
|
||||
|
||||
it("composes every exported workflow-owner identity exactly once", async () => {
|
||||
for (const definition of BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST) {
|
||||
const result = await resolveAgentInstructions(makeAgent({
|
||||
id: `agent-${definition.role}`,
|
||||
name: definition.name,
|
||||
role: definition.role,
|
||||
roles: [definition.role],
|
||||
instructionsText: definition.instructionsText,
|
||||
soul: definition.soul,
|
||||
}), testDir);
|
||||
expect(result.split(definition.instructionsText)).toHaveLength(2);
|
||||
expect(result.split("## Soul")).toHaveLength(2);
|
||||
expect(result.split(definition.soul)).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns memory section when memory is set", async () => {
|
||||
const agent = makeAgent({ memory: "Remember to keep CI green." });
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
@@ -163,6 +179,20 @@ describe("resolveAgentInstructions", () => {
|
||||
expect(result).toBe("Always write tests.");
|
||||
});
|
||||
|
||||
it("does not add a default inline identity beside a preserved custom path", async () => {
|
||||
const definition = BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST.find((item) => item.role === "executor")!;
|
||||
await writeFile(join(testDir, "operator-executor.md"), "operator-owned executor instructions");
|
||||
|
||||
const result = await resolveAgentInstructions(makeAgent({
|
||||
instructionsPath: "operator-executor.md",
|
||||
instructionsText: "",
|
||||
soul: definition.soul,
|
||||
}), testDir);
|
||||
|
||||
expect(result).toContain("operator-owned executor instructions");
|
||||
expect(result).not.toContain(definition.instructionsText);
|
||||
});
|
||||
|
||||
it("returns file contents when instructionsPath is set", async () => {
|
||||
const filePath = join(testDir, "instructions.md");
|
||||
await writeFile(filePath, "# Custom Instructions\nUse strict TypeScript.");
|
||||
|
||||
Reference in New Issue
Block a user