diff --git a/.changeset/fn-8265-task-follow-up-policy.md b/.changeset/fn-8265-task-follow-up-policy.md new file mode 100644 index 0000000000..614403d34d --- /dev/null +++ b/.changeset/fn-8265-task-follow-up-policy.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0652b6859b..801494ceb2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 656d63a2a3..6d17730875 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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 diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 9ce51b7ae2..03a2965bff 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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); diff --git a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts index a28a7c2718..5db70019e6 100644 --- a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts +++ b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts @@ -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` diff --git a/packages/core/src/__tests__/postgres/task-proposal-claim.pg.test.ts b/packages/core/src/__tests__/postgres/task-proposal-claim.pg.test.ts new file mode 100644 index 0000000000..6f6f9eced7 --- /dev/null +++ b/packages/core/src/__tests__/postgres/task-proposal-claim.pg.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/async-message-store.ts b/packages/core/src/async-message-store.ts index fd707ab99b..69affaf878 100644 --- a/packages/core/src/async-message-store.ts +++ b/packages/core/src/async-message-store.ts @@ -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 { + 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 { + 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 { + 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 { + 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). diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 572e35f9ac..68c26e456b 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -42,8 +42,8 @@ version of this file was falsified: 141/335 gate tests failed on missing etc. pulled in by production modules, not test files). */ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d1bc7a1041..46959a8fbf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, PLANNING_DEEPEN_CHECKPOINT_ID, PLANNING_DEEPEN_CHECKPOINT_QUESTION, PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/message-store.ts b/packages/core/src/message-store.ts index d6dc3f148e..56826a797a 100644 --- a/packages/core/src/message-store.ts +++ b/packages/core/src/message-store.ts @@ -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 { 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 { + 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 { + 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 { + 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 diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 9632c4f4ce..da174caf7b 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -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). diff --git a/packages/core/src/postgres/migrations/0020_task_proposal_claim.sql b/packages/core/src/postgres/migrations/0020_task_proposal_claim.sql new file mode 100644 index 0000000000..316ac586dc --- /dev/null +++ b/packages/core/src/postgres/migrations/0020_task_proposal_claim.sql @@ -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; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 5b0f339a35..d41ab4db5b 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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 diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 6f684926ef..da6dbee69d 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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 diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 8a3cdee763..bbb7b2a88f 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -59,7 +59,8 @@ type MovedProjectSettingsKey = | "validatorFallbackModelId" | "validatorFallbackThinkingLevel"; -type ProjectSettingsSchema = Omit; +type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy"; +type ProjectSettingsSchema = Omit; /** * Settings schema source of truth. @@ -807,10 +808,20 @@ export const GLOBAL_SETTINGS_KEYS = Object.freeze( Object.keys(DEFAULT_GLOBAL_SETTINGS) as Array, ); +/* +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, -); +export const PROJECT_SETTINGS_KEYS = Object.freeze([ + ...Object.keys(DEFAULT_PROJECT_SETTINGS), + ...NON_DEFAULT_PROJECT_SETTINGS_KEYS, +] as Array); export function isGlobalSettingsKey(key: string): key is keyof GlobalSettings { return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key); diff --git a/packages/core/src/task-store/async-persistence.ts b/packages/core/src/task-store/async-persistence.ts index 57056153f7..0dab106cf0 100644 --- a/packages/core/src/task-store/async-persistence.ts +++ b/packages/core/src/task-store/async-persistence.ts @@ -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 diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 6fa4cf5203..f7d0fb2f09 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -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), diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 85ae29f476..73295e8b04 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -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>(row.sourceMetadata) ?? undefined; return withTaskBranchContextInSourceMetadata(parsed, parseTaskBranchContextFromSourceMetadata(parsed)); diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 674dc5e548..a89e3892ad 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -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; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; },): Promise { 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, 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), diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index 6f9cd65366..e338bba25b 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -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 ms` entries before diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef5cb88d67..5078aa1f49 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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): 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 { /** Optional link to the original message when this message is a reply. */ replyTo?: MessageReplyReference; @@ -6878,6 +6900,17 @@ export interface MessageMetadata extends Record { * 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 */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e43e805aa4..ed2d66f6e4 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { return api(withProjectId(`/messages/${encodeURIComponent(id)}/read`, projectId), { diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 82de95f091..6901a414bc 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -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} /> + ); })} @@ -931,6 +934,7 @@ export function MailboxModal({ taskId={selectedMessage.metadata?.taskId} onOpenTask={onOpenTask} /> + )} diff --git a/packages/dashboard/app/components/MailboxTaskProposal.css b/packages/dashboard/app/components/MailboxTaskProposal.css new file mode 100644 index 0000000000..a33fb0f48c --- /dev/null +++ b/packages/dashboard/app/components/MailboxTaskProposal.css @@ -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%; } } diff --git a/packages/dashboard/app/components/MailboxTaskProposal.tsx b/packages/dashboard/app/components/MailboxTaskProposal.tsx new file mode 100644 index 0000000000..f45613a756 --- /dev/null +++ b/packages/dashboard/app/components/MailboxTaskProposal.tsx @@ -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
+ {proposal.title}

