Files
fusion/packages/engine/src/executor/create-task-update-tool.ts
Fusion Agent 0b4dbd219b FN-125: prevent workflow agents from creating tasks
Stop workflow executor agents from creating ordinary tasks while preserving explicit self-spawned dependency handling.

- Gate task creation by session identity and execution context.
- Add self-spawned dependency support, validation, and persistence coverage.
- Update agent prompts, tools, documentation, and CLI integration.
- Add a published-package changeset.

Files changed:
 .changeset/fn-125-removal.md                       |  7 +++
 docs/agents.md                                     |  2 +-
 docs/dashboard-guide.md                            |  2 +-
 docs/settings-reference.md                         |  6 +-
 .../cli/skill/fusion/references/engine-tools.md    | 10 ++--
 .../extension-task-execution-task-creation.test.ts | 70 ++++++++++++++++++++++
 packages/cli/src/extension.ts                      | 49 +++++++++++++--
 .../postgres/store-self-spawned-dep.pg.test.ts     | 56 +++++++++++++++++
 .../src/__tests__/self-spawned-dependency.test.ts  | 18 ++++++
 .../__tests__/session-identity-registry.test.ts    | 14 ++++-
 packages/core/src/agents/agent-prompts.ts          | 27 ++++-----
 .../src/agents/task-execution-task-creation.ts     | 19 ++++++
 packages/core/src/index.ts                         |  7 +++
 packages/core/src/session-identity-registry.ts     |  7 +++
 packages/core/src/store.ts                         |  2 +
 packages/core/src/task-store/errors.ts             | 21 +++++++
 packages/core/src/task-store/task-update.ts        | 10 +++-
 packages/core/src/task-store/update-task-deps.ts   | 12 +++-
 .../__tests__/ephemeral-task-create-gate.test.ts   | 18 +++---
 .../src/__tests__/executor-review-verdicts.test.ts |  3 +-
 packages/engine/src/agent-tools.ts                 |  4 ++
 packages/engine/src/agents/agent-runtime.ts        |  2 +
 .../engine/src/execution/step-session-executor.ts  | 15 ++---
 .../executor/attempt-executor-verification-fix.ts  |  1 +
 .../engine/src/executor/create-spawn-agent-tool.ts |  1 +
 .../engine/src/executor/create-task-done-tool.ts   | 18 +++++-
 .../engine/src/executor/create-task-update-tool.ts | 12 +++-
 .../engine/src/executor/execute-workflow-step.ts   |  1 +
 packages/engine/src/executor/run-implementation.ts | 18 +++---
 packages/engine/src/executor/system-prompt.ts      | 21 +++----
 packages/engine/src/executor/task-add-dep-tool.ts  | 14 ++++-
 packages/engine/src/pi.ts                          | 15 ++++-
 32 files changed, 401 insertions(+), 81 deletions(-)

Fusion-Task-Id: FN-125

Fusion-Task-Lineage: da3d69f6-c9cb-45fd-8691-3dfaf7007514
Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-23 01:36:12 +00:00

