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:
gsxdsm
2026-07-18 03:46:27 -07:00
parent 454a2bda28
commit 7c23771433
38 changed files with 696 additions and 66 deletions

View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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`

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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),

View File

@@ -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));

View File

@@ -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),

View File

@@ -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

View File

@@ -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 */

View File

@@ -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), {

View File

@@ -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>

View File

@@ -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%; } }

View 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>;
}

View File

@@ -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>

View File

@@ -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");
});
});

View File

@@ -65,7 +65,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
"chatRoomSummaryMaxChars",
"completionDocumentationMode",
"enabledBuiltinWorkflowIds",
"ephemeralAgentsCanCreateTasks",
"ephemeralAgentTaskCreationPolicy",
"ephemeralAgentsEnabled",
"sessionAdvisorEnabledByDefault",
"mailAutoCleanupDays",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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.

View File

@@ -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" }));
});
});

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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",