FN-8265: add task follow-up proposal creation
Enable configured ephemeral workers to propose and create follow-up tasks from mailbox messages. - Add persisted task-proposal claim state, migrations, and async messaging APIs. - Register task-proposal creation routes, SSE events, agent tool support, and CLI integration. - Add mailbox creation controls, settings, documentation, localization, and regression coverage. Files changed: .changeset/fn-8265-task-follow-up-policy.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 12 ++- packages/cli/src/extension.ts | 34 ++++--- .../src/__tests__/postgres/sqlite-migrator.test.ts | 14 ++- .../postgres/task-proposal-claim.pg.test.ts | 51 +++++++++++ packages/core/src/async-message-store.ts | 39 ++++++++ packages/core/src/index.gate.ts | 4 +- packages/core/src/index.ts | 4 +- packages/core/src/message-store.ts | 46 ++++++++++ .../core/src/postgres/migrations/0000_initial.sql | 2 + .../migrations/0020_task_proposal_claim.sql | 4 + packages/core/src/postgres/schema-applier.ts | 13 ++- packages/core/src/postgres/schema/project.ts | 3 + packages/core/src/settings-schema.ts | 19 +++- packages/core/src/task-store/async-persistence.ts | 2 +- packages/core/src/task-store/persistence.ts | 4 +- packages/core/src/task-store/serialization.ts | 1 + packages/core/src/task-store/task-creation.ts | 51 +++++++++++ packages/core/src/task-store/task-row-mappers.ts | 2 +- packages/core/src/types.ts | 56 +++++++++++- packages/dashboard/app/api/legacy.ts | 5 + packages/dashboard/app/components/MailboxModal.tsx | 4 + .../app/components/MailboxTaskProposal.css | 3 + .../app/components/MailboxTaskProposal.tsx | 33 +++++++ packages/dashboard/app/components/MailboxView.tsx | 4 + .../__tests__/MailboxTaskProposal.test.tsx | 43 +++++++++ .../app/components/settings/section-keys.ts | 2 +- .../settings/sections/GeneralSection.search.ts | 13 ++- .../settings/sections/GeneralSection.tsx | 22 +++-- .../settings-default-descriptions.test.tsx | 4 +- .../routes/__tests__/task-proposal-routes.test.ts | 99 ++++++++++++++++++++ .../src/routes/register-messaging-scripts.ts | 101 +++++++++++++++++++++ packages/dashboard/src/sse.ts | 7 ++ packages/engine/src/agent-tools.ts | 30 ++++-- packages/engine/src/executor.ts | 9 +- packages/engine/src/step-session-executor.ts | 8 +- packages/i18n/locales/en/app.json | 5 + 38 files changed, 696 insertions(+), 66 deletions(-) Fusion-Task-Id: FN-8265 Fusion-Task-Lineage: 4e864a2f-3485-4a54-8be7-1699b5479a94 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8265-task-follow-up-policy.md
Normal file
7
.changeset/fn-8265-task-follow-up-policy.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add mailbox approval for ephemeral agent follow-up tasks.
|
||||
category: feature
|
||||
dev: Adds ephemeralAgentTaskCreationPolicy, stable proposal claim keys, mailbox proposals, and one-click materialization.
|
||||
@@ -698,7 +698,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
|
||||
|
||||
## Mailbox View
|
||||
|
||||
Mailbox view shows inbox/outbox communication threads and unread state.
|
||||
Mailbox view shows inbox/outbox communication threads and unread state. When an ephemeral worker is configured for follow-up validation, its task proposals include a **Create task** action; created proposals link directly to the resulting task.
|
||||
|
||||
- Inbox renders one row per message (no sender-based collapsing)
|
||||
- clicking a message in the Mail tab opens the task detail pane with full message content and conversation context
|
||||
|
||||
@@ -1710,13 +1710,15 @@ Project-scoped default permission policy for agent runtime action gates. It appl
|
||||
- Heartbeat-critical coordination/exempt tools remain non-configurable and allowed to prevent deadlocks.
|
||||
- Legacy ephemeral agents without `permissionPolicy` are not rewritten on disk; they inherit this setting when a runtime session is built.
|
||||
|
||||
### `ephemeralAgentsCanCreateTasks`
|
||||
### `ephemeralAgentTaskCreationPolicy`
|
||||
|
||||
Project-scoped backward-compatibility guard for ephemeral/runtime-managed task workers calling `fn_task_create`.
|
||||
Project-scoped policy for ephemeral/runtime-managed task workers calling `fn_task_create`.
|
||||
|
||||
- Default: `true`, preserving the historical behavior that task workers can create follow-up tasks.
|
||||
- When `false`, ephemeral callers are rejected before task creation even if their unified `permissionPolicy` would otherwise allow `fn_task_create`.
|
||||
- When `true`, the unified runtime policy still applies: `defaultAgentPermissionPolicy.toolRules.fn_task_create = "block"` blocks ephemeral and permanent agents, and `"require-approval"` creates an approval request before the tool can run.
|
||||
- `allow` creates follow-up tasks immediately.
|
||||
- `upon_validation` sends a structured proposal to the operator mailbox. The operator can create the proposed task from the message; repeated requests reuse a durable proposal key so one proposal materializes at most one task.
|
||||
- `deny` rejects ephemeral follow-up creation. Permanent agents and human/dashboard callers are unaffected.
|
||||
- The setting deliberately has no materialized default. The resolver falls back to `allow`; legacy persisted `ephemeralAgentsCanCreateTasks: false` still resolves to `deny` (and legacy `true` resolves to `allow`).
|
||||
- The unified runtime policy still applies: `defaultAgentPermissionPolicy.toolRules.fn_task_create = "block"` blocks ephemeral and permanent agents, and `"require-approval"` creates an approval request before the tool can run.
|
||||
|
||||
## Model selection hierarchy
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
@@ -1165,17 +1166,28 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
*/
|
||||
const fnCtx = ctx as typeof ctx & { agentId?: string };
|
||||
const projectSettingsForGate = await store.getSettings();
|
||||
if (
|
||||
projectSettingsForGate.ephemeralAgentsCanCreateTasks === false &&
|
||||
(await isEphemeralCallerAgent(ctx.cwd ?? process.cwd(), fnCtx.agentId))
|
||||
) {
|
||||
const error =
|
||||
"Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project).";
|
||||
return {
|
||||
content: [{ type: "text", text: `ERROR: ${error}` }],
|
||||
isError: true,
|
||||
details: { error, rule: "ephemeral-agents-cannot-create-tasks", callerAgentId: fnCtx.agentId },
|
||||
};
|
||||
const callerIsEphemeral = await isEphemeralCallerAgent(ctx.cwd ?? process.cwd(), fnCtx.agentId);
|
||||
if (callerIsEphemeral) {
|
||||
const policy = fusionCore.resolveEphemeralTaskCreationPolicy(projectSettingsForGate);
|
||||
if (policy === "deny") {
|
||||
const error = "Ephemeral task-worker agents are not allowed to create tasks (ephemeral agent task creation is denied for this project).";
|
||||
return { content: [{ type: "text", text: `ERROR: ${error}` }], isError: true, details: { error, rule: "ephemeral-agents-cannot-create-tasks", callerAgentId: fnCtx.agentId } };
|
||||
}
|
||||
if (policy === "upon_validation") {
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-19:10:
|
||||
Validation proposals must work for both durable backends. PostgreSQL uses
|
||||
the scoped async layer; legacy SQLite uses the TaskStore database so an
|
||||
ephemeral CLI caller is never denied merely because it is not backend-mode.
|
||||
*/
|
||||
const layer = store.getAsyncLayer();
|
||||
const messageStore = layer
|
||||
? new fusionCore.MessageStore(null, { asyncLayer: layer })
|
||||
: new fusionCore.MessageStore(store.getDatabase());
|
||||
const title = params.description.split(/\r?\n/, 1)[0]?.trim().slice(0, 80) || "Follow-up task";
|
||||
await messageStore.sendMessage({ fromId: fnCtx.agentId ?? "ephemeral-worker", fromType: "agent", toId: fusionCore.DASHBOARD_USER_ID, toType: "user", type: "agent-to-user", content: `Task proposal awaiting validation: ${title}`, metadata: { kind: "task-proposal", proposalStatus: "pending", proposalIdempotencyKey: randomUUID(), proposedTask: { title, description: params.description, priority: params.priority as TaskPriority | undefined, workflowId: params.workflow_id, dependencies: params.depends } } });
|
||||
return { content: [{ type: "text", text: "Task proposal submitted to the operator for validation; no task was created." }], details: { proposed: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
|
||||
|
||||
@@ -112,7 +112,9 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
deletedAt TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
projectId TEXT
|
||||
projectId TEXT,
|
||||
-- FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00: legacy SQLite cutover sources preserve the durable proposal key so a migrated task remains the idempotency anchor.
|
||||
proposalClaimId TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -1661,6 +1663,12 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
|
||||
// VAL-MIGRATE-003 — JSON column fidelity
|
||||
it("round-trips JSON columns with identical shape (text-JSON → jsonb)", async () => {
|
||||
const legacy = new DatabaseSync(join(ctx!.fusionDir, "fusion.db"));
|
||||
try {
|
||||
legacy.prepare("UPDATE tasks SET proposalClaimId = ? WHERE id = ?").run("legacy-proposal-claim", "FN-100");
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
await migrateTest(ctx!.db, [
|
||||
{ sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const },
|
||||
]);
|
||||
@@ -1673,6 +1681,10 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
expect(t.steps).toEqual([{ id: "s1", name: "step one" }]);
|
||||
expect(t.comments).toEqual([{ author: "agent", body: "hello" }]);
|
||||
expect(t.custom_fields).toEqual({ priority: "high", labels: ["a", "b"] });
|
||||
const proposalClaim = (await ctx!.db.execute(sql`
|
||||
SELECT proposal_claim_id FROM project.tasks WHERE id = 'FN-100'
|
||||
`)) as unknown as Array<{ proposal_claim_id: string | null }>;
|
||||
expect(proposalClaim[0].proposal_claim_id).toBe("legacy-proposal-claim");
|
||||
|
||||
// Verify the column type is actually jsonb.
|
||||
const colInfo = (await ctx!.db.execute(sql`
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-18:30:
|
||||
A released proposal lease may be reclaimed while its original creator is still
|
||||
inserting. This PostgreSQL integration test exercises the real partial unique
|
||||
index race: every attempt uses the proposal's stable key and must return one
|
||||
already-materialized task rather than surfacing 23505 or creating another row.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import {
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
pgDescribe,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
|
||||
const pgTest = pgDescribe;
|
||||
|
||||
pgTest("TaskStore proposal claim idempotency", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_proposal_claim",
|
||||
});
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
it("returns the existing task when a reclaim races the original proposal insert", async () => {
|
||||
const stableProposalKey = "proposal-reclaim-race-stable-key";
|
||||
const store = h.store();
|
||||
|
||||
const [originalCreate, reclaimedCreate] = await Promise.all([
|
||||
store.createTask({
|
||||
title: "Original proposal materialization",
|
||||
description: "Original creator resumes after its lease was released.",
|
||||
proposalClaimId: stableProposalKey,
|
||||
}),
|
||||
store.createTask({
|
||||
title: "Reclaimed proposal materialization",
|
||||
description: "Reclaimed creator uses the same stable proposal key.",
|
||||
proposalClaimId: stableProposalKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(reclaimedCreate.id).toBe(originalCreate.id);
|
||||
expect(reclaimedCreate.proposalClaimId).toBe(stableProposalKey);
|
||||
const persisted = (await store.listTasks()).filter((task) => task.proposalClaimId === stableProposalKey);
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted[0]?.id).toBe(originalCreate.id);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
* consume.
|
||||
*/
|
||||
import { and, desc, eq, inArray, lte, or, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as schema from "./postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
||||
import {
|
||||
@@ -153,6 +154,44 @@ export async function getMessage(handle: QueryHandle, id: string): Promise<Messa
|
||||
return rows[0] ? rowToMessage(rows[0] as MessageRow) : null;
|
||||
}
|
||||
|
||||
export async function claimProposalForCreation(handle: QueryHandle, messageId: string): Promise<{ claimed: boolean; idempotencyKey?: string; claimOwnerToken?: string; message?: Message }> {
|
||||
const existing = await getMessage(handle, messageId);
|
||||
if (existing?.metadata?.kind !== "task-proposal" || existing.metadata.proposalStatus !== "pending" || !existing.metadata.proposalIdempotencyKey) return { claimed: false };
|
||||
const owner = randomUUID();
|
||||
const claimStartedAt = new Date().toISOString();
|
||||
// FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00: Persist a lease timestamp with the transient owner so a post-crash click can safely reclaim only stale creating proposals without rotating the stable key.
|
||||
const metadata = { ...existing.metadata, proposalStatus: "creating" as const, claimOwnerToken: owner, claimStartedAt };
|
||||
const updated = await handle.update(schema.project.messages).set({ metadata, updatedAt: claimStartedAt }).where(and(eq(schema.project.messages.id, messageId), sql`${schema.project.messages.metadata}->>'proposalStatus' = 'pending'`)).returning(messageColumns);
|
||||
if (!updated[0]) return { claimed: false };
|
||||
const message = rowToMessage(updated[0] as MessageRow);
|
||||
return { claimed: true, idempotencyKey: metadata.proposalIdempotencyKey, claimOwnerToken: owner, message };
|
||||
}
|
||||
|
||||
export async function finalizeProposalCreation(handle: QueryHandle, messageId: string, claimOwnerToken: string, createdTaskId: string): Promise<Message | null> {
|
||||
const existing = await getMessage(handle, messageId);
|
||||
if (!existing) return null;
|
||||
if (existing.metadata?.proposalStatus === "created" && existing.metadata.createdTaskId === createdTaskId) return existing;
|
||||
if (existing.metadata?.proposalStatus !== "creating" || existing.metadata.claimOwnerToken !== claimOwnerToken) return null;
|
||||
const metadata = { ...existing.metadata, proposalStatus: "created" as const, createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined };
|
||||
const rows = await handle.update(schema.project.messages).set({ metadata, updatedAt: new Date().toISOString() }).where(and(eq(schema.project.messages.id, messageId), sql`${schema.project.messages.metadata}->>'claimOwnerToken' = ${claimOwnerToken}`)).returning(messageColumns);
|
||||
return rows[0] ? rowToMessage(rows[0] as MessageRow) : null;
|
||||
}
|
||||
|
||||
export async function releaseProposalClaim(handle: QueryHandle, messageId: string, claimOwnerToken: string): Promise<Message | null> {
|
||||
const existing = await getMessage(handle, messageId);
|
||||
if (existing?.metadata?.proposalStatus !== "creating" || existing.metadata.claimOwnerToken !== claimOwnerToken) return null;
|
||||
// FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: release only transient ownership; stable idempotency key is retained for an overlapping retry.
|
||||
const metadata = { ...existing.metadata, proposalStatus: "pending" as const, claimOwnerToken: undefined, claimStartedAt: undefined };
|
||||
const rows = await handle.update(schema.project.messages).set({ metadata, updatedAt: new Date().toISOString() }).where(and(eq(schema.project.messages.id, messageId), sql`${schema.project.messages.metadata}->>'claimOwnerToken' = ${claimOwnerToken}`)).returning(messageColumns);
|
||||
return rows[0] ? rowToMessage(rows[0] as MessageRow) : null;
|
||||
}
|
||||
|
||||
export async function reconcileProposalCreation(handle: QueryHandle, messageId: string, resolvedTaskId: string | undefined): Promise<Message | null> {
|
||||
const existing = await getMessage(handle, messageId);
|
||||
if (!existing || existing.metadata?.proposalStatus !== "creating") return existing;
|
||||
return resolvedTaskId ? finalizeProposalCreation(handle, messageId, existing.metadata.claimOwnerToken ?? "", resolvedTaskId) : releaseProposalClaim(handle, messageId, existing.metadata.claimOwnerToken ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:MessageStore 2026-06-24-07:05:
|
||||
* Query messages by participant direction (to = inbox, from = outbox).
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,6 +33,8 @@ export interface MessageStoreEvents {
|
||||
"message:read": [message: Message];
|
||||
/** Emitted when a message is deleted */
|
||||
"message:deleted": [messageId: string];
|
||||
/** Emitted when proposal metadata changes without creating a new message. */
|
||||
"message:updated": [message: Message];
|
||||
}
|
||||
|
||||
// ── Row Interfaces ───────────────────────────────────────────────────
|
||||
@@ -325,6 +327,50 @@ export class MessageStore extends EventEmitter<MessageStoreEvents> {
|
||||
return this.rowToMessage(row);
|
||||
}
|
||||
|
||||
/** Atomically acquire a pending proposal's transient creation lease. */
|
||||
async claimProposalForCreation(messageId: string): Promise<{ claimed: boolean; idempotencyKey?: string; claimOwnerToken?: string }> {
|
||||
if (this.asyncLayer) {
|
||||
const result = await asyncMessageStore.claimProposalForCreation(this.asyncLayer.db, messageId);
|
||||
if (result.message) this.emit("message:updated", result.message);
|
||||
return result;
|
||||
}
|
||||
const owner = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
// FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00: SQLite-compatible stores persist the lease start with its owner, allowing a later operator request to recover a dead creator without changing the stable idempotency key.
|
||||
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'creating', '$.claimOwnerToken', ?, '$.claimStartedAt', ?), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.kind') = 'task-proposal' AND json_extract(metadata, '$.proposalStatus') = 'pending'`).run(owner, now, now, messageId).changes;
|
||||
if (!changed) return { claimed: false };
|
||||
this.db!.bumpLastModified();
|
||||
const message = await this.getMessage(messageId);
|
||||
if (message) this.emit("message:updated", message);
|
||||
return { claimed: true, idempotencyKey: message?.metadata?.proposalIdempotencyKey as string | undefined, claimOwnerToken: owner };
|
||||
}
|
||||
|
||||
async finalizeProposalCreation(messageId: string, claimOwnerToken: string, createdTaskId: string): Promise<Message | null> {
|
||||
if (this.asyncLayer) {
|
||||
const message = await asyncMessageStore.finalizeProposalCreation(this.asyncLayer.db, messageId, claimOwnerToken, createdTaskId);
|
||||
if (message) this.emit("message:updated", message);
|
||||
return message;
|
||||
}
|
||||
const existing = await this.getMessage(messageId);
|
||||
if (existing?.metadata?.proposalStatus === "created" && existing.metadata.createdTaskId === createdTaskId) return existing;
|
||||
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'created', '$.createdTaskId', ?, '$.claimOwnerToken', null, '$.claimStartedAt', null), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.proposalStatus') = 'creating' AND json_extract(metadata, '$.claimOwnerToken') = ?`).run(createdTaskId, new Date().toISOString(), messageId, claimOwnerToken).changes;
|
||||
if (!changed) return null;
|
||||
this.db!.bumpLastModified(); const message = await this.getMessage(messageId); if (message) this.emit("message:updated", message); return message;
|
||||
}
|
||||
|
||||
async releaseProposalClaim(messageId: string, claimOwnerToken: string): Promise<Message | null> {
|
||||
if (this.asyncLayer) { const message = await asyncMessageStore.releaseProposalClaim(this.asyncLayer.db, messageId, claimOwnerToken); if (message) this.emit("message:updated", message); return message; }
|
||||
const changed = this.db!.prepare(`UPDATE messages SET metadata = json_set(metadata, '$.proposalStatus', 'pending', '$.claimOwnerToken', null, '$.claimStartedAt', null), updatedAt = ? WHERE id = ? AND json_extract(metadata, '$.proposalStatus') = 'creating' AND json_extract(metadata, '$.claimOwnerToken') = ?`).run(new Date().toISOString(), messageId, claimOwnerToken).changes;
|
||||
if (!changed) return null; this.db!.bumpLastModified(); const message = await this.getMessage(messageId); if (message) this.emit("message:updated", message); return message;
|
||||
}
|
||||
|
||||
async reconcileProposalCreation(messageId: string, resolvedTaskId: string | undefined): Promise<Message | null> {
|
||||
const message = await this.getMessage(messageId);
|
||||
if (!message || message.metadata?.proposalStatus !== "creating") return message;
|
||||
if (resolvedTaskId) return this.finalizeProposalCreation(messageId, message.metadata.claimOwnerToken ?? "", resolvedTaskId);
|
||||
return this.releaseProposalClaim(messageId, message.metadata.claimOwnerToken ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inbox messages for a participant (messages where they are the recipient).
|
||||
* @param ownerId - The participant ID
|
||||
|
||||
@@ -168,6 +168,7 @@ CREATE TABLE IF NOT EXISTS project.tasks (
|
||||
source_message_id text,
|
||||
source_parent_task_id text,
|
||||
source_metadata jsonb,
|
||||
proposal_claim_id text,
|
||||
checked_out_by text,
|
||||
checked_out_at text,
|
||||
checkout_node_id text,
|
||||
@@ -1521,6 +1522,7 @@ CREATE INDEX IF NOT EXISTS "idxTasksUpdatedAt" ON project.tasks(updated_at DESC)
|
||||
-- filters on source_parent_task_id on every archive/delete. Without this index
|
||||
-- the gate is a full tasks-table scan. Sparse: most rows have NULL parent.
|
||||
CREATE INDEX IF NOT EXISTS "idxTasksSourceParentTaskId" ON project.tasks(source_parent_task_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uqTasksProjectProposalClaimId" ON project.tasks(project_id, proposal_claim_id) WHERE proposal_claim_id IS NOT NULL;
|
||||
-- FNXC:TaskStoreReads 2026-06-26-10:00:
|
||||
-- Partial index for the hot kanban / board-read query shape
|
||||
-- WHERE deleted_at IS NULL AND "column" = ? (every live board hydration).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS proposal_claim_id text;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uqTasksProjectProposalClaimId"
|
||||
ON project.tasks (project_id, proposal_claim_id)
|
||||
WHERE proposal_claim_id IS NOT NULL;
|
||||
@@ -32,7 +32,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
|
||||
FNXC:GitHubImportTranslate 2026-07-17-23:48:
|
||||
Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves.
|
||||
*/
|
||||
export const SCHEMA_BASELINE_VERSION = "0019";
|
||||
export const SCHEMA_BASELINE_VERSION = "0020";
|
||||
const INITIAL_SCHEMA_VERSION = "0000";
|
||||
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
||||
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||
@@ -103,6 +103,8 @@ export const TASK_MERGER_MODEL_LANE_VERSION = "0017";
|
||||
* TaskStore SELECT. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances.
|
||||
*/
|
||||
export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018";
|
||||
/** FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: durable project-scoped proposal key/index protects task creation across crash and reclaim races. */
|
||||
export const TASK_PROPOSAL_CLAIM_VERSION = "0020";
|
||||
|
||||
/** Bookkeeping table for the fresh Drizzle migration history. */
|
||||
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
||||
@@ -209,6 +211,7 @@ const GLOBAL_ROUTINES_MIGRATION_PATH = join(
|
||||
);
|
||||
const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_merger_model_lane.sql");
|
||||
const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql");
|
||||
const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql");
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -297,6 +300,7 @@ export async function applySchemaBaseline(
|
||||
const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION);
|
||||
const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION);
|
||||
const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION);
|
||||
const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION);
|
||||
let schemaChanged = false;
|
||||
|
||||
if (!baselineAlreadyApplied) {
|
||||
@@ -613,6 +617,13 @@ export async function applySchemaBaseline(
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
if (!taskProposalClaimAlreadyApplied) {
|
||||
const migrationSql = await readFile(TASK_PROPOSAL_CLAIM_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_PROPOSAL_CLAIM_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-16-23:30:
|
||||
0010's marker prevents its corrected fresh-install definition from running
|
||||
|
||||
@@ -242,6 +242,7 @@ export const tasks = projectSchema.table("tasks", {
|
||||
sourceMessageId: text("source_message_id"),
|
||||
sourceParentTaskId: text("source_parent_task_id"),
|
||||
sourceMetadata: jsonb("source_metadata"),
|
||||
proposalClaimId: text("proposal_claim_id"),
|
||||
checkedOutBy: text("checked_out_by"),
|
||||
checkedOutAt: text("checked_out_at"),
|
||||
checkoutNodeId: text("checkout_node_id"),
|
||||
@@ -298,6 +299,8 @@ export const tasks = projectSchema.table("tasks", {
|
||||
the gate is a full tasks-table scan. Sparse: most rows have NULL parent.
|
||||
*/
|
||||
index("idxTasksSourceParentTaskId").on(t.sourceParentTaskId),
|
||||
// FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: proposal retries share one stable key, so the database—not a read-before-create race—enforces at-most-once materialization.
|
||||
uniqueIndex("uqTasksProjectProposalClaimId").on(t.projectId, t.proposalClaimId).where(sql`${t.proposalClaimId} IS NOT NULL`),
|
||||
/*
|
||||
FNXC:TaskStoreReads 2026-06-26-10:00:
|
||||
Partial index for the hot kanban / board-read query shape
|
||||
|
||||
@@ -59,7 +59,8 @@ type MovedProjectSettingsKey =
|
||||
| "validatorFallbackModelId"
|
||||
| "validatorFallbackThinkingLevel";
|
||||
|
||||
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
|
||||
type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy";
|
||||
type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey | NonDefaultProjectSettingsKey>;
|
||||
|
||||
/**
|
||||
* Settings schema source of truth.
|
||||
@@ -807,10 +808,20 @@ export const GLOBAL_SETTINGS_KEYS = Object.freeze(
|
||||
Object.keys(DEFAULT_GLOBAL_SETTINGS) as Array<keyof GlobalSettings>,
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00:
|
||||
The validation policy is persisted as a project setting but intentionally absent from defaults.
|
||||
The resolver owns fallback so a legacy-only explicit false remains deny after settings merge.
|
||||
*/
|
||||
export const NON_DEFAULT_PROJECT_SETTINGS_KEYS = Object.freeze([
|
||||
"ephemeralAgentTaskCreationPolicy",
|
||||
] as const satisfies readonly NonDefaultProjectSettingsKey[]);
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
export const PROJECT_SETTINGS_KEYS = Object.freeze(
|
||||
Object.keys(DEFAULT_PROJECT_SETTINGS) as Array<keyof ProjectSettings>,
|
||||
);
|
||||
export const PROJECT_SETTINGS_KEYS = Object.freeze([
|
||||
...Object.keys(DEFAULT_PROJECT_SETTINGS),
|
||||
...NON_DEFAULT_PROJECT_SETTINGS_KEYS,
|
||||
] as Array<keyof ProjectSettings>);
|
||||
|
||||
export function isGlobalSettingsKey(key: string): key is keyof GlobalSettings {
|
||||
return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key);
|
||||
|
||||
@@ -488,7 +488,7 @@ export async function updateTaskColumns(
|
||||
*/
|
||||
export function isTaskIdConflictError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks\.id|PRIMARY KEY constraint failed: tasks\.id/i.test(message)) {
|
||||
if (/SQLITE_CONSTRAINT|UNIQUE constraint failed: tasks\.(id|proposalClaimId)|PRIMARY KEY constraint failed: tasks\.id/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// PostgreSQL unique_violation (23505). The code may be on the error directly
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface TaskRow {
|
||||
sourceMessageId: string | null;
|
||||
sourceParentTaskId: string | null;
|
||||
sourceMetadata: string | null;
|
||||
proposalClaimId: string | null;
|
||||
checkedOutBy: string | null;
|
||||
checkedOutAt: string | null;
|
||||
checkoutNodeId: string | null;
|
||||
@@ -353,7 +354,8 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
|
||||
defineTaskColumn("sourceMessageId", (task) => task.sourceMessageId ?? null),
|
||||
defineTaskColumn("sourceParentTaskId", (task) => task.sourceParentTaskId ?? null),
|
||||
defineTaskColumn("sourceMetadata", (task) => toJsonNullable(task.sourceMetadata)),
|
||||
defineTaskColumn("checkedOutBy", (task) => task.checkedOutBy ?? null),
|
||||
defineTaskColumn("proposalClaimId", (task) => task.proposalClaimId ?? null),
|
||||
defineTaskColumn("checkedOutBy", (task) => task.checkedOutBy ?? null),
|
||||
defineTaskColumn("checkedOutAt", (task) => task.checkedOutAt ?? null),
|
||||
defineTaskColumn("checkoutNodeId", (task) => task.checkoutNodeId ?? null),
|
||||
defineTaskColumn("checkoutRunId", (task) => task.checkoutRunId ?? null),
|
||||
|
||||
@@ -261,6 +261,7 @@ export function rowToTask(row: TaskRow): Task {
|
||||
sourceSessionId: row.sourceSessionId || undefined,
|
||||
sourceMessageId: row.sourceMessageId || undefined,
|
||||
sourceParentTaskId: row.sourceParentTaskId || undefined,
|
||||
proposalClaimId: row.proposalClaimId || undefined,
|
||||
sourceMetadata: (() => {
|
||||
const parsed = fromJson<Record<string, unknown>>(row.sourceMetadata) ?? undefined;
|
||||
return withTaskBranchContextInSourceMetadata(parsed, parseTaskBranchContextFromSourceMetadata(parsed));
|
||||
|
||||
@@ -27,6 +27,23 @@ import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
||||
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
|
||||
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
|
||||
|
||||
function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-19:10:
|
||||
The legacy SQLite store remains a supported MessageStore/task-materialization
|
||||
backend. Its durable partial unique index is the same at-most-once anchor as
|
||||
PostgreSQL: release/reclaim reuses one stable key, so concurrent creators can
|
||||
only insert one task and the loser returns that persisted task.
|
||||
*/
|
||||
const columns = store.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === "proposalClaimId")) {
|
||||
store.db.exec("ALTER TABLE tasks ADD COLUMN proposalClaimId TEXT");
|
||||
}
|
||||
store.db.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_proposal_claim_id ON tasks(proposalClaimId) WHERE proposalClaimId IS NOT NULL",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; },): Promise<Task> {
|
||||
if (!input.description?.trim()) {
|
||||
throw new Error("Description is required and cannot be empty");
|
||||
@@ -279,6 +296,7 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
|
||||
const task: Task = {
|
||||
id,
|
||||
lineageId: input.lineageId ?? generateTaskLineageId(),
|
||||
proposalClaimId: input.proposalClaimId,
|
||||
title: normalizedTitle.title ?? undefined,
|
||||
description: input.description,
|
||||
priority: normalizeTaskPriority(input.priority),
|
||||
@@ -359,6 +377,19 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
|
||||
await insertTaskRowInTransaction(tx, task as unknown as Record<string, unknown>, context, layer.projectId);
|
||||
});
|
||||
} catch (error) {
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-18:30:
|
||||
Proposal creation retries can race after a creation lease is released while
|
||||
the original creator is still inserting. Both attempts deliberately use the
|
||||
same stable proposalClaimId, so the partial unique index is the at-most-once
|
||||
authority. A 23505 for that key returns the committed winner instead of
|
||||
treating it as an ID collision; no loser may continue into task-file or
|
||||
workflow materialization. Other unique violations remain task-ID errors.
|
||||
*/
|
||||
if (input.proposalClaimId && isTaskIdConflictError(error)) {
|
||||
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
if (isTaskIdConflictError(error)) {
|
||||
throw new Error(`Task ID already exists: ${task.id}`);
|
||||
}
|
||||
@@ -428,6 +459,11 @@ export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, o
|
||||
if (!input.description?.trim()) {
|
||||
throw new Error("Description is required and cannot be empty");
|
||||
}
|
||||
if (input.proposalClaimId) {
|
||||
ensureSqliteProposalClaimUniqueness(store);
|
||||
const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const selfDefeatingDep = detectSelfDefeatingDependency(input.title, input.dependencies ?? []);
|
||||
if (selfDefeatingDep) {
|
||||
@@ -598,6 +634,10 @@ export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, o
|
||||
// The task row was never created, so any default-workflow steps we
|
||||
// materialized above would orphan with no task/selection pointing at them.
|
||||
await store.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
|
||||
if (input.proposalClaimId && isTaskIdConflictError(err)) {
|
||||
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -694,6 +734,12 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
|
||||
);
|
||||
}
|
||||
|
||||
if (input.proposalClaimId) {
|
||||
ensureSqliteProposalClaimUniqueness(store);
|
||||
const existing = (await store.listTasks()).find((task) => task.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const id = options.taskId.trim();
|
||||
if (!id) {
|
||||
throw new Error("taskId is required");
|
||||
@@ -801,6 +847,10 @@ export async function createTaskWithReservedIdImpl(store: TaskStore, input: Task
|
||||
// The task row was never created, so any default-workflow steps we
|
||||
// materialized above would orphan with no task/selection pointing at them.
|
||||
await store.cleanupOrphanedMaterializedSteps(pendingWorkflowSelection?.stepIds);
|
||||
if (input.proposalClaimId && isTaskIdConflictError(err)) {
|
||||
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -826,6 +876,7 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
|
||||
const task: Task = {
|
||||
id,
|
||||
lineageId: input.lineageId ?? generateTaskLineageId(),
|
||||
proposalClaimId: input.proposalClaimId,
|
||||
title: normalizedTitle.title ?? undefined,
|
||||
description: input.description,
|
||||
priority: normalizeTaskPriority(input.priority),
|
||||
|
||||
@@ -45,7 +45,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA
|
||||
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees",
|
||||
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "proposalClaimId",
|
||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection",
|
||||
// `log` is fetched in slim mode so the server can aggregate
|
||||
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
|
||||
|
||||
@@ -1392,6 +1392,8 @@ export interface Task {
|
||||
id: string;
|
||||
/** Immutable lineage identity used for durable commit/task attribution. */
|
||||
lineageId?: string;
|
||||
/** Stable task-proposal idempotency key; unique per project when present. */
|
||||
proposalClaimId?: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
/**
|
||||
@@ -2014,6 +2016,8 @@ export interface TaskCreateInput {
|
||||
title?: string;
|
||||
/** Optional lineage override for trusted replication/import paths only. */
|
||||
lineageId?: string;
|
||||
/** Stable task-proposal idempotency key; repeated creates return the same task. */
|
||||
proposalClaimId?: string;
|
||||
/**
|
||||
* Opt-in createTask override for soft-deleted ID reuse.
|
||||
* Not persisted to storage.
|
||||
@@ -3718,11 +3722,13 @@ export interface ProjectSettings {
|
||||
* to permanent executor agents using the reporting chain heuristic.
|
||||
* Tasks without an eligible permanent executor remain queued. */
|
||||
ephemeralAgentsEnabled?: boolean;
|
||||
/**
|
||||
* FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
||||
* Gates whether ephemeral/runtime-managed task-worker agents may create new tasks via `fn_task_create`.
|
||||
* Default true preserves the existing behavior where a task-worker can spin off follow-up tasks.
|
||||
* When false, an ephemeral caller's `fn_task_create` is rejected while human/dashboard/CLI callers and permanent agents remain unaffected. */
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00:
|
||||
The three-state policy routes ephemeral-worker follow-ups to allow, operator validation, or deny.
|
||||
The policy has no schema default because resolver fallback must preserve persisted legacy false as deny.
|
||||
*/
|
||||
ephemeralAgentTaskCreationPolicy?: EphemeralTaskCreationPolicy;
|
||||
/** @deprecated Legacy compatibility read only; resolve with resolveEphemeralTaskCreationPolicy. */
|
||||
ephemeralAgentsCanCreateTasks?: boolean;
|
||||
/** Approval policy for agent provisioning tools (fn_agent_create/fn_agent_delete). */
|
||||
agentProvisioning?: {
|
||||
@@ -6869,6 +6875,22 @@ export interface MessageReplyReference {
|
||||
}
|
||||
|
||||
/** Optional metadata attached to mailbox messages. */
|
||||
export type EphemeralTaskCreationPolicy = "allow" | "upon_validation" | "deny";
|
||||
|
||||
/** Resolve the non-default policy without masking legacy persisted settings. */
|
||||
export function resolveEphemeralTaskCreationPolicy(settings: Pick<Settings, "ephemeralAgentTaskCreationPolicy" | "ephemeralAgentsCanCreateTasks">): EphemeralTaskCreationPolicy {
|
||||
if (settings.ephemeralAgentTaskCreationPolicy === "allow" || settings.ephemeralAgentTaskCreationPolicy === "upon_validation" || settings.ephemeralAgentTaskCreationPolicy === "deny") return settings.ephemeralAgentTaskCreationPolicy;
|
||||
return settings.ephemeralAgentsCanCreateTasks === false ? "deny" : "allow";
|
||||
}
|
||||
|
||||
export interface ProposedTaskMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: TaskPriority;
|
||||
workflowId?: string;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
export interface MessageMetadata extends Record<string, unknown> {
|
||||
/** Optional link to the original message when this message is a reply. */
|
||||
replyTo?: MessageReplyReference;
|
||||
@@ -6878,6 +6900,17 @@ export interface MessageMetadata extends Record<string, unknown> {
|
||||
* use sparingly for urgent messages. Ignored when recipient is a user.
|
||||
*/
|
||||
wakeRecipient?: boolean;
|
||||
/** Structured operator-approved follow-up task proposal. */
|
||||
kind?: string;
|
||||
proposedTask?: ProposedTaskMetadata;
|
||||
proposalStatus?: "pending" | "creating" | "created" | "dismissed";
|
||||
createdTaskId?: string;
|
||||
/** Stable proposal key issued at send time and never rotated across reclaims. */
|
||||
proposalIdempotencyKey?: string;
|
||||
/** Transient owner token for the current creating lease only. */
|
||||
claimOwnerToken?: string;
|
||||
/** Durable ISO timestamp used to reclaim a creator that died before task persistence. */
|
||||
claimStartedAt?: string;
|
||||
}
|
||||
|
||||
/** Message record stored in the system */
|
||||
@@ -6955,6 +6988,19 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined):
|
||||
if (metadata.wakeRecipient !== undefined && typeof metadata.wakeRecipient !== "boolean") {
|
||||
throw new Error("metadata.wakeRecipient must be a boolean");
|
||||
}
|
||||
|
||||
const proposalFieldsPresent = metadata.proposalStatus !== undefined || metadata.createdTaskId !== undefined || metadata.proposalIdempotencyKey !== undefined || metadata.claimOwnerToken !== undefined || metadata.claimStartedAt !== undefined;
|
||||
if (metadata.kind === "task-proposal" || proposalFieldsPresent || metadata.proposedTask !== undefined) {
|
||||
if (metadata.kind !== "task-proposal" || !metadata.proposedTask) throw new Error("task proposal metadata requires kind and proposedTask");
|
||||
const proposal = metadata.proposedTask;
|
||||
if (typeof proposal.title !== "string" || !proposal.title.trim() || typeof proposal.description !== "string" || !proposal.description.trim()) throw new Error("metadata.proposedTask requires non-empty title and description");
|
||||
if (proposal.dependencies !== undefined && (!Array.isArray(proposal.dependencies) || proposal.dependencies.some((id) => typeof id !== "string"))) throw new Error("metadata.proposedTask.dependencies must be string[]");
|
||||
if (proposal.priority !== undefined && !["low", "normal", "high", "urgent"].includes(proposal.priority)) throw new Error("metadata.proposedTask.priority is invalid");
|
||||
if (metadata.proposalStatus !== undefined && !["pending", "creating", "created", "dismissed"].includes(metadata.proposalStatus)) throw new Error("metadata.proposalStatus is invalid");
|
||||
if (typeof metadata.proposalIdempotencyKey !== "string" || !metadata.proposalIdempotencyKey.trim()) throw new Error("task proposal requires proposalIdempotencyKey");
|
||||
if (metadata.claimStartedAt !== undefined && (typeof metadata.claimStartedAt !== "string" || Number.isNaN(Date.parse(metadata.claimStartedAt)))) throw new Error("metadata.claimStartedAt must be an ISO timestamp");
|
||||
if (metadata.proposalStatus === "pending" && (metadata.claimOwnerToken !== undefined || metadata.claimStartedAt !== undefined)) throw new Error("pending proposal cannot have a creation lease");
|
||||
}
|
||||
}
|
||||
|
||||
/** Mailbox summary for a participant */
|
||||
|
||||
@@ -8763,6 +8763,11 @@ export function sendMessage(input: SendMessageInput, projectId?: string): Promis
|
||||
});
|
||||
}
|
||||
|
||||
/** Materialize an operator-approved task proposal exactly once. */
|
||||
export function createProposedTask(id: string, projectId?: string): Promise<{ task: import("@fusion/core").Task; proposal: Message }> {
|
||||
return api(withProjectId(`/messages/${encodeURIComponent(id)}/create-proposed-task`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
/** Mark a specific message as read. */
|
||||
export function markMessageRead(id: string, projectId?: string): Promise<Message> {
|
||||
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}/read`, projectId), {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
||||
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
||||
import type { Agent } from "../api";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
@@ -394,6 +395,7 @@ export function MailboxModal({
|
||||
"message:received": onMailboxUpdate,
|
||||
"message:read": onMailboxUpdate,
|
||||
"message:deleted": onMailboxUpdate,
|
||||
"message:updated": onMailboxUpdate,
|
||||
},
|
||||
});
|
||||
}, [isOpen, projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox]);
|
||||
@@ -900,6 +902,7 @@ export function MailboxModal({
|
||||
taskId={msg.metadata?.taskId}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -931,6 +934,7 @@ export function MailboxModal({
|
||||
taskId={selectedMessage.metadata?.taskId}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.mailbox-task-proposal { margin-block: var(--space-2); padding: var(--space-3); border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); }
|
||||
.mailbox-task-proposal p { margin-block: var(--space-2); }
|
||||
@media (max-width: 768px) { .mailbox-task-proposal .btn { inline-size: 100%; } }
|
||||
33
packages/dashboard/app/components/MailboxTaskProposal.tsx
Normal file
33
packages/dashboard/app/components/MailboxTaskProposal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { MessageMetadata } from "@fusion/core";
|
||||
import { createProposedTask } from "../api";
|
||||
import "./MailboxTaskProposal.css";
|
||||
|
||||
export function MailboxTaskProposal({ messageId, metadata, projectId, onOpenTask, onCreated }: { messageId: string; metadata?: MessageMetadata; projectId?: string; onOpenTask?: (id: string) => void; onCreated?: () => void }) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [currentMetadata, setCurrentMetadata] = useState(metadata);
|
||||
useEffect(() => setCurrentMetadata(metadata), [metadata]);
|
||||
if (currentMetadata?.kind !== "task-proposal" || !currentMetadata.proposedTask) return null;
|
||||
const proposal = currentMetadata.proposedTask;
|
||||
const status = currentMetadata.proposalStatus ?? "pending";
|
||||
|
||||
const create = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await createProposedTask(messageId, projectId);
|
||||
// FNXC:EphemeralAgentTaskCreation 2026-07-30-13:00: apply the finalized response immediately so a stale mailbox list cannot offer a duplicate create click before SSE refreshes it.
|
||||
setCurrentMetadata(response.proposal.metadata);
|
||||
onCreated?.();
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <section className="mailbox-task-proposal" data-testid="mailbox-task-proposal">
|
||||
<strong>{proposal.title}</strong><p>{proposal.description}</p>
|
||||
{status === "pending" && <button type="button" className="btn" disabled={creating} onClick={() => void create()}>{creating ? "Creating task…" : "Create task"}</button>}
|
||||
{status === "creating" && <button type="button" className="btn" disabled>Creating task…</button>}
|
||||
{status === "created" && currentMetadata.createdTaskId && <button type="button" className="btn" onClick={() => onOpenTask?.(currentMetadata.createdTaskId!)}>Task {currentMetadata.createdTaskId} created — View task</button>}
|
||||
{status === "dismissed" && <span>Task proposal dismissed</span>}
|
||||
</section>;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "../api";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
|
||||
import { MailboxTaskProposal } from "./MailboxTaskProposal";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { ViewHeader } from "./ViewHeader";
|
||||
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
|
||||
@@ -584,6 +585,7 @@ export function MailboxView({
|
||||
"message:received": onMailboxUpdate,
|
||||
"message:read": onMailboxUpdate,
|
||||
"message:deleted": onMailboxUpdate,
|
||||
"message:updated": onMailboxUpdate,
|
||||
"approval:requested": onMailboxUpdate,
|
||||
"approval:updated": onMailboxUpdate,
|
||||
"approval:decided": onMailboxUpdate,
|
||||
@@ -950,6 +952,7 @@ export function MailboxView({
|
||||
taskId={msg.metadata?.taskId}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -976,6 +979,7 @@ export function MailboxView({
|
||||
taskId={selectedMessage.metadata?.taskId}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Message, MessageMetadata } from "@fusion/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createProposedTask } from "../../api";
|
||||
import { MailboxTaskProposal } from "../MailboxTaskProposal";
|
||||
|
||||
vi.mock("../../api", () => ({ createProposedTask: vi.fn() }));
|
||||
|
||||
const proposalMetadata: MessageMetadata = {
|
||||
kind: "task-proposal",
|
||||
proposalStatus: "pending",
|
||||
proposalIdempotencyKey: "proposal-key",
|
||||
proposedTask: { title: "Follow up", description: "Implement the follow-up." },
|
||||
};
|
||||
|
||||
function createdMessage(): Message {
|
||||
return {
|
||||
id: "message-1", fromId: "agent-1", fromType: "agent", toId: "dashboard-user", toType: "user",
|
||||
content: "Proposal", type: "agent-to-user", read: false, metadata: {
|
||||
...proposalMetadata, proposalStatus: "created", createdTaskId: "FN-8265",
|
||||
}, createdAt: "2026-07-30T00:00:00.000Z", updatedAt: "2026-07-30T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("MailboxTaskProposal", () => {
|
||||
it("renders nothing for non-proposal metadata", () => {
|
||||
const { container } = render(<MailboxTaskProposal messageId="message-1" metadata={{}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("replaces Create task with the created-task affordance from the successful response", async () => {
|
||||
const onOpenTask = vi.fn();
|
||||
vi.mocked(createProposedTask).mockResolvedValue({ task: { id: "FN-8265" } as never, proposal: createdMessage() });
|
||||
render(<MailboxTaskProposal messageId="message-1" metadata={proposalMetadata} projectId="project-1" onOpenTask={onOpenTask} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create task" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Task FN-8265 created — View task" })).toBeInTheDocument());
|
||||
expect(createProposedTask).toHaveBeenCalledWith("message-1", "project-1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Task FN-8265 created — View task" }));
|
||||
expect(onOpenTask).toHaveBeenCalledWith("FN-8265");
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"chatRoomSummaryMaxChars",
|
||||
"completionDocumentationMode",
|
||||
"enabledBuiltinWorkflowIds",
|
||||
"ephemeralAgentsCanCreateTasks",
|
||||
"ephemeralAgentTaskCreationPolicy",
|
||||
"ephemeralAgentsEnabled",
|
||||
"sessionAdvisorEnabledByDefault",
|
||||
"mailAutoCleanupDays",
|
||||
|
||||
@@ -19,13 +19,12 @@ export const generalSearchEntries: SettingsSearchEntry[] = [
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "ephemeralAgentsCanCreateTasks",
|
||||
labelKey: "settings.general.allowEphemeralAgentsToCreateTasks",
|
||||
labelFallback: " Allow ephemeral agents to create tasks ",
|
||||
helpKey: "settings.general.allowEphemeralAgentsToCreateTasksHint",
|
||||
helpFallback:
|
||||
"When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.",
|
||||
keywords: ["follow-up", "permissions"],
|
||||
key: "ephemeralAgentTaskCreationPolicy",
|
||||
labelKey: "settings.general.ephemeralAgentTaskCreationPolicy",
|
||||
labelFallback: "Ephemeral agent follow-up tasks",
|
||||
helpKey: "settings.general.ephemeralAgentTaskCreationPolicyHint",
|
||||
helpFallback: "Allow creates follow-up tasks immediately. Upon validation sends a proposal to your mailbox for one-click approval. Deny rejects follow-up task creation.",
|
||||
keywords: ["follow-up", "permissions", "validation", "proposal"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
|
||||
@@ -208,18 +208,24 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
||||
Default-on toggle controlling whether ephemeral task-worker agents may open new tasks via fn_task_create. Turning it off confines task creation to humans and permanent agents; ephemeral callers get a rejection.
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00:
|
||||
Operators choose free creation, an operator-mailbox proposal, or denial for ephemeral worker follow-ups.
|
||||
The legacy boolean only supplies the displayed fallback; changing this control persists the non-default policy key.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "ephemeralAgentsCanCreateTasks",
|
||||
label: t("settings.general.allowEphemeralAgentsToCreateTasks", " Allow ephemeral agents to create tasks "),
|
||||
help: t("settings.general.allowEphemeralAgentsToCreateTasksHint", "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected."),
|
||||
key: "ephemeralAgentTaskCreationPolicy",
|
||||
label: t("settings.general.ephemeralAgentTaskCreationPolicy", "Ephemeral agent follow-up tasks"),
|
||||
help: t("settings.general.ephemeralAgentTaskCreationPolicyHint", "No default — unset policy falls back to Allow. Upon validation sends a proposal to your mailbox for one-click approval; Deny rejects follow-up task creation."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "allow", label: t("settings.general.ephemeralAgentTaskCreationPolicyAllow", "Allow") },
|
||||
{ value: "upon_validation", label: t("settings.general.ephemeralAgentTaskCreationPolicyUponValidation", "Upon validation") },
|
||||
{ value: "deny", label: t("settings.general.ephemeralAgentTaskCreationPolicyDeny", "Deny") },
|
||||
],
|
||||
}}
|
||||
value={form.ephemeralAgentsCanCreateTasks !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ephemeralAgentsCanCreateTasks: v === true }))}
|
||||
value={form.ephemeralAgentTaskCreationPolicy ?? (form.ephemeralAgentsCanCreateTasks === false ? "deny" : "allow")}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ephemeralAgentTaskCreationPolicy: v as "allow" | "upon_validation" | "deny" }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:Workspace 2026-06-24-16:00:
|
||||
|
||||
@@ -247,7 +247,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
chatRoomRecentVerbatimMessages: "general.numberOfMostRecentChatRoomMessagesKept",
|
||||
chatRoomSummaryMaxChars: "general.hardCapOnTheSynthesizedEarlierRoomContext",
|
||||
completionDocumentationMode: "general.workflowsOrChangelogModeWhenContributorsShouldUpdate",
|
||||
ephemeralAgentsCanCreateTasks: "general.allowEphemeralAgentsToCreateTasksHint",
|
||||
ephemeralAgentTaskCreationPolicy: "general.ephemeralAgentTaskCreationPolicyHint",
|
||||
ephemeralAgentsEnabled: "general.whenEnabledDefaultFusionSpawnsShortLived",
|
||||
githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource",
|
||||
// FNXC:GitHubImportTranslate 2026-07-15-09:30: surfaced as plain rows in
|
||||
@@ -288,6 +288,8 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
|
||||
/** Setting keys intentionally not surfaced as a plain Settings UI description field, with reasons. */
|
||||
const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
|
||||
// Legacy compatibility input; GeneralSection exposes its policy replacement instead.
|
||||
ephemeralAgentsCanCreateTasks: "legacy compatibility input replaced by ephemeralAgentTaskCreationPolicy",
|
||||
// Global-only serve/dashboard LAN discovery switch; no Settings UI description field exists.
|
||||
localNetworkDiscoveryEnabled: "global-only LAN discovery runtime switch",
|
||||
// Moved to workflow settings (U4) — see MOVED_SETTINGS_KEYS in settings-schema.ts.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { DASHBOARD_USER_ID, type Message, type TaskStore } from "@fusion/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { request } from "../../test-request.js";
|
||||
import { registerMessagingScriptRoutes } from "../register-messaging-scripts.js";
|
||||
import type { ApiRoutesContext } from "../types.js";
|
||||
|
||||
function proposal(status: "pending" | "creating" | "created" = "creating"): Message {
|
||||
return {
|
||||
id: "message-1", fromId: "agent-1", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user",
|
||||
content: "Proposal", type: "agent-to-user", read: false,
|
||||
metadata: {
|
||||
kind: "task-proposal", proposalStatus: status, proposalIdempotencyKey: "stable-proposal-key",
|
||||
proposedTask: { title: "Follow up", description: "Implement it" },
|
||||
},
|
||||
createdAt: "2026-07-30T00:00:00.000Z", updatedAt: "2026-07-30T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const createdTask = { id: "FN-8265", proposalClaimId: "stable-proposal-key" };
|
||||
const messageStore = {
|
||||
getMessage: vi.fn(async () => proposal()),
|
||||
reconcileProposalCreation: vi.fn(async (_id: string, taskId?: string) => ({ ...proposal("created"), metadata: { ...proposal("created").metadata, createdTaskId: taskId } })),
|
||||
claimProposalForCreation: vi.fn(), finalizeProposalCreation: vi.fn(), releaseProposalClaim: vi.fn(),
|
||||
};
|
||||
const store = {
|
||||
getRootDir: () => "/test", listTasks: vi.fn(async () => [createdTask]), getTask: vi.fn(async () => createdTask), createTask: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
const context = {
|
||||
router: express.Router(), store,
|
||||
getProjectContext: async () => ({ store, engine: { getMessageStore: () => messageStore }, projectId: undefined }),
|
||||
rethrowAsApiError: (error: unknown): never => { throw error; }, runtimeLogger: { warn: vi.fn() }, planningLogger: {}, chatLogger: {},
|
||||
} as unknown as ApiRoutesContext;
|
||||
registerMessagingScriptRoutes(context);
|
||||
app.use("/api", context.router);
|
||||
return { app, messageStore, store };
|
||||
}
|
||||
|
||||
describe("task proposal materialization route", () => {
|
||||
it("reconciles a creating proposal to its already-created task instead of rejecting or creating again", async () => {
|
||||
const { app, messageStore, store } = setup();
|
||||
const response = await request(app, "POST", "/api/messages/message-1/create-proposed-task");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.task.id).toBe("FN-8265");
|
||||
expect(messageStore.reconcileProposalCreation).toHaveBeenCalledWith("message-1", "FN-8265");
|
||||
expect(messageStore.claimProposalForCreation).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(store.createTask)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a creating claim untouched while its task creation may still be in flight", async () => {
|
||||
const { app, messageStore, store } = setup();
|
||||
vi.mocked(store.listTasks).mockResolvedValue([]);
|
||||
vi.mocked(messageStore.getMessage).mockResolvedValue({
|
||||
...proposal(),
|
||||
metadata: { ...proposal().metadata, claimStartedAt: new Date().toISOString() },
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const response = await request(app, "POST", "/api/messages/message-1/create-proposed-task");
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(messageStore.reconcileProposalCreation).not.toHaveBeenCalled();
|
||||
expect(messageStore.claimProposalForCreation).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(store.createTask)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases an expired crashed claim without rotating its stable key, then reclaims it", async () => {
|
||||
const { app, messageStore, store } = setup();
|
||||
const stale = {
|
||||
...proposal(),
|
||||
metadata: { ...proposal().metadata, claimStartedAt: new Date(Date.now() - 31_000).toISOString() },
|
||||
updatedAt: new Date(Date.now() - 31_000).toISOString(),
|
||||
};
|
||||
const pending = { ...stale, metadata: { ...stale.metadata, proposalStatus: "pending" as const, claimOwnerToken: undefined, claimStartedAt: undefined } };
|
||||
let current: Message = stale;
|
||||
vi.mocked(messageStore.getMessage).mockImplementation(async () => current);
|
||||
vi.mocked(messageStore.reconcileProposalCreation).mockImplementation(async (_id: string, taskId?: string) => {
|
||||
current = taskId ? { ...current, metadata: { ...current.metadata, proposalStatus: "created", createdTaskId: taskId } } : pending;
|
||||
return current;
|
||||
});
|
||||
vi.mocked(messageStore.claimProposalForCreation).mockResolvedValue({ claimed: true, idempotencyKey: "stable-proposal-key", claimOwnerToken: "new-owner" });
|
||||
vi.mocked(store.listTasks).mockResolvedValue([]);
|
||||
vi.mocked(store.createTask).mockResolvedValue({ id: "FN-8265", proposalClaimId: "stable-proposal-key" } as never);
|
||||
vi.mocked(messageStore.finalizeProposalCreation).mockResolvedValue({ ...pending, metadata: { ...pending.metadata, proposalStatus: "created", createdTaskId: "FN-8265" } });
|
||||
|
||||
const response = await request(app, "POST", "/api/messages/message-1/create-proposed-task");
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(messageStore.reconcileProposalCreation).toHaveBeenCalledWith("message-1", undefined);
|
||||
expect(messageStore.claimProposalForCreation).toHaveBeenCalledWith("message-1");
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ proposalClaimId: "stable-proposal-key" }));
|
||||
});
|
||||
});
|
||||
@@ -401,6 +401,107 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00:
|
||||
Claim precedes task creation. Every retry uses the proposal's never-rotated key as proposalClaimId,
|
||||
so the database unique index returns the one existing task across concurrent clicks, crashes, and reclaim races.
|
||||
The durable creation lease also bounds a pre-persistence crash: expiry releases only transient ownership,
|
||||
then a retry reuses that same key while any slow original insertion remains idempotent.
|
||||
*/
|
||||
const TASK_PROPOSAL_CREATION_LEASE_MS = 30_000;
|
||||
|
||||
router.post("/messages/:id/create-proposed-task", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const msgStore = await getMessageStore(req);
|
||||
let message = await msgStore.getMessage(req.params.id);
|
||||
let metadata = message?.metadata;
|
||||
if (!message || message.toId !== DASHBOARD_USER_ID || message.toType !== "user" || metadata?.kind !== "task-proposal" || !metadata.proposedTask || !metadata.proposalIdempotencyKey) throw badRequest("Invalid operator task proposal");
|
||||
const messageId = message.id;
|
||||
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-15:00:
|
||||
A request observing an active creating lease must return in-progress when no durable
|
||||
task is visible. Releasing that live lease here would let a second request create while
|
||||
the original is still inserting. Only a separately scheduled expired-lease recovery may
|
||||
release it; every create still carries the stable unique proposalClaimId.
|
||||
*/
|
||||
const findCreatedTask = async (proposalIdempotencyKey: string) =>
|
||||
(await scopedStore.listTasks({ includeArchived: true })).find((task) => task.proposalClaimId === proposalIdempotencyKey);
|
||||
|
||||
if (metadata.proposalStatus === "created" && metadata.createdTaskId) {
|
||||
const task = await scopedStore.getTask(metadata.createdTaskId).catch(() => null);
|
||||
if (task) {
|
||||
res.json({ task, proposal: message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.proposalStatus === "creating" && message) {
|
||||
const creatingMessage = message;
|
||||
const existingTask = await findCreatedTask(metadata.proposalIdempotencyKey);
|
||||
if (existingTask) {
|
||||
await msgStore.reconcileProposalCreation(messageId, existingTask.id);
|
||||
message = await msgStore.getMessage(messageId);
|
||||
if (message) {
|
||||
res.json({ task: existingTask, proposal: message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-16:00:
|
||||
A creating claim survives a process death. Once its durable lease expires and no task is
|
||||
findable by the never-rotated proposal key, release only transient ownership and retry the
|
||||
normal claim path. A slow original insert and a reclaimer use the same unique key, so either
|
||||
order returns one task rather than allowing a duplicate.
|
||||
*/
|
||||
const leaseStartedAt = Date.parse(metadata.claimStartedAt ?? creatingMessage.updatedAt);
|
||||
if (Number.isFinite(leaseStartedAt) && Date.now() - leaseStartedAt >= TASK_PROPOSAL_CREATION_LEASE_MS) {
|
||||
await msgStore.reconcileProposalCreation(messageId, undefined);
|
||||
message = await msgStore.getMessage(messageId);
|
||||
metadata = message?.metadata;
|
||||
} else {
|
||||
// Do not release a currently held claim based on a read that may race its insert.
|
||||
res.status(409).json({ error: "Task proposal is already being created", proposal: creatingMessage });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (message && metadata?.proposalStatus === "creating") {
|
||||
res.status(409).json({ error: "Task proposal is already being created", proposal: message });
|
||||
return;
|
||||
}
|
||||
if (!message || !metadata || metadata.proposalStatus !== "pending") throw badRequest("Task proposal is not pending");
|
||||
const claim = await msgStore.claimProposalForCreation(messageId);
|
||||
if (!claim.claimed || !claim.idempotencyKey || !claim.claimOwnerToken) throw badRequest("Task proposal is already being created");
|
||||
|
||||
let task;
|
||||
try {
|
||||
const proposal = metadata.proposedTask;
|
||||
task = await scopedStore.createTask({ title: proposal!.title, description: proposal!.description, priority: proposal!.priority, dependencies: proposal!.dependencies, workflowId: proposal!.workflowId, proposalClaimId: claim.idempotencyKey });
|
||||
} catch (error) {
|
||||
await msgStore.releaseProposalClaim(messageId, claim.claimOwnerToken);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// A post-create finalization failure must reconcile to the durable task, never release it for a new create.
|
||||
let finalized = null;
|
||||
try {
|
||||
finalized = await msgStore.finalizeProposalCreation(messageId, claim.claimOwnerToken, task.id);
|
||||
} catch {
|
||||
// The durable task is the recovery source; reconciliation below links it on a retryable metadata failure.
|
||||
}
|
||||
if (!finalized) {
|
||||
finalized = await msgStore.reconcileProposalCreation(messageId, task.id);
|
||||
}
|
||||
res.status(201).json({ task, proposal: finalized ?? await msgStore.getMessage(messageId) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/messages/:id", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
|
||||
@@ -265,6 +265,7 @@ export type MessageSseEventType =
|
||||
| "message:sent"
|
||||
| "message:received"
|
||||
| "message:read"
|
||||
| "message:updated"
|
||||
| "message:deleted";
|
||||
|
||||
export type ApprovalSseEventType = "approval:requested" | "approval:updated" | "approval:decided";
|
||||
@@ -769,6 +770,10 @@ export function createSSE(
|
||||
send(`event: message:read\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
};
|
||||
|
||||
const onMessageUpdated = (message: unknown) => {
|
||||
send(`event: message:updated\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
};
|
||||
|
||||
const onMessageDeleted = (messageId: string) => {
|
||||
send(`event: message:deleted\ndata: ${JSON.stringify({ id: messageId })}\n\n`);
|
||||
};
|
||||
@@ -951,6 +956,7 @@ export function createSSE(
|
||||
messageStore.off("message:sent", onMessageSent);
|
||||
messageStore.off("message:received", onMessageReceived);
|
||||
messageStore.off("message:read", onMessageRead);
|
||||
messageStore.off("message:updated", onMessageUpdated);
|
||||
messageStore.off("message:deleted", onMessageDeleted);
|
||||
}
|
||||
approvalSseListeners.delete(onApprovalEvent);
|
||||
@@ -1072,6 +1078,7 @@ export function createSSE(
|
||||
messageStore.on("message:sent", onMessageSent);
|
||||
messageStore.on("message:received", onMessageReceived);
|
||||
messageStore.on("message:read", onMessageRead);
|
||||
messageStore.on("message:updated", onMessageUpdated);
|
||||
messageStore.on("message:deleted", onMessageDeleted);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { appendFile, mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
@@ -937,6 +937,9 @@ type AgentTaskCreationOptions = {
|
||||
Set true when fn_task_create is registered for an ephemeral/runtime task-worker session (executor-FN-XXXX). The tool then honors the project `ephemeralAgentsCanCreateTasks` toggle and rejects creation when it is disabled. Permanent-agent sessions leave this unset and are never gated.
|
||||
*/
|
||||
callerIsEphemeral?: boolean;
|
||||
messageStore?: MessageStore;
|
||||
sourceAgentId?: string;
|
||||
sourceTaskId?: string;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -1083,14 +1086,23 @@ export function createTaskCreateTool(
|
||||
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
|
||||
? await store.getSettings().catch(() => ({} as Settings))
|
||||
: ({} as Settings);
|
||||
if ((settings as Settings).ephemeralAgentsCanCreateTasks === false) {
|
||||
const message =
|
||||
"Ephemeral task-worker agents are not allowed to create tasks (ephemeralAgentsCanCreateTasks is disabled for this project).";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
|
||||
details: { error: message, rule: "ephemeral-agents-cannot-create-tasks" },
|
||||
isError: true,
|
||||
};
|
||||
const policy = fusionCore.resolveEphemeralTaskCreationPolicy(settings as Settings);
|
||||
if (policy === "deny") {
|
||||
const message = "Ephemeral task-worker agents are not allowed to create tasks (ephemeral agent task creation is denied for this project).";
|
||||
return { content: [{ type: "text" as const, text: `ERROR: ${message}` }], details: { error: message, rule: "ephemeral-agents-cannot-create-tasks" }, isError: true };
|
||||
}
|
||||
if (policy === "upon_validation") {
|
||||
if (!options.messageStore) {
|
||||
const message = "Task proposal validation is configured but the mailbox is unavailable; no task was created.";
|
||||
return { content: [{ type: "text" as const, text: `ERROR: ${message}` }], details: { error: message, rule: "ephemeral-agents-cannot-create-tasks" }, isError: true };
|
||||
}
|
||||
const title = params.description.split(/\r?\n/, 1)[0]?.trim().slice(0, 80) || "Follow-up task";
|
||||
await options.messageStore.sendMessage({
|
||||
fromId: options.sourceAgentId ?? provenance?.sourceAgentId ?? "ephemeral-worker", fromType: "agent", toId: DASHBOARD_USER_ID, toType: "user", type: "agent-to-user",
|
||||
content: `Task proposal awaiting validation: ${title}`,
|
||||
metadata: { kind: "task-proposal", proposalStatus: "pending", proposalIdempotencyKey: randomUUID(), taskId: options.sourceTaskId, proposedTask: { title, description: params.description, priority: params.priority, workflowId: params.workflow_id, dependencies: params.dependencies } },
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: "Task proposal submitted to the operator for validation; no task was created." }], details: { proposed: true } };
|
||||
}
|
||||
}
|
||||
const workflowId = params.workflow_id?.trim() || undefined;
|
||||
|
||||
@@ -11046,6 +11046,9 @@ export class TaskExecutor {
|
||||
// Pass agentStore and messageStore for delegation and messaging tools
|
||||
agentStore: this.options.agentStore,
|
||||
messageStore: this.options.messageStore,
|
||||
callerIsEphemeral: !stepIdentityAgent || isEphemeralAgent(stepIdentityAgent),
|
||||
sourceTaskId: task.id,
|
||||
sourceAgentId: stepIdentityAgent?.id,
|
||||
taskEnv,
|
||||
onStepStart: (stepIndex) => {
|
||||
this.options.stuckTaskDetector?.recordProgress(task.id);
|
||||
@@ -11609,7 +11612,7 @@ export class TaskExecutor {
|
||||
this.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stepCheckpoints, stuckDetector),
|
||||
this.createTaskLogTool(task.id),
|
||||
this.createTaskLogsReadTool(task.id),
|
||||
this.createTaskCreateTool(!identityAgent || isEphemeralAgent(identityAgent)),
|
||||
this.createTaskCreateTool(!identityAgent || isEphemeralAgent(identityAgent), task.id, identityAgent?.id),
|
||||
this.createTaskAddDepTool(task.id),
|
||||
this.createTaskDoneTool(task.id, worktreePath, detail.prompt ?? "", codeReviewVerdicts, () => { taskDone = true; }, audit),
|
||||
createRunVerificationTool({
|
||||
@@ -13849,8 +13852,8 @@ export class TaskExecutor {
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
||||
A task-execution session is an ephemeral worker when no permanent identity agent governs it (default executor-FN-XXXX worker) or the governing agent is itself ephemeral. Pass that through so fn_task_create honors the project `ephemeralAgentsCanCreateTasks` toggle; permanent-agent sessions are never gated.
|
||||
*/
|
||||
private createTaskCreateTool(callerIsEphemeral: boolean): ToolDefinition {
|
||||
return sharedCreateTaskCreateTool(this.store, { sourceType: "api" }, { rootDir: this.rootDir, callerIsEphemeral });
|
||||
private createTaskCreateTool(callerIsEphemeral: boolean, sourceTaskId?: string, sourceAgentId?: string): ToolDefinition {
|
||||
return sharedCreateTaskCreateTool(this.store, { sourceType: "api", sourceAgentId, sourceParentTaskId: sourceTaskId }, { rootDir: this.rootDir, callerIsEphemeral, sourceTaskId, sourceAgentId, messageStore: this.options.messageStore });
|
||||
}
|
||||
|
||||
private createTaskDocumentWriteTool(taskId: string): ToolDefinition {
|
||||
|
||||
@@ -132,8 +132,12 @@ export interface StepSessionExecutorOptions {
|
||||
additionalSkillPaths?: string[];
|
||||
/** Optional agent store for delegation tools. */
|
||||
agentStore?: AgentStore;
|
||||
/** Optional message store for messaging tools. */
|
||||
/** Optional message store for messaging tools and validated follow-up proposals. */
|
||||
messageStore?: MessageStore;
|
||||
/** Whether this step session is a runtime ephemeral worker subject to task-proposal policy. */
|
||||
callerIsEphemeral?: boolean;
|
||||
sourceTaskId?: string;
|
||||
sourceAgentId?: string;
|
||||
/** Optional action-gate context for permanent assigned agents. */
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
/** Optional permanent-agent action gating context. */
|
||||
@@ -1307,7 +1311,7 @@ export class StepSessionExecutor {
|
||||
]
|
||||
: [];
|
||||
const taskCreateTool = this.options.store
|
||||
? [createTaskCreateTool(this.options.store, undefined, { rootDir: this.options.rootDir })]
|
||||
? [createTaskCreateTool(this.options.store, undefined, { rootDir: this.options.rootDir, callerIsEphemeral: this.options.callerIsEphemeral, sourceTaskId: this.options.sourceTaskId ?? taskDetail.id, sourceAgentId: this.options.sourceAgentId ?? taskDetail.assignedAgentId, messageStore: this.options.messageStore })]
|
||||
: [];
|
||||
|
||||
// Agent delegation tools — discover and delegate work to other agents.
|
||||
|
||||
@@ -5917,6 +5917,11 @@
|
||||
"followDashboardLanguage": "Follow dashboard language",
|
||||
"gitLabEnabledHint": "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).",
|
||||
"allowEphemeralAgentsToCreateTasksHint": "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.",
|
||||
"ephemeralAgentTaskCreationPolicy": "Ephemeral agent follow-up tasks",
|
||||
"ephemeralAgentTaskCreationPolicyHint": "No default — unset policy falls back to Allow. Upon validation sends a proposal to your mailbox for one-click approval; Deny rejects follow-up task creation.",
|
||||
"ephemeralAgentTaskCreationPolicyAllow": "Allow",
|
||||
"ephemeralAgentTaskCreationPolicyUponValidation": "Upon validation",
|
||||
"ephemeralAgentTaskCreationPolicyDeny": "Deny",
|
||||
"quickChatCloseOnOutsideClickHint": "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",
|
||||
"disabledFusionWorkflowsAreHiddenFromWorkflow": "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset).",
|
||||
"aiUndoTaskWorkflow": "AI-undo task workflow",
|
||||
|
||||
Reference in New Issue
Block a user