331 lines
15 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-12:50:
* createTaskUpdateTool peeled from TaskExecutor (U4).
*
* FNXC:StepNumbering 2026-06-17-00:00:
* FN-6607: step is 0-based matching PROMPT.md Step N numbers.
*
* FNXC:WorkflowReviewGates 2026-07-19-02:30:
* U10: in-session code-review REVISE gate on fn_task_update(done) deleted with fn_review_step.
*
* FNXC:StepLifecycle 2026-07-22-09:50:
* Persisted-status mismatch is a deterministic churn signal after loop recovery.
*/
import { Type, type Static } from "@earendil-works/pi-ai";
import type { StepStatus, TaskStore, WorkflowFieldDefinition } from "@fusion/core";
import type { ToolDefinition, AgentSession } from "@earendil-works/pi-coding-agent";
import type { ReviewVerdict } from "../execution/reviewer.js";
import type { StuckTaskDetector } from "../healing/stuck-task-detector.js";
import { executorLog } from "../logger.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
const taskUpdateParams = Type.Object({
step: Type.Optional(Type.Number({ description: "Step number (0-indexed; matches the `### Step N:` numbers in PROMPT.md — Step 0 is Preflight). Omit when updating only custom_fields/dependencies." })),
status: Type.Optional(Type.Union(
STEP_STATUSES.map((s) => Type.Literal(s)),
{ description: "New status: pending, in-progress, done, or skipped. Required when step is set." },
)),
dependencies: Type.Optional(Type.Array(Type.String(), {
description: "Optional task dependency array. Replaces existing dependencies. Pass ['FN-001', 'FN-002'] to set dependencies. Pass [] to clear all dependencies. Omit parameter to preserve existing dependencies.",
})),
custom_fields: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
description:
"Optional patch of workflow-defined custom field values, keyed by field id. " +
"Values are validated against the task's workflow field schema (type/enum membership); " +
"pass null for a field to clear it. Rejected writes return the offending field id and reason. " +
"Only fields declared by the task's workflow may be written.",
})),
});
export type CreateTaskUpdateToolDeps = {
store: TaskStore;
resolveTaskCustomFieldDefs: (taskId: string) => Promise<WorkflowFieldDefinition[] | undefined>;
loopRecoveryState: Map<string, { attempts: number; pending: boolean }>;
};
/**
* Create fn_task_update for the executor coding session.
* `codeReviewVerdicts` / `sessionRef` remain in the signature for call-site compatibility
* (legacy review-gate args; U10 no longer consults them).
*/
export function createTaskUpdateTool(
deps: CreateTaskUpdateToolDeps,
taskId: string,
_codeReviewVerdicts: Map<number, ReviewVerdict>,
_sessionRef: { current: AgentSession | null },
stuckDetector?: StuckTaskDetector,
): ToolDefinition {
const store = deps.store;
return {
name: "fn_task_update",
label: "Update Step",
description:
"Update a step's status. Call before starting a step (in-progress), " +
"after completing it (done), or to skip it (skipped). " +
"Optionally update task dependencies by passing a dependencies array. " +
"Optionally set workflow-defined custom field values by passing a custom_fields patch " +
"(keyed by field id; validated against the workflow's field schema; pass null to clear a field). " +
"step/status may be omitted to update only custom_fields or dependencies. " +
"The board updates in real-time.",
parameters: taskUpdateParams,
execute: async (_id: string, params: Static<typeof taskUpdateParams>) => {
const { step, status, dependencies, custom_fields } = params;
// Bare-call guard (P1 api-contract): a call with none of
// step/status/dependencies/custom_fields silently no-op'd, which the
// agent cannot observe. Reject it up front so the failure is visible and
// self-describing. The legacy no-op text is preserved as the detail.
if (step === undefined && status === undefined && dependencies === undefined && custom_fields === undefined) {
return {
content: [{
type: "text" as const,
text: "ERROR: fn_task_update requires at least one of: step+status (report step progress), " +
"dependencies (array of task ids), or custom_fields (workflow-defined field patch). " +
"No-op: provide a step+status, dependencies, or custom_fields to update.",
}],
details: {},
isError: true,
};
}
// Custom-field patch (KTD-13): routed through the store's single write
// authority, which validates each value against the task's workflow field
// schema. A typed rejection surfaces the offending field id + reason as a
// tool error so the agent can correct it. Applied first so a field-only
// call (step omitted) returns here.
if (custom_fields !== undefined) {
const res = await store.updateTaskCustomFields(taskId, custom_fields);
if (!res.ok) {
const r = res.rejection;
// Self-correcting rejection text: append the valid field ids (and,
// for an enum violation, the valid values for the offending field)
// resolved from the task's workflow field schema so a failed write
// carries everything the agent needs to retry. Best-effort: a
// resolution failure just omits the hint (the base reason still ships).
let hint = "";
try {
const defs = await deps.resolveTaskCustomFieldDefs(taskId);
if (defs && defs.length > 0) {
if (r.code === "unknown-field" || r.code === "no-fields-defined") {
hint = ` Valid field ids: ${defs.map((f) => f.id).join(", ")}.`;
} else if (r.code === "enum-violation") {
const field = defs.find((f) => f.id === r.fieldId);
const opts = field?.options?.map((o) => o.value) ?? [];
if (opts.length > 0) hint = ` Valid values for '${r.fieldId}': ${opts.join(", ")}.`;
}
}
} catch { /* hint is best-effort */ }
return {
content: [{
type: "text" as const,
text: `ERROR: custom field '${r.fieldId}' rejected (${r.code}): ${r.detail}${hint}`,
}],
details: { fieldId: r.fieldId, code: r.code, detail: r.detail },
isError: true,
};
}
// A custom-fields-only update (no step) succeeds here.
if (step === undefined && status === undefined && dependencies === undefined) {
const updatedKeys = Object.keys(custom_fields);
return {
content: [{
type: "text" as const,
text: `Updated custom field(s): ${updatedKeys.join(", ")}.`,
}],
details: { updatedFields: updatedKeys },
};
}
}
// Record step progress for stuck task detection.
// Step transitions (in-progress, done, skipped) indicate real progress
// and reset the loop detection counter. Generic activity (text deltas,
// tool calls) is tracked separately via recordActivity in AgentLogger.
if (status === "in-progress" || status === "done" || status === "skipped") {
stuckDetector?.recordProgress(taskId);
}
// Dependencies-only update (no step) is permitted; handle deps then return.
if (step === undefined) {
if (dependencies !== undefined) {
if (dependencies.includes(taskId)) {
return {
content: [{ type: "text" as const, text: `Cannot add self-dependency: ${taskId} cannot depend on itself.` }],
details: {},
};
}
const invalidIds: string[] = [];
const selfSpawnedIds: string[] = [];
for (const depId of dependencies) {
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 {
content: [{ type: "text" as const, text: `Cannot set dependencies — the following task(s) do not exist: ${invalidIds.join(", ")}` }],
details: {},
};
}
await store.updateTask(taskId, { dependencies });
return {
content: [{ type: "text" as const, text: `Dependencies updated.` }],
details: {},
};
}
return {
content: [{ type: "text" as const, text: `No-op: provide a step+status, dependencies, or custom_fields to update.` }],
details: {},
};
}
if (status === undefined) {
return {
content: [{ type: "text" as const, text: `Step ${step} provided without a status. Pass status (pending/in-progress/done/skipped).` }],
details: {},
};
}
if (!Number.isInteger(step) || step < 0) {
return {
content: [{
type: "text" as const,
text: `Invalid step number: ${step}. Steps are 0-indexed; Step 0 is Preflight.`,
}],
details: {},
};
}
/*
* FNXC:StepNumbering 2026-06-17-00:00:
* FN-6607 makes fn_task_update.step the same 0-based number agents see in PROMPT.md (`### Step N:`) and TaskStore.updateStep uses internally. The prior `step - 1` conversion made Step 0 impossible to mark done and shifted every review/progress update one array slot early.
*/
const stepIndex = step;
if (status === "in-progress") {
try {
const latestTask = await store.getTask(taskId);
const otherInProgressStepIndex = latestTask.steps.findIndex(
(taskStep, index) => index !== stepIndex && taskStep.status === "in-progress",
);
if (otherInProgressStepIndex !== -1) {
executorLog.warn(
`${taskId}: fn_task_update marking step ${step} in-progress while step ${otherInProgressStepIndex} is already in-progress`,
);
}
} catch (err) {
executorLog.warn(`${taskId}: failed to inspect step lease state before fn_task_update: ${err}`);
}
}
/*
FNXC:WorkflowReviewGates 2026-07-19-02:30:
U10 (R9): the in-session code-review REVISE gate on `fn_task_update(status="done")` is
deleted. Its verdict source was the legacy `fn_review_step` tool, which no longer exists,
so the map it read is permanently empty. A REVISE from a graph-owned Code Review node routes back to
the implementation node as a graph edge instead of blocking a step-status tool call.
*/
// Handle dependencies parameter if provided
if (dependencies !== undefined) {
// Validate: prevent self-dependency
if (dependencies.includes(taskId)) {
return {
content: [{
type: "text" as const,
text: `Cannot add self-dependency: ${taskId} cannot depend on itself.`,
}],
details: {},
};
}
// Validate: all dependency task IDs must exist
const invalidIds: string[] = [];
for (const depId of dependencies) {
try {
await store.getTask(depId);
} catch {
invalidIds.push(depId);
}
}
if (invalidIds.length > 0) {
return {
content: [{
type: "text" as const,
text: `Cannot set dependencies — the following task(s) do not exist: ${invalidIds.join(", ")}`,
}],
details: {},
};
}
// Update dependencies
await store.updateTask(taskId, { dependencies });
}
const task = await store.updateStep(taskId, stepIndex, status as StepStatus);
const stepInfo = task.steps[stepIndex];
if (!stepInfo) {
return {
content: [{
type: "text" as const,
text: `Invalid step number: ${step}. This task has ${task.steps.length} step(s) (0-indexed; valid range 0-${Math.max(0, task.steps.length - 1)}).`,
}],
details: {},
};
}
const persistedStatus = stepInfo.status;
const progress = task.steps.filter((s) => s.status === "done").length;
/*
FNXC:WorkflowReviewGates 2026-07-19-02:30:
U10 (R9): the pre-step conversation-checkpoint capture is deleted with `fn_review_step`.
Its only consumer was that tool's RETHINK rewind (`session.navigateTree`); a graph-owned
RETHINK re-enters the implementation node instead of rewinding the live conversation.
*/
// FNXC:StepLifecycle 2026-07-22-09:50: A persisted-status mismatch means
// the store rejected the transition (for example, a completed-step
// regression or an out-of-order start/completion). FN-5168 treats
// repeated rebuffs after loop recovery as a deterministic churn signal.
if (persistedStatus !== status) {
stuckDetector?.recordIgnoredStepUpdate(taskId);
const ignoredStepUpdates = stuckDetector?.getIgnoredStepUpdateCount(taskId) ?? 0;
const loopAttempts = deps.loopRecoveryState.get(taskId)?.attempts ?? 0;
if (loopAttempts >= 1 && ignoredStepUpdates === 25) {
executorLog.warn(
`${taskId}: no-progress churn detected ` +
`(ignoredStepUpdates=${ignoredStepUpdates}, stuckKillStreak=${task.stuckKillCount ?? 0}) — ` +
`escalating to STUCK_NO_PROGRESS_CHURN`,
);
}
return {
content: [{
type: "text" as const,
text: `Step ${step} (${stepInfo.name}) remains ${persistedStatus} — ${status} request ignored to preserve step lifecycle invariants. Progress: ${progress}/${task.steps.length} done.`,
}],
details: {},
};
}
return {
content: [{
type: "text" as const,
text: `Step ${step} (${stepInfo.name}) → ${persistedStatus}. Progress: ${progress}/${task.steps.length} done.`,
}],
details: {},
};
},
};
}