{proposal.description}

+ {status === "pending" && } + {status === "creating" && } + {status === "created" && currentMetadata.createdTaskId && } + {status === "dismissed" && Task proposal dismissed} +
; +} diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 7090de1665..11593df99d 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -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} /> + ); })} @@ -976,6 +979,7 @@ export function MailboxView({ taskId={selectedMessage.metadata?.taskId} onOpenTask={onOpenTask} /> + )} diff --git a/packages/dashboard/app/components/__tests__/MailboxTaskProposal.test.tsx b/packages/dashboard/app/components/__tests__/MailboxTaskProposal.test.tsx new file mode 100644 index 0000000000..e30f5fe267 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxTaskProposal.test.tsx @@ -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(); + 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(); + + 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"); + }); +}); diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 3610450d7e..42087448ae 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -65,7 +65,7 @@ const PROJECT_SECTION_KEYS: Record = { "chatRoomSummaryMaxChars", "completionDocumentationMode", "enabledBuiltinWorkflowIds", - "ephemeralAgentsCanCreateTasks", + "ephemeralAgentTaskCreationPolicy", "ephemeralAgentsEnabled", "sessionAdvisorEnabledByDefault", "mailAutoCleanupDays", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.search.ts b/packages/dashboard/app/components/settings/sections/GeneralSection.search.ts index 4b3ed84c64..7ea66c8f1a 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.search.ts +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.search.ts @@ -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", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index fc70415464..e074b39ffd 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -208,18 +208,24 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError {/* - 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. */} - 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: diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 0d7f3d6606..cce8ff86ab 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -247,7 +247,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { 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 = { /** Setting keys intentionally not surfaced as a plain Settings UI description field, with reasons. */ const NOT_SURFACED_ALLOWLIST: Record = { + // 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. diff --git a/packages/dashboard/src/routes/__tests__/task-proposal-routes.test.ts b/packages/dashboard/src/routes/__tests__/task-proposal-routes.test.ts new file mode 100644 index 0000000000..6e240fae0f --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/task-proposal-routes.test.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" })); + }); +}); diff --git a/packages/dashboard/src/routes/register-messaging-scripts.ts b/packages/dashboard/src/routes/register-messaging-scripts.ts index 407be9075b..fef4bea501 100644 --- a/packages/dashboard/src/routes/register-messaging-scripts.ts +++ b/packages/dashboard/src/routes/register-messaging-scripts.ts @@ -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); diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 358e0da335..1cc23250ef 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -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); } diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 40d4195469..ab2b8c5e3e 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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; diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 24f0122684..4ce2fb5972 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 { diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 6c03aebbb2..3a443a3926 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -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. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index f8add89be8..a0db3031f7 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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",