diff --git a/.changeset/fn-125-removal.md b/.changeset/fn-125-removal.md new file mode 100644 index 0000000000..c61b0b659a --- /dev/null +++ b/.changeset/fn-125-removal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Executing agents no longer create tasks; out-of-scope findings become completion recommendations. +category: breaking +dev: Withholds fn_task_create/fn_delegate_task by task-execution lane, marks sessions with taskExecutionSession, refuses extension calls with task-execution-cannot-create-tasks, and rejects SELF_SPAWNED_DEPENDENCY edges. diff --git a/docs/agents.md b/docs/agents.md index 30fa97c8a8..9ebe6e5fcc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -95,7 +95,7 @@ printf "deploy report" | fn chat agent-abc123 --once --non-interactive ## Mission lineage and task creation -`fn_task_create` and `fn_delegate_task` use two distinct controls. In an autonomous no-task heartbeat, the caller must provide approved `mission_lineage` (mission, slice, and feature); a rejection states that approved mission lineage is required, and no permission grant overrides it. In interactive/user-supervised and task-scoped sessions, lineage is optional and `task_agent_mutation` category rules and exact-tool overrides decide whether creation is allowed, requires approval, or is blocked. +`fn_task_create` and `fn_delegate_task` use two distinct controls. A session executing a board task, including the durable Workflow Executor, implementation/retry, both workflow-step lanes, verification-fix, and spawned-child sessions, cannot create or delegate tasks. It records optional out-of-scope findings as `fn_task_done` completion recommendations, implements in-scope needs directly, and uses the honest blocked exit only for real external blockers. In an autonomous no-task heartbeat, the caller must provide approved `mission_lineage` (mission, slice, and feature); a rejection states that approved mission lineage is required, and no permission grant overrides it. In interactive/user-supervised non-execution sessions, lineage is optional and `task_agent_mutation` category rules and exact-tool overrides decide whether creation is allowed, requires approval, or is blocked. A valid active lineage can bootstrap the first task for a hand-authored `defined` feature. The feature is linked to that exact task and promoted to `triaged`; later autonomous scheduler work still requires a `triaged` or `in-progress` feature. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a1cb8ddacf..895126066c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1591,7 +1591,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, the **Merge Details** card (merge status, commit, PR, timestamp, and message), changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. -- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. Enable **Settings → General → Require automatic task recommendations** to make every positive-cap successful completion explicitly submit an array at the accepted completion checkpoint; this is automatic executor capture, not a background generator or a retroactive backfill. The executor aims toward the configured maximum from concrete source-task/worktree evidence, but a shorter list or `[]` is correct when grounded candidates run out. Duplicate, restated, speculative, filler, or scope-drifting suggestions are never valid, and relevance always outranks count. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. Fusion does not automatically create follow-up tasks from recommendations and does not generate them for already-completed tasks; operators use each row's **Create task** action when appropriate. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task. The same recommendations also appear project-wide in **Insights → Task Recommendations**, where row pagination and an explicit **Load more** control keep the aggregate bounded without hiding later suggestions. +- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. Enable **Settings → General → Require automatic task recommendations** to make every positive-cap successful completion explicitly submit an array at the accepted completion checkpoint; this is automatic executor capture, not a background generator or a retroactive backfill. The executor aims toward the configured maximum from concrete source-task/worktree evidence, but a shorter list or `[]` is correct when grounded candidates run out. Duplicate, restated, speculative, filler, or scope-drifting suggestions are never valid, and relevance always outranks count. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. For executing agents, recommendations are the only out-of-scope channel; they implement in-scope needs directly and use the honest blocked exit only for real external blockers. Fusion does not automatically create follow-up tasks from recommendations and does not generate them for already-completed tasks; operators use each row's **Create task** action when appropriate. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task. The same recommendations also appear project-wide in **Insights → Task Recommendations**, where row pagination and an explicit **Load more** control keep the aggregate bounded without hiding later suggestions. - The **Cost** tab is available for tasks in every column and sits immediately after **Comments → Terminal** in the tab strip. It shows the read-time derived per-model cost breakdown (input, output, cached, cache-write, total tokens, derived USD) and a task total; no token usage shows an explicit empty state, while unpriced or zero-usage rows use `—` instead of a guessed `$0`. - The **Models** tab exposes inline **Thinking Level** selectors for **Executor Model**, **Reviewer Model**, and **Planning Model**. Executor saves the shared task thinking level, while Reviewer and Planning save independent per-lane overrides; leaving either lane on **Default** inherits the shared task thinking level and then the configured workflow/project defaults. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6569b19edb..217921b32c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1850,13 +1850,13 @@ Project-scoped default permission policy for agent runtime action gates. It appl ### `ephemeralAgentTaskCreationPolicy` -Project-scoped policy for ephemeral/runtime-managed task workers calling `fn_task_create` or `fn_delegate_task` (delegation creates a task through the same path, so the policy governs both). +Project-scoped policy for ephemeral/runtime-managed non-execution task workers calling `fn_task_create` or `fn_delegate_task` (delegation creates a task through the same path, so the policy governs both). Task-execution sessions never receive either tool regardless of this policy or principal ephemerality. - `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. `fn_delegate_task` is withheld under this policy — delegation has no proposal channel, so allowing it would bypass the operator review this policy exists to require. - `deny` withholds both tools from the agent's tool list entirely: an ephemeral session never sees `fn_task_create` or `fn_delegate_task`, and the session prompt states that creation is disabled and points the agent at `fn_task_log` instead. An execute-time refusal is retained as defense in depth. Permanent agents and human/dashboard callers are unaffected. - - Suppression emits an `agent:task-create-withheld` run-audit event (ids/policy/outcomes only) so an operator can tell "policy suppressed the tool" apart from "the agent had nothing to file". - - Known gap: the `fn_task_create` registered by the pi extension is gated only at execute time, and that gate does not currently fire because pi's extension context carries no agent identity. Enforcement today comes from the engine session lanes, which withhold the tools outright. + - Suppression emits an `agent:task-create-withheld` run-audit event with `reason: "task-execution-lane"` and `principalEphemeral` for executor-lane withholding. + - Implementation, retry, spawned-child, verification-fix, and both workflow-step execution lanes never receive either tool. The host extension copies execute-time refuse these marked principals, including the durable Workflow Executor. - 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. diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 436ea3aaf0..2dce1f8213 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -11,7 +11,7 @@ This reference documents tools injected by the engine at runtime for specific ag | Tool | Agent Types | Purpose | Parameters | |---|---|---|---| -| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) | +| `fn_task_create` | triage, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) | | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | | `fn_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named **live-task** document revision, optionally with CAS; archived parents remain read-only | `key` (string), `content` (string), `author?` (string), `expected_revision?` (non-negative integer), `expected_content_hash?` (`sha256:<64 lowercase hex>`); chat/planning also require `task_id` (string) | | `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one named live or retained archived document; list mode remains live-only | `key?` (string); chat/planning also require `task_id` (string) | @@ -53,7 +53,7 @@ Archived publication is deliberately absent from every runtime tool schema: ther | `fn_update_identity` | heartbeat | Update the current agent's own `soul`, `instructionsText`, or `memory` fields | `soul?` (string), `instructionsText?` (string), `memory?` (string) | | `fn_reflect_on_performance` | executor, heartbeat (when reflection service enabled) | Generate reflection insights from prior runs | `focus_area?` (string) | | `fn_list_agents` | triage, executor, heartbeat | List agents (optionally filtered) | `role?` (string), `state?` (string), `includeEphemeral?` (boolean) | -| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `workflow_id?` (string), `override?` (boolean) | +| `fn_delegate_task` | triage, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `workflow_id?` (string), `override?` (boolean) | | `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) | | `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` | | `fn_agent_create` | executor, heartbeat | Create a non-ephemeral direct-report agent | `name` (string), `role` (string), optional: `soul`, `instructions_text`, `instructions_path`, `reportsTo`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` | @@ -76,12 +76,12 @@ Archived publication is deliberately absent from every runtime tool schema: ther ## Executor-only runtime tools (`executor.ts`) -Note: step-session execution (`step-session-executor.ts`) reuses executor coordination tools (`fn_send_message`, `fn_read_messages`, `fn_list_agents`, `fn_delegate_task`, task-document tools, and memory tools) so spawned/session-sliced execution keeps parity with main executor runs. +Note: step-session execution (`step-session-executor.ts`) reuses executor coordination tools (`fn_send_message`, `fn_read_messages`, `fn_list_agents`, task-document tools, and memory tools) so spawned/session-sliced execution keeps parity with main executor runs. | Tool | Purpose | Parameters | |---|---|---| -| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 0-indexed; matches `### Step N:` in PROMPT.md, Step 0 = Preflight), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | -| `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) | +| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies (never a task it spawned), and/or workflow-defined custom field values | `step?` (number, 0-indexed; matches `### Step N:` in PROMPT.md, Step 0 = Preflight), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | +| `fn_task_add_dep` | Add a dependency to current task (confirmation-gated; never a task it spawned) | `task_id` (string), `confirm?` (boolean) | | `fn_task_done` | End the task: `outcome="completed"` (default) marks it complete; `outcome="blocked"` honestly parks it failed (`BLOCKED: `) with no completion claim, preserving steps/worktree and recording `blockedBy` as dependencies | `summary?` (string), `outcome?` (`completed` \| `blocked`), `blockedBy?` (string[]), `reason?` (string, required when blocked) | | `fn_spawn_agent` | Spawn child agent in separate worktree | `name` (string), `role` (enum), `task` (string) | | `fn_acquire_repo_worktree` | Acquire an isolated git worktree for a sub-repo in a workspace task (workspace mode only) | `repo` (string — must be one of the workspace's configured repos) | diff --git a/packages/cli/src/__tests__/extension-task-execution-task-creation.test.ts b/packages/cli/src/__tests__/extension-task-execution-task-creation.test.ts new file mode 100644 index 0000000000..0bd225c29c --- /dev/null +++ b/packages/cli/src/__tests__/extension-task-execution-task-creation.test.ts @@ -0,0 +1,70 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { + __clearFusionSessionIdentityRegistryForTests, + registerFusionSessionIdentity, +} from "@fusion/core"; +import { + createMockApi, + createPgExtensionHarness, + pgDescribe, + registerExtension, + requireTool, +} from "./pg-extension-harness.js"; + +const pgTest = pgDescribe; +const h = createPgExtensionHarness("fn-ext-task-execution-create"); + +pgTest("task-execution host extension task creation guard", () => { + beforeAll(h.beforeAll); + beforeEach(async () => { + __clearFusionSessionIdentityRegistryForTests(); + await h.beforeEach(); + }); + afterEach(async () => { + __clearFusionSessionIdentityRegistryForTests(); + await h.afterEach(); + }); + afterAll(h.afterAll); + + it("refuses every alternate task-producing tool before it can mutate the store", async () => { + const api = createMockApi(); + registerExtension(api); + const cwd = h.rootDir(); + const dispose = registerFusionSessionIdentity(cwd, { + agentId: "workflow-executor", + taskId: "FN-PARENT", + taskExecutionSession: true, + }); + const createTask = vi.spyOn(h.store(), "createTask"); + const duplicateTask = vi.spyOn(h.store(), "duplicateTask"); + const refineTask = vi.spyOn(h.store(), "refineTask"); + + try { + const calls: ReadonlyArray]> = [ + ["fn_task_duplicate", { id: "FN-OTHER" }], + ["fn_task_refine", { id: "FN-OTHER", feedback: "Follow up" }], + ["fn_task_import_github", { ownerRepo: "owner/repo" }], + ["fn_task_import_github_issue", { owner: "owner", repo: "repo", issueNumber: 1 }], + ["fn_task_import_gitlab_project_issues", { project: "group/project" }], + ["fn_task_import_gitlab_group_issues", { group: "group" }], + ["fn_task_import_gitlab_merge_requests", { project: "group/project" }], + ["fn_task_plan", {}], + ]; + + for (const [name, params] of calls) { + const result = await requireTool(api, name).execute(name, params, undefined, undefined, { cwd }); + expect(result.isError).toBe(true); + expect(result.details).toMatchObject({ + rule: "task-execution-cannot-create-tasks", + tool: name, + }); + } + } finally { + dispose(); + } + + expect(createTask).not.toHaveBeenCalled(); + expect(duplicateTask).not.toHaveBeenCalled(); + expect(refineTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 184a80c842..0348014c01 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -38,6 +38,8 @@ import { resolveTaskGithubTracking, formatCurrentTaskLine, resolveFusionSessionPrincipal, + isTaskExecutionSessionPrincipal, + taskExecutionTaskCreationRefusalText, resolveEffectiveAgentPermissionPolicy, type FusionSessionPrincipal, type AgentPermissionPolicy, @@ -1027,10 +1029,29 @@ async function findLatestApprovalRequestByDedupeKey( } /** - * FNXC:ToolPermissionGates 2026-07-26-13:55: - * Shared hard-deny for the withheld list above. Returns null for operator principals - * (tool proceeds unchanged) and a structured error result for agent/ambiguous principals. + * FNXC:TaskExecutionTaskCreation 2026-08-21-23:43: + * FN-125 closes every host-extension route that can add a board card, not only + * fn_task_create and fn_delegate_task. Pi injects this extension into coding sessions, + * so duplicate, refine, imports, and planning share this execute-time fence. */ +function denyTaskCreationForTaskExecutionPrincipal( + toolName: string, + ctx: ExtensionCallerContext, +): AgentGateDenyResult | null { + /* FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: explicit ctx.agentId + synthesizes an identity without the execution marker, so inspect the registry too. */ + const resolved = resolveExtensionCallerPrincipal(ctx); + const registry = resolveFusionSessionPrincipal(typeof ctx.cwd === "string" && ctx.cwd ? ctx.cwd : process.cwd()); + if (!isTaskExecutionSessionPrincipal(resolved) && !isTaskExecutionSessionPrincipal(registry)) return null; + const agentId = resolved.kind === "agent" ? resolved.identity.agentId : undefined; + const taskId = resolved.kind === "agent" ? resolved.identity.taskId : undefined; + return { + content: [{ type: "text", text: taskExecutionTaskCreationRefusalText(toolName) }], + isError: true, + details: { rule: "task-execution-cannot-create-tasks", tool: toolName, ...(taskId ? { taskId } : {}), ...(agentId ? { agentId } : {}) }, + }; +} + function denyWithheldToolForAgentPrincipal( toolName: string, ctx: ExtensionCallerContext, @@ -1732,6 +1753,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_create", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const store = await getStore(ctx.cwd); /* @@ -2816,6 +2839,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_duplicate", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const store = await getStore(ctx.cwd); const newTask = await store.duplicateTask(params.id); @@ -2852,6 +2877,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_refine", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const store = await getStore(ctx.cwd); const newTask = await store.refineTask(params.id, params.feedback); @@ -3049,6 +3076,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_import_github", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const [owner, repo] = params.ownerRepo.split("/"); const limit = clampImportBrowseLimit(params.limit, 30); const labels = params.labels; @@ -3143,6 +3172,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_import_github_issue", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const { owner, repo, issueNumber } = params; const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal }); @@ -3348,6 +3379,8 @@ export default function kbExtension(pi: ExtensionAPI) { promptSnippet: "Import GitLab project issues", parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })), labels: Type.Optional(Type.Array(Type.String())) }), async execute(_id, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_import_gitlab_project_issues", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const { client } = await createGitLabClient(ctx); const issues = await client.listProjectIssues(params.project, { limit: clampImportBrowseLimit(params.limit, 30), labels: params.labels }); const createdTasks = await importGitLabItems(ctx, "project_issue", params.project, issues); @@ -3375,6 +3408,8 @@ export default function kbExtension(pi: ExtensionAPI) { promptSnippet: "Import GitLab group issues", parameters: Type.Object({ group: Type.String({ description: "GitLab group path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })), labels: Type.Optional(Type.Array(Type.String())) }), async execute(_id, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_import_gitlab_group_issues", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const { client } = await createGitLabClient(ctx); const issues = await client.listGroupIssues(params.group, { limit: clampImportBrowseLimit(params.limit, 30), labels: params.labels }); const createdTasks = await importGitLabItems(ctx, "group_issue", params.group, issues); @@ -3402,6 +3437,8 @@ export default function kbExtension(pi: ExtensionAPI) { promptSnippet: "Import GitLab merge requests", parameters: Type.Object({ project: Type.String({ description: "GitLab project path or numeric ID" }), limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })), labels: Type.Optional(Type.Array(Type.String())) }), async execute(_id, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_import_gitlab_merge_requests", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; const { client } = await createGitLabClient(ctx); const mergeRequests = await client.listMergeRequests(params.project, { limit: clampImportBrowseLimit(params.limit, 30), labels: params.labels }); const createdTasks = await importGitLabItems(ctx, "merge_request", params.project, mergeRequests); @@ -3434,7 +3471,9 @@ export default function kbExtension(pi: ExtensionAPI) { resumeSessionId: Type.Optional(Type.String({ description: "Existing planning session id to resume instead of starting a new session" })), }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_task_plan", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; // Import the planning function dynamically to avoid circular dependencies const { runTaskPlan } = await import("./commands/task.js"); @@ -6538,6 +6577,8 @@ export default function kbExtension(pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const taskExecutionDenied = denyTaskCreationForTaskExecutionPrincipal("fn_delegate_task", ctx as ExtensionCallerContext); + if (taskExecutionDenied) return taskExecutionDenied; /* FNXC:ToolPermissionGates 2026-07-26-13:55: Ordinary delegation stays ungated (coordination primitive), but the executor-role diff --git a/packages/core/src/__tests__/postgres/store-self-spawned-dep.pg.test.ts b/packages/core/src/__tests__/postgres/store-self-spawned-dep.pg.test.ts new file mode 100644 index 0000000000..6847468c22 --- /dev/null +++ b/packages/core/src/__tests__/postgres/store-self-spawned-dep.pg.test.ts @@ -0,0 +1,56 @@ +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 self-spawned dependency guard (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_self_spawned_dependency", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("rejects both dependency write seams without changing the persisted parent", async () => { + const store = h.store(); + const parent = await store.createTask({ description: "parent" }); + const child = await store.createTask({ + description: "child", + source: { sourceType: "api", sourceParentTaskId: parent.id }, + }); + + await expect(store.updateTask(parent.id, { dependencies: [child.id] })).rejects.toMatchObject({ + name: "SelfSpawnedDependencyError", + code: "SELF_SPAWNED_DEPENDENCY", + }); + expect((await store.getTask(parent.id)).dependencies).toEqual([]); + + await expect(store.updateTaskDependencies(parent.id, { + operation: "add", + dependency: child.id, + })).rejects.toMatchObject({ + name: "SelfSpawnedDependencyError", + code: "SELF_SPAWNED_DEPENDENCY", + }); + expect((await store.getTask(parent.id)).dependencies).toEqual([]); + }); + + it("continues to allow independently planned dependencies", async () => { + const store = h.store(); + const parent = await store.createTask({ description: "parent" }); + const independent = await store.createTask({ description: "independent" }); + + const updated = await store.updateTaskDependencies(parent.id, { + operation: "add", + dependency: independent.id, + }); + + expect(updated.dependencies).toEqual([independent.id]); + }); +}); diff --git a/packages/core/src/__tests__/self-spawned-dependency.test.ts b/packages/core/src/__tests__/self-spawned-dependency.test.ts new file mode 100644 index 0000000000..7996da18b1 --- /dev/null +++ b/packages/core/src/__tests__/self-spawned-dependency.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { detectSelfSpawnedDependency } from "../task-store/errors.js"; + +describe("detectSelfSpawnedDependency", () => { + it("identifies only dependencies spawned by the task", () => { + expect(detectSelfSpawnedDependency("FN-1", [ + { id: "FN-CHILD", sourceParentTaskId: "FN-1" }, + { id: "FN-OTHER", sourceParentTaskId: "FN-2" }, + ])).toEqual({ dependencyId: "FN-CHILD" }); + }); + + it("ignores missing and unrelated parent links", () => { + expect(detectSelfSpawnedDependency("FN-1", [ + { id: "FN-NO-PARENT" }, + { id: "FN-OTHER", sourceParentTaskId: "FN-2" }, + ])).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/session-identity-registry.test.ts b/packages/core/src/__tests__/session-identity-registry.test.ts index 8cccab4a0d..b5fd0f3ca0 100644 --- a/packages/core/src/__tests__/session-identity-registry.test.ts +++ b/packages/core/src/__tests__/session-identity-registry.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { isTaskExecutionSessionPrincipal } from "../agents/task-execution-task-creation.js"; import { __clearFusionSessionIdentityRegistryForTests, registerFusionSessionIdentity, @@ -38,11 +39,12 @@ describe("session identity registry", () => { expect(resolveFusionSessionPrincipal("/tmp/wt-a")).toEqual({ kind: "operator" }); }); - it("two live sessions on one cwd resolve to ambiguous (fail closed)", () => { - registerFusionSessionIdentity("/tmp/project-root", { agentId: "agent-1" }); + it("round-trips the task-execution marker and fails closed for ambiguity", () => { + registerFusionSessionIdentity("/tmp/project-root", { agentId: "agent-1", taskExecutionSession: true }); registerFusionSessionIdentity("/tmp/project-root", { agentId: "agent-2" }); const principal = resolveFusionSessionPrincipal("/tmp/project-root"); expect(principal.kind).toBe("ambiguous"); + expect(isTaskExecutionSessionPrincipal(principal)).toBe(true); }); it("uses the invocation identity for concurrent sessions sharing a cwd", async () => { @@ -71,6 +73,14 @@ describe("session identity registry", () => { } }); + it("uses marker-free ALS identity over a marker-bearing registry entry", async () => { + const cwd = "/tmp/marker-precedence"; + registerFusionSessionIdentity(cwd, { agentId: "executor", taskExecutionSession: true }); + const principal = await runWithFusionSessionIdentity([cwd], { agentId: "heartbeat" }, async () => resolveFusionSessionPrincipal(cwd)); + expect(principal).toEqual(expect.objectContaining({ kind: "agent", identity: expect.objectContaining({ agentId: "heartbeat" }) })); + expect(isTaskExecutionSessionPrincipal(principal)).toBe(false); + }); + it("dispose is idempotent and only removes its own entry", () => { const disposeA = registerFusionSessionIdentity("/tmp/shared", { agentId: "agent-a" }); registerFusionSessionIdentity("/tmp/shared", { agentId: "agent-b" }); diff --git a/packages/core/src/agents/agent-prompts.ts b/packages/core/src/agents/agent-prompts.ts index 7467e521e8..15e6c48ebf 100644 --- a/packages/core/src/agents/agent-prompts.ts +++ b/packages/core/src/agents/agent-prompts.ts @@ -108,16 +108,11 @@ You have tools to report progress. The board updates in real-time. /* FNXC:TaskRecommendations 2026-08-09-04:06: -FN-8850 requires optional, non-blocking discoveries to be captured only at the explicit accepted -completion boundary. Immediate task creation remains for required dependency coordination or an -operator-directed filing, while workflow step sessions remain unable to write recommendations. +FN-125 requires task-execution sessions to preserve optional, non-blocking discoveries only at the +accepted completion boundary. Durable Workflow Executor sessions cannot create or delegate tasks; +in-scope work remains in the current task and external blockers use the honest blocked exit. */ -**Out-of-scope findings at completion:** Do not automatically create a task for optional, non-blocking work discovered outside this task. When recommendation capture is enabled, at the final accepted \`fn_task_done(outcome="completed")\` checkpoint evaluate genuine task-ready follow-ups and send \`recommendations\` (or \`recommendations: []\` when none qualify). Each recommendation needs a stable unique \`id\`, \`title\`, \`description\`, and \`category\`; never use it for a required current-task fix, blocker, secret, executable command, reasoning transcript, or filler. - -Use \`task_create\` or \`fn_delegate_task\` only when the task explicitly requires immediate filing, necessary dependency coordination, or the operator directs it. When creating multiple related tasks, declare dependencies between them: -\`task_create(description="load door sounds", dependencies=[])\` → returns KB-050 -\`task_create(description="play sound on door open/close", dependencies=["KB-050"])\` - +**Out-of-scope findings at completion:** This task-execution session cannot create or delegate tasks. When recommendation capture is enabled, at the final accepted \`fn_task_done(outcome="completed")\` checkpoint evaluate optional, non-blocking findings as genuine task-ready \`recommendations\` (or \`recommendations: []\` when none qualify). Each recommendation needs a stable unique \`id\`, \`title\`, \`description\`, and \`category\`; never use it for a required current-task fix, blocker, secret, executable command, reasoning transcript, or filler. Implement required in-scope work directly in this task. **Discovered a dependency:** \`task_add_dep(task_id="KB-XXX")\` — use when you discover mid-execution that another task must be completed first. This will return a warning first — you must call again with \`confirm=true\` to proceed. Adding a dependency stops execution, discards current work, and moves the task to triage for re-specification. ## Task Documents @@ -189,16 +184,16 @@ If you attempt to write to a path outside the worktree, the file tools will reje FNXC:WorkflowRouting 2026-06-22-17:26: Executors must not move the workflow of the task they are executing unless the user explicitly asked for that task's workflow. Agents remain free to set workflows on tasks they create because they are the creator for those new tasks. --> -- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. You may still set the workflow on tasks you create via \`fn_task_create\` or \`fn_delegate_task\`, because you are the creator of those new tasks. +- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. - **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. Do not run \`kill\`, \`pkill\`, \`killall\`, or \`lsof -ti:4040 | xargs kill\` against it. If you need to start a test server, use \`--port 0\` for a random free port. If port 4040 is occupied, pick a different port — do NOT kill the occupant. - Treat the File Scope in PROMPT.md as the expected starting scope, not a hard boundary when quality gates fail - Read "Context to Read First" files before starting - Follow the "Do NOT" section strictly - If tests, lint, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green -- Use \`task_create\` for genuinely separate follow-up work, not for mandatory fixes required to make this task land cleanly +- Implement mandatory and in-scope work directly in this task. Preserve optional out-of-scope follow-ups as completion recommendations. - Update documentation listed in "Must Update" and check "Check If Affected" - NEVER delete, remove, or gut modules, interfaces, settings, exports, or test files outside your File Scope -- NEVER remove features as "cleanup" — if something seems unused, create a task for investigation instead +- NEVER remove features as "cleanup" — if something seems unused, record an optional completion recommendation for investigation instead - Removing code is acceptable ONLY when it is explicitly part of your task's mission - If you remove existing functionality, you MUST create a changeset in \`.changeset/\` explaining the removal and rationale @@ -907,7 +902,7 @@ You have tools to report progress. The board updates in real-time. **Logging important actions:** \`task_log(message="what happened")\` -**Out-of-scope findings at completion:** When recommendation capture is enabled, retain optional, non-blocking discoveries as task-ready \`fn_task_done\` recommendations at accepted completion, or send \`recommendations: []\` when none qualify. Use \`task_create\` only for explicit immediate filing, dependency coordination, or operator direction; never recommend required current-task work, blockers, secrets, commands, reasoning, or filler. +**Out-of-scope findings at completion:** This task-execution session cannot create or delegate tasks. When recommendation capture is enabled, retain optional, non-blocking discoveries as task-ready \`fn_task_done\` recommendations at accepted completion, or send \`recommendations: []\` when none qualify. Implement required in-scope work directly here; use the honest blocked exit only for a real external blocker. Never recommend required current-task work, blockers, secrets, commands, reasoning, or filler. **Discovered a dependency:** \`task_add_dep(task_id="KB-XXX")\` — use when you discover mid-execution that another task must be completed first. This will return a warning first — you must call again with \`confirm=true\` to proceed. Adding a dependency stops execution, discards current work, and moves the task to triage for re-specification. @@ -977,15 +972,15 @@ If you attempt to write to a path outside the worktree, the file tools will reje FNXC:WorkflowRouting 2026-06-22-17:26: Executors must not move the workflow of the task they are executing unless the user explicitly asked for that task's workflow. Agents remain free to set workflows on tasks they create because they are the creator for those new tasks. --> -- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. You may still set the workflow on tasks you create via \`fn_task_create\` or \`fn_delegate_task\`, because you are the creator of those new tasks. +- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. - **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. Do not run \`kill\`, \`pkill\`, \`killall\`, or \`lsof -ti:4040 | xargs kill\` against it. If you need to start a test server, use \`--port 0\` for a random free port. If port 4040 is occupied, pick a different port — do NOT kill the occupant. - Treat the File Scope in PROMPT.md as the expected starting scope, not a hard boundary when quality gates fail - Read "Context to Read First" files before starting - Follow the "Do NOT" section strictly - If tests, lint, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green -- Use \`task_create\` for genuinely separate follow-up work, not for mandatory fixes required to make this task land cleanly +- Implement mandatory and in-scope work directly in this task. Preserve optional out-of-scope follow-ups as completion recommendations. - NEVER delete, remove, or gut modules, interfaces, settings, exports, or test files outside your File Scope -- NEVER remove features as "cleanup" — if something seems unused, create a task for investigation instead +- NEVER remove features as "cleanup" — if something seems unused, record an optional completion recommendation for investigation instead - If you remove existing functionality, you MUST create a changeset in \`.changeset/\` explaining the removal and rationale ## Spawning Child Agents diff --git a/packages/core/src/agents/task-execution-task-creation.ts b/packages/core/src/agents/task-execution-task-creation.ts new file mode 100644 index 0000000000..31cf844ba2 --- /dev/null +++ b/packages/core/src/agents/task-execution-task-creation.ts @@ -0,0 +1,19 @@ +import type { FusionSessionPrincipal } from "../session-identity-registry.js"; + +/* +FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: +FN-125 forbids sessions executing a board task from creating or delegating board +work. Ephemerality was insufficient because the durable Workflow Executor bypassed +that policy; this shared contract makes the restriction lane-based and fail-closed. +*/ +export const TASK_EXECUTION_WITHHELD_TASK_CREATION_TOOLS = ["fn_task_create", "fn_delegate_task"] as const; + +export function isTaskExecutionSessionPrincipal(principal: FusionSessionPrincipal): boolean { + if (principal.kind === "operator") return false; + if (principal.kind === "agent") return principal.identity.taskExecutionSession === true; + return principal.identities.some((identity) => identity.taskExecutionSession === true); +} + +export function taskExecutionTaskCreationRefusalText(toolName: string): string { + return `${toolName} is unavailable while executing a board task. Record out-of-scope findings as completion recommendations, and implement in-scope work directly in this task.`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22da9dee23..94006314ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -928,6 +928,8 @@ export { detectSelfDefeatingDependency, detectDependencyCycle, SelfDefeatingDependencyError, + SelfSpawnedDependencyError, + detectSelfSpawnedDependency, DependencyCycleError, TaskDeletedError, // FNXC:TaskLookup404 2026-07-26-11:20: typed task-miss signal + guard so API @@ -2895,6 +2897,11 @@ export { type FusionSessionIdentity, type FusionSessionPrincipal, } from "./session-identity-registry.js"; +export { + TASK_EXECUTION_WITHHELD_TASK_CREATION_TOOLS, + isTaskExecutionSessionPrincipal, + taskExecutionTaskCreationRefusalText, +} from "./agents/task-execution-task-creation.js"; export { pruneTaskLifecycleEvents } from "./task-store/task-lifecycle-event-retention.js"; diff --git a/packages/core/src/session-identity-registry.ts b/packages/core/src/session-identity-registry.ts index dd591dc867..e5bd1e9126 100644 --- a/packages/core/src/session-identity-registry.ts +++ b/packages/core/src/session-identity-registry.ts @@ -32,6 +32,13 @@ export interface FusionSessionIdentity { isEphemeral?: boolean; /** Session lane, e.g. "executor", "heartbeat", "chat". Diagnostic only. */ purpose?: string; + /* + FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: + FN-125 marks only engine sessions executing a board task so host extension tools + can withhold task creation. A durable Workflow Executor bypasses ephemerality, + so this must be an explicit session-lane signal rather than a policy inference. + */ + taskExecutionSession?: boolean; /** Epoch ms at registration; diagnostic only (no TTL semantics). */ registeredAt: number; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index cff8f74b5b..f9fbfdf73d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -267,7 +267,9 @@ export { InvalidFileScopeError, SELF_DEFEATING_OPERATION_VERBS, SelfDefeatingDependencyError, + SelfSpawnedDependencyError, detectSelfDefeatingDependency, + detectSelfSpawnedDependency, DependencyCycleError, detectDependencyCycle, MergeQueueTaskNotFoundError, diff --git a/packages/core/src/task-store/errors.ts b/packages/core/src/task-store/errors.ts index 4b56f81ee1..3c5a30f73f 100644 --- a/packages/core/src/task-store/errors.ts +++ b/packages/core/src/task-store/errors.ts @@ -160,6 +160,27 @@ export class SelfDefeatingDependencyError extends Error { } } +/* +FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: +FN-125 prevents a task from blocking on work it spawned, independent of which +API writes the dependency edge. This store invariant protects legacy and host paths. +*/ +export class SelfSpawnedDependencyError extends Error { + readonly code = "SELF_SPAWNED_DEPENDENCY" as const; + constructor(readonly taskId: string, readonly dependencyId: string) { + super(`Task ${taskId} cannot depend on self-spawned task ${dependencyId}`); + this.name = "SelfSpawnedDependencyError"; + } +} + +export function detectSelfSpawnedDependency( + taskId: string, + candidates: ReadonlyArray<{ id: string; sourceParentTaskId?: string }>, +): { dependencyId: string } | null { + const match = candidates.find((candidate) => candidate.sourceParentTaskId === taskId); + return match ? { dependencyId: match.id } : null; +} + export function detectSelfDefeatingDependency( title: string | undefined, dependencies: readonly string[], diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index afc6e5568a..0e7ea8f068 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -15,7 +15,7 @@ import { type TaskMoveLanes, } from "../workflows/workflow-lifecycle-traits.js"; import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js"; -import {InvalidFileScopeError} from "./errors.js"; +import {InvalidFileScopeError, SelfSpawnedDependencyError, detectSelfSpawnedDependency} from "./errors.js"; import {mkdir, readFile, stat, writeFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; @@ -117,6 +117,14 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat update clears checkedOutBy during reassignment later in this pass; reading the mutated task there would wrongly invalidate the in-flight route of a task that was checked out on read (issue #3365). */ + if (updates.dependencies !== undefined) { + const existing = new Set(task.dependencies ?? []); + const candidates = await Promise.all(updates.dependencies + .filter((dependencyId) => !existing.has(dependencyId)) + .map(async (dependencyId) => await store.readTaskJson(store.taskDir(dependencyId)))); + const selfSpawned = detectSelfSpawnedDependency(id, candidates); + if (selfSpawned) throw new SelfSpawnedDependencyError(id, selfSpawned.dependencyId); + } const wasCheckedOutOnRead = Boolean(task.checkedOutBy); const preUpdateNodeId = task.nodeId; const preUpdateEffectiveNodeId = task.effectiveNodeId; diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index 6629c05a29..015651a8f5 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -10,7 +10,7 @@ import {TaskStore, storeLog, type TaskDependencyMutation} from "../store.js"; import {buildRefinementSeedPrompt} from "../mesh/mesh-task-replication.js"; import {resolveDependencyReplanTarget, resolveLifecycleColumns, toTaskMoveLanes} from "../workflows/workflow-lifecycle-traits.js"; import {resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js"; -import {SelfDefeatingDependencyError, detectSelfDefeatingDependency} from "./errors.js"; +import {SelfDefeatingDependencyError, SelfSpawnedDependencyError, detectSelfDefeatingDependency, detectSelfSpawnedDependency} from "./errors.js"; import {resolveTaskLifecycleColumns} from "../workflows/workflow-lifecycle-traits.js"; import {resolveWorkflowIntakeFacts} from "./task-creation.js"; import type {WorkflowIr} from "../workflows/workflow-ir-types.js"; @@ -347,7 +347,15 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri ); const previousDependencySet = new Set(normalizedCurrent); - const hasNewDependencies = nextDependencies.some((dependencyId) => !previousDependencySet.has(dependencyId)); + const newlyAdded = nextDependencies.filter((dependencyId) => !previousDependencySet.has(dependencyId)); + const candidates = (await Promise.all(newlyAdded.map(async (dependencyId) => { + try { return await store.getTask(dependencyId); } catch { return null; } + }))).flatMap((candidate) => candidate + ? [{ id: candidate.id, ...(candidate.sourceParentTaskId ? { sourceParentTaskId: candidate.sourceParentTaskId } : {}) }] + : []); + const selfSpawned = detectSelfSpawnedDependency(id, candidates); + if (selfSpawned) throw new SelfSpawnedDependencyError(id, selfSpawned.dependencyId); + const hasNewDependencies = newlyAdded.length > 0; const dependenciesChanged = normalizedCurrent.length !== nextDependencies.length || normalizedCurrent.some((dependency, index) => dependency !== nextDependencies[index]); diff --git a/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts b/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts index 2e63a746e6..fb47797152 100644 --- a/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts +++ b/packages/engine/src/__tests__/ephemeral-task-create-gate.test.ts @@ -189,20 +189,22 @@ describe("executor session tool list (behavioral)", () => { expect(toolNames).toContain("fn_task_done"); }); - it("keeps fn_task_create under upon_validation but still withholds delegation", async () => { + it("withholds both tools under upon_validation", async () => { const { toolNames } = await captureExecutorSession("upon_validation"); - expect(toolNames).toContain("fn_task_create"); + expect(toolNames).not.toContain("fn_task_create"); expect(toolNames).not.toContain("fn_delegate_task"); }); - it("keeps both tools when the policy allows creation", async () => { + it("withholds both tools when policy allows creation", async () => { const { toolNames } = await captureExecutorSession("allow"); - expect(toolNames).toContain("fn_task_create"); + expect(toolNames).not.toContain("fn_task_create"); + expect(toolNames).not.toContain("fn_delegate_task"); }); - it("defaults to allow when no policy is persisted", async () => { + it("withholds both tools with no persisted policy", async () => { const { toolNames } = await captureExecutorSession(); - expect(toolNames).toContain("fn_task_create"); + expect(toolNames).not.toContain("fn_task_create"); + expect(toolNames).not.toContain("fn_delegate_task"); }); /* @@ -218,8 +220,8 @@ describe("executor session tool list (behavioral)", () => { expect(systemPrompt).toContain("recommendations: []"); }); - it("adds no withheld-tool guidance when creation is allowed", async () => { + it("adds withheld-tool guidance even when policy allows creation", async () => { const { systemPrompt } = await captureExecutorSession("allow"); - expect(systemPrompt).not.toContain("Follow-up task creation is disabled for this session"); + expect(systemPrompt).toContain("Follow-up task creation is disabled for this session"); }); }); diff --git a/packages/engine/src/__tests__/executor-review-verdicts.test.ts b/packages/engine/src/__tests__/executor-review-verdicts.test.ts index c7dcb51440..de39879b10 100644 --- a/packages/engine/src/__tests__/executor-review-verdicts.test.ts +++ b/packages/engine/src/__tests__/executor-review-verdicts.test.ts @@ -569,7 +569,8 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => { expect(capturedSystemPrompt).toContain("allowFullSuite: true"); expect(capturedSystemPrompt).toContain("Do not call `fn_workflow_select` to change the workflow of the task you are executing"); expect(capturedSystemPrompt).toContain("The only exception is when the user explicitly requested a specific workflow for this task"); - expect(capturedSystemPrompt).toContain("You may still set the workflow on tasks you create via `fn_task_create` or `fn_delegate_task`"); + expect(capturedSystemPrompt).toContain("Implement required in-scope work directly here"); + expect(capturedSystemPrompt).not.toContain("You may still set the workflow on tasks you create via `fn_task_create` or `fn_delegate_task`"); }); // Note: The EXECUTOR_SYSTEM_PROMPT constant is tested indirectly via the buildExecutionPrompt test. diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index caa3b26d91..8c8af1bf87 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1073,7 +1073,9 @@ async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryCon export function isAgentTaskCreateToolAvailable( settings: Pick | undefined | null, callerIsEphemeral: boolean | undefined, + lane: "task-execution" | "agent-session" = "agent-session", ): boolean { + if (lane === "task-execution") return false; if (!callerIsEphemeral) return true; return fusionCore.resolveEphemeralTaskCreationPolicy(settings ?? {}) !== "deny"; } @@ -1096,7 +1098,9 @@ export function isAgentTaskCreateToolAvailable( export function isAgentDelegateTaskToolAvailable( settings: Pick | undefined | null, callerIsEphemeral: boolean | undefined, + lane: "task-execution" | "agent-session" = "agent-session", ): boolean { + if (lane === "task-execution") return false; if (!callerIsEphemeral) return true; return fusionCore.resolveEphemeralTaskCreationPolicy(settings ?? {}) === "allow"; } diff --git a/packages/engine/src/agents/agent-runtime.ts b/packages/engine/src/agents/agent-runtime.ts index 9f31635c4d..593cbe565e 100644 --- a/packages/engine/src/agents/agent-runtime.ts +++ b/packages/engine/src/agents/agent-runtime.ts @@ -74,6 +74,8 @@ export interface AgentRuntimeOptions { */ /** Lane purpose (executor/merger/triage/…). Used for host-extension policy and diagnostics. */ sessionPurpose?: string; + /** True only when this session is executing a board task (FN-125). */ + taskExecutionSession?: boolean; /** * Optional structured prompt layers for cross-session caching. * When present, runtimes that support prompt caching use the `stable` diff --git a/packages/engine/src/execution/step-session-executor.ts b/packages/engine/src/execution/step-session-executor.ts index 199bc3231c..01b487fcdb 100644 --- a/packages/engine/src/execution/step-session-executor.ts +++ b/packages/engine/src/execution/step-session-executor.ts @@ -44,16 +44,12 @@ import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js"; import { isContextLimitError } from "../errors/context-limit-detector.js"; import { checkSessionError, isUsageLimitError } from "../errors/usage-limit-detector.js"; import { - createDelegateTaskTool, createTaskAssignTool, createListAgentsTool, createMemoryTools, createWebFetchTool, createReadMessagesTool, createSendMessageTool, - createTaskCreateTool, - isAgentTaskCreateToolAvailable, - isAgentDelegateTaskToolAvailable, createTaskDocumentReadTool, createTaskDocumentWriteTool, createTaskLogTool, @@ -1400,9 +1396,9 @@ export class StepSessionExecutor { execution session: an ephemeral step worker under `deny` is never handed fn_task_create, rather than being handed a tool that only refuses on call. */ - const taskCreateTool = this.options.store && isAgentTaskCreateToolAvailable(settings, this.options.callerIsEphemeral) - ? [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 })] - : []; + // FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: model-node + // task sessions are marked and structurally withheld from task creation. + const taskCreateTool: never[] = []; /* FNXC:EphemeralAgentTaskCreation 2026-07-26-07:40: @@ -1414,9 +1410,7 @@ export class StepSessionExecutor { const delegationTools = this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), - ...(isAgentDelegateTaskToolAvailable(settings, this.options.callerIsEphemeral) - ? [createDelegateTaskTool(this.options.agentStore, this.options.store!, { rootDir: this.options.rootDir, sourceTaskId: this.options.sourceTaskId ?? taskDetail.id, sourceAgentId: this.options.sourceAgentId ?? taskDetail.assignedAgentId, callerIsEphemeral: this.options.callerIsEphemeral })] - : []), + // FN-125: delegation creates board tasks and is unavailable in this lane. createTaskAssignTool(this.options.agentStore, this.options.store!), ] : []; @@ -1462,6 +1456,7 @@ export class StepSessionExecutor { } else { const createResult = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, runtimeHint: this.options.runtimeHint, pluginRunner: this.options.pluginRunner, cwd: worktreePath, diff --git a/packages/engine/src/executor/attempt-executor-verification-fix.ts b/packages/engine/src/executor/attempt-executor-verification-fix.ts index a7440e7449..f75d0120f2 100644 --- a/packages/engine/src/executor/attempt-executor-verification-fix.ts +++ b/packages/engine/src/executor/attempt-executor-verification-fix.ts @@ -120,6 +120,7 @@ export async function attemptExecutorVerificationFix( // Create the fix agent session const { session } = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, pluginRunner: deps.pluginRunner, cwd: worktreePath, // Run in the task's worktree systemPrompt: `You are a verification fix agent running during task execution in a worktree. diff --git a/packages/engine/src/executor/create-spawn-agent-tool.ts b/packages/engine/src/executor/create-spawn-agent-tool.ts index 3d2776206e..6f13ba930e 100644 --- a/packages/engine/src/executor/create-spawn-agent-tool.ts +++ b/packages/engine/src/executor/create-spawn-agent-tool.ts @@ -308,6 +308,7 @@ export function createSpawnAgentTool( // Create child agent session const { session: childSession } = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, runtimeHint: childRuntimeHint, pluginRunner: deps.pluginRunner, cwd: childWorktreePath, diff --git a/packages/engine/src/executor/create-task-done-tool.ts b/packages/engine/src/executor/create-task-done-tool.ts index ec6a971c92..e8ee08046b 100644 --- a/packages/engine/src/executor/create-task-done-tool.ts +++ b/packages/engine/src/executor/create-task-done-tool.ts @@ -187,7 +187,23 @@ export function createTaskDoneTool( throw error; } } - const blockedByIds = hasMissingTaskBlocker ? [] : requestedBlockedByIds; + /* + FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: + FN-125 treats a self-spawned blocker as a plan defect, not external work. + Drop it before classification so an all-self-spawned list follows the no-dependency replan path. + */ + const selfSpawnedBlockedByIds = hasMissingTaskBlocker + ? [] + : (await Promise.all(requestedBlockedByIds.map(async (blockerId) => { + const blocker = await store.getTask(blockerId); + return blocker.sourceParentTaskId === taskId ? blockerId : null; + }))).flatMap((blockerId) => blockerId ? [blockerId] : []); + const blockedByIds = hasMissingTaskBlocker + ? [] + : requestedBlockedByIds.filter((blockerId) => !selfSpawnedBlockedByIds.includes(blockerId)); + if (selfSpawnedBlockedByIds.length > 0) { + await store.logEntry(taskId, `Ignored self-spawned blockedBy task(s): ${selfSpawnedBlockedByIds.join(", ")}.`); + } const classification = classifyBlockedExit(reason, blockedByIds); const thrashCount = countBlockedThrashHits( blockedTask.log, diff --git a/packages/engine/src/executor/create-task-update-tool.ts b/packages/engine/src/executor/create-task-update-tool.ts index 4576272f78..9c2a8d30b6 100644 --- a/packages/engine/src/executor/create-task-update-tool.ts +++ b/packages/engine/src/executor/create-task-update-tool.ts @@ -156,8 +156,18 @@ export function createTaskUpdateTool( }; } const invalidIds: string[] = []; + const selfSpawnedIds: string[] = []; for (const depId of dependencies) { - try { await store.getTask(depId); } catch { invalidIds.push(depId); } + try { + const dependency = await store.getTask(depId); + if (dependency.sourceParentTaskId === taskId) selfSpawnedIds.push(depId); + } catch { invalidIds.push(depId); } + } + if (selfSpawnedIds.length > 0) { + return { + content: [{ type: "text" as const, text: `Cannot depend on self-spawned task(s): ${selfSpawnedIds.join(", ")}. Implement required work directly in ${taskId}.` }], + details: { code: "SELF_SPAWNED_DEPENDENCY", dependencyIds: selfSpawnedIds }, + }; } if (invalidIds.length > 0) { return { diff --git a/packages/engine/src/executor/execute-workflow-step.ts b/packages/engine/src/executor/execute-workflow-step.ts index 0795627f40..a5afdd1875 100644 --- a/packages/engine/src/executor/execute-workflow-step.ts +++ b/packages/engine/src/executor/execute-workflow-step.ts @@ -692,6 +692,7 @@ CRITICAL SCOPING RULES — read before doing anything else: : resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings); const { session } = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, runtimeHint: workflowRuntimeHint, pluginRunner: deps.options.pluginRunner, cwd: worktreePath, diff --git a/packages/engine/src/executor/run-implementation.ts b/packages/engine/src/executor/run-implementation.ts index d5ee3ce79e..a52de40805 100644 --- a/packages/engine/src/executor/run-implementation.ts +++ b/packages/engine/src/executor/run-implementation.ts @@ -66,7 +66,6 @@ import { createArtifactListTool, createArtifactRegisterTool, createArtifactViewTool, - createTaskCreateTool, createTaskDocumentReadTool, createTaskDocumentWriteTool, createTaskFileScopeAddTool, @@ -87,7 +86,6 @@ import { createAcquireRepoWorktreeTool, createAgentCreateTool, createAgentDeleteTool, - createDelegateTaskTool, createGetAgentConfigTool, createGoalRetrievalTools, createIdeationTools, @@ -1859,8 +1857,8 @@ export async function runImplementation( every other policy decision in this engine leaves that trail. */ const executionCallerIsEphemeral = !identityAgent || isEphemeralAgent(identityAgent); - const taskCreateWithheld = !isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral); - const delegateWithheld = !isAgentDelegateTaskToolAvailable(settings, executionCallerIsEphemeral); + const taskCreateWithheld = !isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral, "task-execution"); + const delegateWithheld = !isAgentDelegateTaskToolAvailable(settings, executionCallerIsEphemeral, "task-execution"); if (taskCreateWithheld || delegateWithheld) { await emitBoundedRunAudit(deps.store, { taskId: task.id, @@ -1875,6 +1873,8 @@ export async function runImplementation( withheldTaskCreate: taskCreateWithheld, withheldDelegateTask: delegateWithheld, lane: "execution-session", + reason: "task-execution-lane", + principalEphemeral: executionCallerIsEphemeral, }, }); } @@ -1896,9 +1896,7 @@ export async function runImplementation( deps.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stuckDetector), createTaskLogTool(tools, task.id), createTaskLogsReadTool(tools, task.id), - ...(taskCreateWithheld - ? [] - : [createTaskCreateTool(tools, executionCallerIsEphemeral, task.id, identityAgent?.id)]), + // FN-125: execution sessions never receive task creation tools. deps.createTaskAddDepTool(task.id), deps.createTaskDoneTool(task.id, worktreePath, detail.prompt ?? "", codeReviewVerdicts, () => { taskDone = true; }, audit), createRunVerificationTool({ @@ -1980,9 +1978,7 @@ export async function runImplementation( // Agent delegation tools — discover and delegate work to other agents. ...(deps.options.agentStore ? [ createListAgentsTool(deps.options.agentStore), - ...(delegateWithheld - ? [] - : [createDelegateTaskTool(deps.options.agentStore, deps.store, { rootDir: deps.rootDir, sourceTaskId: task.id, sourceAgentId: assignedAgentId, callerIsEphemeral: executionCallerIsEphemeral })]), + // FN-125: execution sessions never receive delegation tools. createTaskAssignTool(deps.options.agentStore, deps.store), ...(assignedAgentId ? [ createGetAgentConfigTool(deps.options.agentStore, assignedAgentId), @@ -2196,6 +2192,7 @@ export async function runImplementation( try { const createdSession = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, runtimeHint: executorRuntimeHint, pluginRunner: deps.options.pluginRunner, cwd: worktreePath, @@ -2669,6 +2666,7 @@ export async function runImplementation( try { const createdRetrySession = await createResolvedAgentSession({ sessionPurpose: "executor", + taskExecutionSession: true, runtimeHint: executorRuntimeHint, pluginRunner: deps.options.pluginRunner, cwd: worktreePath, diff --git a/packages/engine/src/executor/system-prompt.ts b/packages/engine/src/executor/system-prompt.ts index 8f0adc5a45..93120e6345 100644 --- a/packages/engine/src/executor/system-prompt.ts +++ b/packages/engine/src/executor/system-prompt.ts @@ -87,10 +87,7 @@ This path exists specifically to prevent the executor from looping when PROMPT.m **Logging important actions:** \`fn_task_log(message="what happened")\` -**Out-of-scope work found during execution:** \`fn_task_create(description="what needs doing")\` -When creating multiple related tasks, declare dependencies between them: -\`fn_task_create(description="load door sounds", dependencies=[])\` → returns KB-050 -\`fn_task_create(description="play sound on door open/close", dependencies=["KB-050"])\` +**Out-of-scope findings:** This session cannot create or delegate tasks. Preserve optional, non-blocking findings as \`fn_task_done(outcome="completed", recommendations=[…])\` completion recommendations, which appear in the task's Recommendations tab. Implement anything required and in scope directly in this task. For a genuine external blocker, use \`fn_task_done(outcome="blocked", reason="…", blockedBy=[…])\` with independently planned existing tasks. **Discovered a dependency:** \`fn_task_add_dep(task_id="KB-XXX")\` — use when you discover mid-execution that another task must be completed first. This will return a warning first — you must call again with \`confirm=true\` to proceed. Adding a dependency stops execution, discards current work, and moves the task to triage for re-planning. @@ -164,17 +161,17 @@ If you attempt to write to a path outside the worktree, the file tools will reje FNXC:WorkflowRouting 2026-06-22-17:26: Executors must not move the workflow of the task they are executing unless the user explicitly asked for that task's workflow. Agents remain free to set workflows on tasks they create because they are the creator for those new tasks. --> -- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. You may still set the workflow on tasks you create via \`fn_task_create\` or \`fn_delegate_task\`, because you are the creator of those new tasks. +- Do not call \`fn_workflow_select\` to change the workflow of the task you are executing; you did not create that task, the user or triage did. The only exception is when the user explicitly requested a specific workflow for this task in a steering comment, task instruction, or similar direct instruction. - **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. Do not run \`kill\`, \`pkill\`, \`killall\`, or \`lsof -ti:4040 | xargs kill\` against it. If you need to start a test server, use \`--port 0\` for a random free port. If port 4040 is occupied, pick a different port — do NOT kill the occupant. - Treat the File Scope in PROMPT.md as the expected starting scope, not a hard boundary when quality gates fail - Read "Context to Read First" files before starting - Follow the "Do NOT" section strictly — these are hard constraints, not suggestions - If tests, lint, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green - When you must edit files beyond the declared File Scope to complete this task, call \`fn_task_file_scope_add\` to add them to the File Scope as you go — keep the declared scope in sync with what you actually change so your edits are not stranded by the scope-aware squash merge -- Use \`fn_task_create\` for genuinely separate follow-up work, not for mandatory fixes required to make this task land cleanly +- Implement mandatory and in-scope fixes directly in this task. Preserve optional out-of-scope follow-ups as completion recommendations; this session cannot create or delegate tasks. - Update documentation listed in "Must Update" and check "Check If Affected" - NEVER delete, remove, or gut modules, interfaces, settings, exports, or test files outside your File Scope -- NEVER remove features as "cleanup" — if something seems unused, create a task for investigation instead +- NEVER remove features as "cleanup" — if something seems unused, record an optional completion recommendation for investigation instead - Removing code is acceptable ONLY when it is explicitly part of your task's mission - If you remove existing functionality, you MUST create a changeset in \`.changeset/\` explaining the removal and rationale @@ -280,11 +277,11 @@ Recommendation capture is disabled for this project (maxRecommendationsPerTask i if (required) { return `## Completion recommendations -At the final accepted \`fn_task_done(outcome="completed")\` checkpoint, you MUST explicitly evaluate optional, non-blocking work discovered outside this task by sending a \`recommendations\` array. Aim toward ${maximum} distinct, grounded, task-ready recommendations from concrete source-task or worktree findings, but stop before relevance degrades: a shorter list or \`recommendations: []\` is correct when fewer candidates qualify. Reject scope drift, restatements of this task, duplicates, speculation, filler, required current-task work, blockers, secrets, executable commands, and reasoning. Each item needs a stable unique \`id\`, \`title\`, \`description\`, and \`category\`. Recommendations are only for completed outcomes; never send them with \`outcome="blocked"\`. Use immediate task creation/delegation only for an explicit task requirement, necessary dependency coordination, or operator direction.`; +At the final accepted \`fn_task_done(outcome="completed")\` checkpoint, you MUST explicitly evaluate optional, non-blocking work discovered outside this task by sending a \`recommendations\` array. Aim toward ${maximum} distinct, grounded, task-ready recommendations from concrete source-task or worktree findings, but stop before relevance degrades: a shorter list or \`recommendations: []\` is correct when fewer candidates qualify. Reject scope drift, restatements of this task, duplicates, speculation, filler, required current-task work, blockers, secrets, executable commands, and reasoning. Each item needs a stable unique \`id\`, \`title\`, \`description\`, and \`category\`. Recommendations are only for completed outcomes; never send them with \`outcome="blocked"\`. Implement required in-scope work directly in this task; use the honest blocked exit only for a real external blocker.`; } return `## Completion recommendations -At the final accepted \`fn_task_done(outcome="completed")\` checkpoint, optionally evaluate grounded, non-blocking work discovered outside this task. Send at most ${maximum} task-ready recommendations, each with a stable unique \`id\`, \`title\`, \`description\`, and \`category\`, or explicitly send \`recommendations: []\` when none genuinely qualify. Example populated payload: \`recommendations: [{ id: "follow-up-export", title: "Add task export", description: "Provide CSV export outside the completed task's scope.", category: "feature" }]\`. Stop before relevance degrades: do not fabricate filler or include scope drift, restatements, duplicates, speculation, required current-task work, blockers, secrets, executable commands, or reasoning. Recommendations are only for completed outcomes; never send them with \`outcome="blocked"\`. Use immediate task creation/delegation only for an explicit task requirement, necessary dependency coordination, or operator direction.`; +At the final accepted \`fn_task_done(outcome="completed")\` checkpoint, optionally evaluate grounded, non-blocking work discovered outside this task. Send at most ${maximum} task-ready recommendations, each with a stable unique \`id\`, \`title\`, \`description\`, and \`category\`, or explicitly send \`recommendations: []\` when none genuinely qualify. Example populated payload: \`recommendations: [{ id: "follow-up-export", title: "Add task export", description: "Provide CSV export outside the completed task's scope.", category: "feature" }]\`. Stop before relevance degrades: do not fabricate filler or include scope drift, restatements, duplicates, speculation, required current-task work, blockers, secrets, executable commands, or reasoning. Recommendations are only for completed outcomes; never send them with \`outcome="blocked"\`. Implement required in-scope work directly in this task; use the honest blocked exit only for a real external blocker.`; } /* @@ -307,13 +304,13 @@ function getWithheldTaskCreationGuidance(taskCreateWithheld: boolean, delegateWi : "Recommendation capture is disabled, so retain non-blocking context in an honest task log or completion summary without inventing a follow-up."; return `## Follow-up task creation is disabled for this session -This project's "Ephemeral agent follow-up tasks" policy withholds ${withheld}. ${ +Task-execution sessions structurally withhold ${withheld}, regardless of the ephemeral-agent policy or whether the Workflow Executor principal is durable. ${ taskCreateWithheld && delegateWithheld ? "Those tools are" : "That tool is" - } deliberately absent from your tool list — this is an operator setting, not a malfunction or a transient error. Do not attempt to call ${ + } deliberately absent from your tool list; do not attempt to call ${ taskCreateWithheld && delegateWithheld ? "them" : "it" }, and do not retry. -Ignore any instruction above that tells you to file follow-up work with ${withheld}. ${recommendationRoute} If the work genuinely blocks this task, use \`fn_task_done(outcome="blocked", reason="...")\` rather than trying to create a task for it.`; +Ignore any instruction above that tells you to file follow-up work with ${withheld}. ${recommendationRoute} Implement required in-scope work directly here. If an external dependency genuinely blocks this task, use \`fn_task_done(outcome="blocked", reason="...")\` rather than trying to create a task for it.`; } /** Resolve the executor system prompt from settings, falling back to the hardcoded constant. */ diff --git a/packages/engine/src/executor/task-add-dep-tool.ts b/packages/engine/src/executor/task-add-dep-tool.ts index 1db547280d..791fa5e0ad 100644 --- a/packages/engine/src/executor/task-add-dep-tool.ts +++ b/packages/engine/src/executor/task-add-dep-tool.ts @@ -4,7 +4,7 @@ * declaration tool; confirm=true aborts the active session for re-planning. */ import { Type, type Static } from "@earendil-works/pi-ai"; -import type { TaskStore } from "@fusion/core"; +import type { Task, TaskStore } from "@fusion/core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { executorLog } from "../logger.js"; @@ -47,8 +47,9 @@ export function createTaskAddDepTool(deps: TaskAddDepToolDeps, taskId: string): } // Validate target task exists + let targetTask: Task; try { - await store.getTask(targetId); + targetTask = await store.getTask(targetId); } catch { return { content: [{ @@ -62,6 +63,15 @@ export function createTaskAddDepTool(deps: TaskAddDepToolDeps, taskId: string): // Read current task to get existing dependencies const currentTask = await store.getTask(taskId); const existing = currentTask.dependencies; + if (targetTask.sourceParentTaskId === taskId) { + return { + content: [{ + type: "text" as const, + text: `Cannot depend on self-spawned task ${targetId}; implement required work directly in ${taskId}.`, + }], + details: { code: "SELF_SPAWNED_DEPENDENCY", dependencyId: targetId }, + }; + } // Dedup check if (existing.includes(targetId)) { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 087ee9bd6e..9225526a3b 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1130,6 +1130,8 @@ export interface AgentOptions { onFallbackModelUsed?: (payload: FallbackModelUsedPayload) => Promise | void; /** Optional task context for fallback notifications. */ taskId?: string; + /** True only for sessions actively executing a board task (FN-125). */ + taskExecutionSession?: boolean; taskTitle?: string; actionGateContext?: AgentActionGateContext; /** Permanent-agent action gating context forwarded by runtime/session helpers. */ @@ -3092,11 +3094,22 @@ export async function createPiAgentSessionRaw(options: AgentOptions): Promise Boolean(key)))]; const attachSessionIdentity = (session: PromptableSession & { dispose?: () => void | Promise }): void => { - const identityDisposers = sessionIdentityKeys.map((key) => registerFusionSessionIdentity(key, sessionIdentity)); + /* + FNXC:TaskExecutionTaskCreation 2026-08-21-23:16: + Task-execution markers must not occupy the shared project-root registry key: + concurrent heartbeat or triage lookup would become ambiguous and fail closed. + ALS retains the marker during this session's own invocation. + */ + const identityDisposers = sessionIdentityKeys.map((key) => { + if (key === options.cwd || !options.taskExecutionSession) return registerFusionSessionIdentity(key, sessionIdentity); + const { taskExecutionSession: _marker, ...projectRootIdentity } = sessionIdentity; + return registerFusionSessionIdentity(key, projectRootIdentity); + }); const sessionInvocations = session as unknown as Partial unknown>>; const wrapInvocation = (methodName: "prompt" | "promptWithFallback"): void => { const original = sessionInvocations[methodName];