fix(FN-7226): make plan review the single pre-execution gate
Fusion-Task-Id: FN-7226
This commit is contained in:
7
.changeset/fn-plan-review-single-gate.md
Normal file
7
.changeset/fn-plan-review-single-gate.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Use workflow Plan Review as the single pre-execution plan gate.
|
||||
category: fix
|
||||
dev: Triage no longer injects fn_review_spec or requires a separate spec-review approval before workflow execution.
|
||||
7
.changeset/fn-quick-add-optional-steps-empty.md
Normal file
7
.changeset/fn-quick-add-optional-steps-empty.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Preserve Quick Add tasks with all workflow optional steps unchecked.
|
||||
category: fix
|
||||
dev: Sends empty enabledWorkflowSteps from Quick Add and honors explicit empty workflow selections in task details.
|
||||
@@ -691,7 +691,7 @@ Runtime action-gate flow (v1):
|
||||
#### Stuck-loop exhaustion terminal contract
|
||||
When stuck-kill retries are exhausted, `checkStuckBudget()` marks executor-phase tasks `status: "failed"`, moves them to `in-review`, and writes an error that starts with `STUCK_LOOP_EXHAUSTED:`. The error and final task-log line both include the kill count/max and last stuck reason (`loop` or `inactivity`). `StuckTaskDetector` also untracks the task and refuses to re-track it while that failed terminal error remains, preventing further automatic kill/requeue churn. The final log line explicitly states that no further automatic retries will run and directs operators to manually retry, pause, or move the task back to triage to resume work.
|
||||
|
||||
Planning-phase stuck kills use the same `stuckKillCount` / `settings.maxStuckKills` budget before execution starts. While under budget, a stuck triage requeue resumes from a non-empty on-disk `PROMPT.md` draft in revision mode and logs resume feedback; if `PROMPT.md` is absent, it falls back to a non-empty `plan` task document. Absent or whitespace-only drafts preserve cold-start behavior, and already-approved drafts continue through approved-spec recovery. At budget exhaustion, triage parks the task as `status: "failed"`, `paused: true` with a `STUCK_LOOP_EXHAUSTED:` error so a reasoning-looping planner cannot restart indefinitely.
|
||||
Planning-phase stuck kills use the same `stuckKillCount` / `settings.maxStuckKills` budget before execution starts. While under budget, a stuck triage requeue resumes from a non-empty on-disk `PROMPT.md` draft in revision mode and logs resume feedback; if `PROMPT.md` is absent, it falls back to a non-empty `plan` task document. Absent or whitespace-only drafts preserve cold-start behavior, and recoverable written drafts continue through prompt-based planning recovery. At budget exhaustion, triage parks the task as `status: "failed"`, `paused: true` with a `STUCK_LOOP_EXHAUSTED:` error so a reasoning-looping planner cannot restart indefinitely.
|
||||
|
||||
Active `fn_run_verification` subprocesses are a bounded progress signal (FN-6598). `createRunVerificationTool()` brackets each command with `StuckTaskDetector.beginVerification()` / `endVerification()`; while the command is active and still inside its own timeout plus cleanup grace, the detector suppresses `loop` and `no-progress-churn` classification so healthy marathon verification output cannot consume stuck-kill budget. `inactivity` is not suppressed: the verification runner must continue emitting line output or synthetic heartbeats, and if the process overruns its recorded deadline or never sends an end signal, normal detection resumes.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Stages that cannot satisfy all three must either (a) tighten predicate to requir
|
||||
| recoverNoProgressNoTaskDoneFailures | 6451 | in-progress failed no-task-done no progress | implicit (no explicit grace) | no-step-progress + no git work + not executing | clear metadata + move to todo | BACKWARD | tighten | triple proof + no-progress checks + recent liveness-audit absence | gate move; emit `task:no-progress-no-task-done-no-action` |
|
||||
| recoverMissingWorktreeReviewFailures | 6516 | in-review failed session-start missing/unusable worktree | classifier-based | error classifier proof only | autoRecover requeue to todo | BACKWARD | tighten | triple proof + classifier proof | gate requeue; emit `task:missing-worktree-review-no-action` |
|
||||
| recoverPartialProgressNoTaskDoneFailures | 6586 | in-review failed no-task-done with partial progress | bounded by `MAX_TASK_DONE_RETRIES` | no-task-done + partial progress + retry budget | clear error + move to todo preserveProgress | BACKWARD | tighten | triple proof + retry-budget predicates | gate move; emit `task:partial-progress-no-task-done-no-action` |
|
||||
| recoverApprovedTriageTasks | 6706 | triage planning approved stale | `APPROVED_TRIAGE_RECOVERY_GRACE_MS` | planning idle + approved spec | recoverApprovedTriageTask callback | FORWARD | keep | n/a | n/a |
|
||||
| recoverApprovedTriageTasks | 6706 | triage planning specified stale | `APPROVED_TRIAGE_RECOVERY_GRACE_MS` | planning idle + valid PROMPT.md | recoverApprovedTriageTask callback | FORWARD | keep | n/a | n/a |
|
||||
| recoverStarvedRefinementTriageTasks | 6827 | refinement planning stale | `STARVED_REFINEMENT_RECOVERY_GRACE_MS` | no progress idle | requeue/annotation in triage | RECONCILE-ONLY | keep | n/a | n/a |
|
||||
| recoverOrphanedPlanningTasks | 6940 | planning-status tasks drifted | `APPROVED_TRIAGE_RECOVERY_GRACE_MS` | planning drift + inactive | clear planning status | RECONCILE-ONLY | keep | n/a | n/a |
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ The built-in workflows also declare triage/spec policy settings that were **not*
|
||||
| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred workflow for decision-only/no-commit tasks when the user explicitly requests that routing or the agent is creating the task. |
|
||||
| `triageDefaultWorkflowId` | `builtin:coding` | Default workflow for standard coding tasks and for existing tasks without an explicit user-requested or creator-owned workflow selection. |
|
||||
| `leanPlanning` | `false` | Workflow-native fast-mode policy: select the lean `planning-fast` prompt variant instead of the full triage spec prompt. |
|
||||
| `autoApproveSpec` | `false` | Workflow-native fast-mode policy: auto-approve generated specs and skip the independent spec reviewer. |
|
||||
| `autoApproveSpec` | `false` | Legacy compatibility setting. Workflow Plan Review now owns optional pre-execution AI plan approval. |
|
||||
|
||||
In the dashboard Settings modal, Project Models exposes Plan/Triage, Executor,
|
||||
Reviewer, and declared fallback dropdown controls for the default workflow. The
|
||||
@@ -382,7 +382,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. |
|
||||
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
|
||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
|
||||
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every approved spec to todo without manual approval; `"require-all"` parks every approved spec at `status: "awaiting-approval"` regardless of workflow settings. |
|
||||
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every specified task to todo without manual approval; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. |
|
||||
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
|
||||
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
|
||||
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
||||
@@ -1528,7 +1528,7 @@ All three lanes (planning / executor / reviewer) follow the same 5-tier preceden
|
||||
## Mock provider (test mode)
|
||||
|
||||
Set `defaultProvider: "mock"` at any tier in that hierarchy (or the per-task lane override) to force planning, executor, reviewer/validator, mission validation, merger, and heartbeat sessions onto the deterministic zero-network mock runtime.
|
||||
Default scripts are scripted by session purpose: executor marks unfinished steps done, triage writes a minimal PROMPT.md and calls `fn_review_spec` when available, reviewer/validation emit `Verdict: APPROVE`, and merger/heartbeat no-op safely.
|
||||
Default scripts are scripted by session purpose: executor marks unfinished steps done, triage writes a minimal PROMPT.md and leaves optional plan approval to workflow Plan Review, reviewer/validation emit `Verdict: APPROVE`, and merger/heartbeat no-op safely.
|
||||
Per-task and global script overrides live in `mockScriptRegistry` (`setMockScript`, `clearMockScript`, `resetMockScripts`) exported from `@fusion/engine`.
|
||||
The mock runtime never registers with pi's `ModelRegistry` and is guarded by tests that fail on any `fetch`, `http.request`, or `https.request` usage.
|
||||
Activation UX/settings affordances are handled separately in FN-5204.
|
||||
|
||||
@@ -131,7 +131,7 @@ This guard adds three fail-open layers on top of the existing duplicate stack, i
|
||||
Layer behavior:
|
||||
|
||||
- **Dashboard intake (`POST /api/tasks`)** — after deterministic/similarity/near-duplicate checks and before `createTask`, intake returns `409 duplicate_candidates` with `reason: "explicit-marker"` when the combined title/description is exactly a canonical redirect and the canonical target exists. `acknowledgedDuplicates` and `bypassDuplicateCheck: true` both suppress the conflict. Because this guard runs before task creation, the activity breadcrumb is attached to the canonical target.
|
||||
- **Triage planning loop** — after triage reads the generated `PROMPT.md` but before the `fn_review_spec()` APPROVE gate, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. This prevents one-line redirect specs from burning review reminders or fallback planning retries.
|
||||
- **Triage planning loop** — after triage reads the generated `PROMPT.md`, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. Normal plans run deterministic spec hygiene checks in triage, then the selected workflow's optional Plan Review gate owns AI plan review before execution.
|
||||
- **Self-healing sweep** — maintenance Batch 2 runs `resolveExplicitDuplicateMarkerTasks()` across `triage`/`todo` tasks to clean up older stuck marker tasks. The sweep is best-effort, capped at 50 marker tasks per cycle, and can be disabled with the internal setting `resolveExplicitDuplicateMarkerEnabled: false` (default `true`).
|
||||
|
||||
All three layers fail open: parse errors, task lookup failures, file-read failures, activity-recording errors, or other unexpected exceptions log a warning and continue normal intake/triage/self-healing flow instead of blocking task creation or recovery.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "../builtin-stepwise-final-review-coding-workflow-ir.js";
|
||||
import type { TaskStore } from "../store.js";
|
||||
import type { WorkflowRunStepInstance } from "../types.js";
|
||||
import type { WorkflowIr } from "../workflow-ir-types.js";
|
||||
@@ -233,20 +234,20 @@ describe("workflow restart durability for explicit selections", () => {
|
||||
|
||||
// FNXC:WorkflowStepCRUD 2026-06-26-14:00: U7c — explicit selection of an
|
||||
// interpreter-deferred builtin now seeds its DEFAULT-ON optional-group ids (here
|
||||
// `code-review`), consistent with the create-time selection path. (The pre-U7c
|
||||
// `plan-review` and `code-review`), consistent with the create-time selection path. (The pre-U7c
|
||||
// selectTaskWorkflow returned [] for this case — an inconsistency with create-time
|
||||
// seeding — because it only returned materialized step ids, which no longer exist.)
|
||||
it("persists interpreter-deferred builtin selection seeding its default-on group across restart", async () => {
|
||||
const task = await store().createTask({ description: "builtin selection", enabledWorkflowSteps: [] });
|
||||
|
||||
await expect(store().selectTaskWorkflow(task.id, "builtin:coding")).resolves.toEqual(["code-review"]);
|
||||
await expect(store().selectTaskWorkflow(task.id, "builtin:coding")).resolves.toEqual(["plan-review", "code-review"]);
|
||||
|
||||
await reopenAsDiskBackedStore();
|
||||
|
||||
expect(store().getTaskWorkflowSelection(task.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["code-review"] });
|
||||
expect((await store().getTask(task.id)).enabledWorkflowSteps ?? []).toEqual(["code-review"]);
|
||||
expect((await taskJsonEnabledWorkflowSteps(task.id)) ?? []).toEqual(["code-review"]);
|
||||
expect(privateStore().resolveTaskWorkflowIrSync(task.id)).toEqual(BUILTIN_CODING_WORKFLOW_IR);
|
||||
expect(store().getTaskWorkflowSelection(task.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["plan-review", "code-review"] });
|
||||
expect((await store().getTask(task.id)).enabledWorkflowSteps ?? []).toEqual(["plan-review", "code-review"]);
|
||||
expect((await taskJsonEnabledWorkflowSteps(task.id)) ?? []).toEqual(["plan-review", "code-review"]);
|
||||
expect(privateStore().resolveTaskWorkflowIrSync(task.id)).toEqual(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR);
|
||||
});
|
||||
|
||||
it("persists create-time workflowId selections for custom and builtin workflows across restart", async () => {
|
||||
@@ -257,9 +258,9 @@ describe("workflow restart durability for explicit selections", () => {
|
||||
const customSelectionBefore = store().getTaskWorkflowSelection(customTask.id);
|
||||
expect(customSelectionBefore?.workflowId).toBe(workflow.id);
|
||||
expect(customSelectionBefore?.stepIds).toEqual(["review-group"]);
|
||||
// FNXC:CodeReviewStep — builtin:coding carries the DEFAULT-ON `code-review`
|
||||
// optional-group, so the create-time workflowId path seeds it into the selection.
|
||||
expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["code-review"] });
|
||||
// FNXC:PlanReview 2026-06-29-01:52:
|
||||
// builtin:coding carries DEFAULT-ON `plan-review` and `code-review` optional groups, so the create-time workflowId path seeds both into the selection.
|
||||
expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["plan-review", "code-review"] });
|
||||
|
||||
await reopenAsDiskBackedStore();
|
||||
|
||||
@@ -267,9 +268,9 @@ describe("workflow restart durability for explicit selections", () => {
|
||||
expect(customSelection).toEqual(customSelectionBefore);
|
||||
expect((await store().getTask(customTask.id)).enabledWorkflowSteps).toEqual(customSelectionBefore?.stepIds);
|
||||
expect(await taskJsonEnabledWorkflowSteps(customTask.id)).toEqual(customSelectionBefore?.stepIds);
|
||||
expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["code-review"] });
|
||||
expect((await store().getTask(builtinTask.id)).enabledWorkflowSteps ?? []).toEqual(["code-review"]);
|
||||
expect((await taskJsonEnabledWorkflowSteps(builtinTask.id)) ?? []).toEqual(["code-review"]);
|
||||
expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: ["plan-review", "code-review"] });
|
||||
expect((await store().getTask(builtinTask.id)).enabledWorkflowSteps ?? []).toEqual(["plan-review", "code-review"]);
|
||||
expect((await taskJsonEnabledWorkflowSteps(builtinTask.id)) ?? []).toEqual(["plan-review", "code-review"]);
|
||||
});
|
||||
|
||||
it("fails closed when a selected custom workflow definition is missing without corrupting the dangling selection", async () => {
|
||||
|
||||
@@ -235,7 +235,7 @@ Before writing a spec, call \`fn_task_list\` for active work, then call \`fn_tas
|
||||
Write a real PROMPT.md to the requested path using the write tool. Keep sections lean, but include Mission, Dependencies, Context to Read First, File Scope, Steps with Preflight / Testing & Verification / Documentation & Delivery, Documentation Requirements, Completion Criteria, Git Commit Convention, and Do NOT. Do not add a review-level heading, triage subtask breakdown, or proactive subtask breakdown.
|
||||
|
||||
## Surface Enumeration
|
||||
For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE.
|
||||
For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. The workflow Plan Review gate validates this before execution when plan review is enabled.
|
||||
For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section from docs/testing.md:
|
||||
- [ ] Providers / bridges / execution paths touched by the invariant; include every provider/bridge for streaming and agent paths.
|
||||
- [ ] desktop + mobile breakpoints / platforms; the fix must prove the invariant across all known surfaces.
|
||||
@@ -268,7 +268,7 @@ If the requested outcome is only to decide, route, or coordinate work, include \
|
||||
- Do not expand scope, skip tests, weaken acceptance, or delete/gut unrelated modules/features.
|
||||
|
||||
## Output
|
||||
Write PROMPT.md directly, then call \`fn_review_spec()\`. Fast-mode specs auto-approve, but the call is still required.`;
|
||||
Write PROMPT.md directly and stop. Do not call \`fn_review_spec()\`; workflow Plan Review is the single optional plan review gate before execution.`;
|
||||
|
||||
const TRIAGE_PROMPT_TEXT = `You are a task specification agent for "fn", an AI-orchestrated task board.
|
||||
|
||||
@@ -434,7 +434,7 @@ files with assertions that run via a test runner. Typechecks and builds are NOT
|
||||
tests. Manual verification is NOT a test.
|
||||
|
||||
- Each implementation step should include writing tests for the code being changed
|
||||
- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE.
|
||||
- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. The workflow Plan Review gate validates this before execution when plan review is enabled.
|
||||
- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal.
|
||||
- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123)
|
||||
- For bug-class/bug-fix tasks, the spec MUST include a \`## Symptom Verification\` section with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**. The final verification step must perform symptom-based acceptance: reproduce the original failure and prove it is gone with a real automated test. Green build/tests alone are insufficient. Feature/docs/non-bug tasks are not required to carry \`## Symptom Verification\`.
|
||||
@@ -553,15 +553,9 @@ Standard triage must not infer workflow changes from task type. Agents preserve
|
||||
- For decision-only tasks ({{triageNoCommitsDecisionVerbs}}) or other no-code tasks, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met; this is a header marker only and does not select \`{{triageDecisionOnlyWorkflowId}}\` or any custom investigation workflow by itself.
|
||||
- If the user explicitly asks for a workflow, call \`fn_workflow_list\` to discover valid IDs, then use \`fn_workflow_select\` to set the workflow on the current task or pass \`workflow_id\` to \`fn_task_create\` when creating a requested subtask.
|
||||
|
||||
## Spec Review
|
||||
## Plan Review
|
||||
|
||||
After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent quality review.
|
||||
|
||||
- **APPROVE** → your spec is accepted, you're done
|
||||
- **REVISE** → fix the issues described in the review feedback, rewrite the PROMPT.md, and call \`fn_review_spec()\` again. Repeat until approved.
|
||||
- **RETHINK** → your approach was fundamentally rejected. The conversation will rewind. Read the feedback carefully and take a completely different approach. Do NOT repeat the rejected strategy.
|
||||
|
||||
You MUST call \`fn_review_spec()\` after writing the PROMPT.md. Do not finish without getting an APPROVE verdict.
|
||||
Workflow Plan Review is the single optional plan quality gate before execution. Your job in triage is to write a complete PROMPT.md; do not call \`fn_review_spec()\` or any other review tool. If Plan Review is enabled for the task, the workflow graph runs it after triage and before execution.
|
||||
|
||||
## PROMPT.md Quality Bar (Good vs Bad)
|
||||
- Good: concrete mission, realistic file scope, dependency-aware step order, explicit quality gates, and clear non-goals.
|
||||
@@ -571,7 +565,7 @@ You MUST call \`fn_review_spec()\` after writing the PROMPT.md. Do not finish wi
|
||||
Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk.
|
||||
|
||||
## Output
|
||||
Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` for review.
|
||||
Write the PROMPT.md directly using the write tool and stop. The workflow graph owns plan review and execution routing.
|
||||
|
||||
## Task Artifact Location for Forensic / Reconciliation Tasks
|
||||
|
||||
|
||||
@@ -5562,8 +5562,8 @@ export function designWorkflow(
|
||||
}
|
||||
|
||||
/** Read the workflow currently selected for a task. */
|
||||
export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null }> {
|
||||
return api<{ workflowId: string | null }>(
|
||||
export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null; enabledWorkflowSteps?: string[] | null }> {
|
||||
return api<{ workflowId: string | null; enabledWorkflowSteps?: string[] | null }>(
|
||||
withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -575,7 +575,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||||
enabledWorkflowSteps: enabledOptionalStepIds.length ? enabledOptionalStepIds : undefined,
|
||||
/*
|
||||
FNXC:QuickAddWorkflowSteps 2026-06-29-01:31:
|
||||
Quick Add optional-step toggles are explicit task intent. When the workflow exposes optional steps and the user unchecks every one, submit an empty array instead of omitting the field so default-on Plan Review / Code Review do not reappear on the created task.
|
||||
*/
|
||||
enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
|
||||
...(isFastMode ? { executionMode: "fast" } : {}),
|
||||
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
|
||||
priority,
|
||||
|
||||
@@ -933,7 +933,7 @@ export function TaskDetailContent({
|
||||
// Workflow results state
|
||||
const [workflowResults, setWorkflowResults] = useState<WorkflowStepResult[]>([]);
|
||||
const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false);
|
||||
const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState<string[] | undefined>(task.enabledWorkflowSteps);
|
||||
const isNodeOverrideLocked = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
|
||||
|
||||
// Reset edit state when task changes
|
||||
@@ -956,7 +956,7 @@ export function TaskDetailContent({
|
||||
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps);
|
||||
}, [task.id, task.enabledWorkflowSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -331,6 +331,7 @@ export function WorkflowResultsTab({
|
||||
const [boardWorkflowFallbackId, setBoardWorkflowFallbackId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const [selectedWorkflowStepIds, setSelectedWorkflowStepIds] = useState<string[] | null | undefined>(undefined);
|
||||
const [resumeError, setResumeError] = useState<string | null>(null);
|
||||
const [graphExpanded, setGraphExpanded] = useState(false);
|
||||
const [workflowGraphCache, setWorkflowGraphCache] = useState<Record<string, WorkflowDefinition>>({});
|
||||
@@ -355,12 +356,19 @@ export function WorkflowResultsTab({
|
||||
Task-detail hosts can keep WorkflowResultsTab mounted while switching tasks. Clear the previous explicit selection before the new task selection fetch resolves (or fails) so default-inherited tasks use boardWorkflowFallbackId for the summary, graph fetch, and configured step details instead of a stale custom workflow from the prior task.
|
||||
*/
|
||||
setSelectedWorkflowId(null);
|
||||
setSelectedWorkflowStepIds(undefined);
|
||||
fetchTaskWorkflow(taskId, projectId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setSelectedWorkflowId(res.workflowId);
|
||||
if (!cancelled) {
|
||||
setSelectedWorkflowId(res.workflowId);
|
||||
setSelectedWorkflowStepIds(Array.isArray(res.enabledWorkflowSteps) ? res.enabledWorkflowSteps : null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSelectedWorkflowId(null);
|
||||
if (!cancelled) {
|
||||
setSelectedWorkflowId(null);
|
||||
setSelectedWorkflowStepIds(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -371,6 +379,7 @@ export function WorkflowResultsTab({
|
||||
async (workflowId: string | null) => {
|
||||
const res = await selectTaskWorkflow(taskId, workflowId, projectId);
|
||||
setSelectedWorkflowId(res.workflowId);
|
||||
setSelectedWorkflowStepIds(res.enabledWorkflowSteps);
|
||||
onWorkflowStepsChange?.(res.enabledWorkflowSteps);
|
||||
/*
|
||||
FNXC:CustomWorkflows 2026-06-17-07:21:
|
||||
@@ -497,13 +506,18 @@ export function WorkflowResultsTab({
|
||||
};
|
||||
}, [effectiveWorkflowId, projectId]);
|
||||
|
||||
const selectedWorkflowSteps = enabledWorkflowSteps ?? [];
|
||||
const selectedWorkflowSteps = enabledWorkflowSteps ?? (Array.isArray(selectedWorkflowStepIds) ? selectedWorkflowStepIds : []);
|
||||
const hasExplicitWorkflowStepSelection = enabledWorkflowSteps !== undefined || Array.isArray(selectedWorkflowStepIds);
|
||||
const canSynthesizeDefaultOnWorkflowSteps = enabledWorkflowSteps !== undefined || selectedWorkflowStepIds !== undefined;
|
||||
/*
|
||||
FNXC:TaskWorkflowDetails 2026-06-28-12:10:
|
||||
The configured-steps panel is read-only status truth for non-editable in-progress tasks, so it must show the effective enabled optional steps: persisted task ids plus workflow optional-group ids marked `defaultOn`. Keep edit-mode controls bound to the persisted ids so toggling a default-on step writes only the explicit task override set.
|
||||
|
||||
FNXC:TaskWorkflowDetails 2026-06-28-12:34:
|
||||
Persisted enabledWorkflowSteps can store a materialized workflow-step id while optional groups resolve by templateId. Treat those ids as aliases when appending defaultOn steps so the configured panel does not count the same optional step twice.
|
||||
|
||||
FNXC:TaskWorkflowDetails 2026-06-29-01:31:
|
||||
An explicit empty optional-step selection means the operator disabled every optional group at create time. Do not append default-on Plan Review / Code Review in that case; only synthesize default-on display steps when no explicit task or workflow-selection step list exists.
|
||||
*/
|
||||
const effectiveEnabledStepIds = useMemo(() => {
|
||||
const stepIds = [...selectedWorkflowSteps];
|
||||
@@ -515,13 +529,15 @@ export function WorkflowResultsTab({
|
||||
if (workflowStep.templateId) seen.add(workflowStep.templateId);
|
||||
}
|
||||
}
|
||||
for (const step of optionalWorkflowSteps) {
|
||||
if (!step.defaultOn || seen.has(step.templateId)) continue;
|
||||
seen.add(step.templateId);
|
||||
stepIds.push(step.templateId);
|
||||
if (!hasExplicitWorkflowStepSelection && canSynthesizeDefaultOnWorkflowSteps) {
|
||||
for (const step of optionalWorkflowSteps) {
|
||||
if (!step.defaultOn || seen.has(step.templateId)) continue;
|
||||
seen.add(step.templateId);
|
||||
stepIds.push(step.templateId);
|
||||
}
|
||||
}
|
||||
return stepIds;
|
||||
}, [allWorkflowSteps, optionalWorkflowSteps, selectedWorkflowSteps]);
|
||||
}, [allWorkflowSteps, canSynthesizeDefaultOnWorkflowSteps, hasExplicitWorkflowStepSelection, optionalWorkflowSteps, selectedWorkflowSteps]);
|
||||
|
||||
const workflowStepOptions = useMemo<WorkflowStepOption[]>(() => {
|
||||
const options: WorkflowStepOption[] = allWorkflowSteps.map((step) => ({
|
||||
|
||||
@@ -1679,6 +1679,24 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an empty enabledWorkflowSteps array when every optional step is unchecked", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
|
||||
|
||||
const trigger = await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create without reviews" } });
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: [] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves null, undefined, and explicit workflow ids for optional-step fetches", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||
const { unmount: unmountNull } = renderQuickEntryBox({ workflowId: null });
|
||||
|
||||
@@ -827,7 +827,7 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(screen.queryByTestId("workflow-configured-steps")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows default-on optional steps for in-progress tasks without persisted workflow steps", async () => {
|
||||
it("shows default-on optional steps for in-progress tasks without any explicit workflow step selection", async () => {
|
||||
mockedFetchWorkflowOptionalSteps.mockResolvedValueOnce([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
@@ -844,7 +844,6 @@ describe("WorkflowResultsTab", () => {
|
||||
results={[]}
|
||||
canEdit={false}
|
||||
isTaskInProgress
|
||||
enabledWorkflowSteps={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -855,6 +854,40 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(screen.queryByTestId("workflow-results-empty")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show default-on optional steps when workflow selection explicitly has no enabled steps", async () => {
|
||||
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "builtin:coding", enabledWorkflowSteps: [] });
|
||||
mockedFetchWorkflowOptionalSteps.mockResolvedValueOnce([
|
||||
{
|
||||
templateId: "plan-review",
|
||||
name: "Plan Review",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
templateId: "code-review",
|
||||
name: "Code Review",
|
||||
description: "",
|
||||
phase: "pre-merge",
|
||||
defaultOn: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit={false}
|
||||
isTaskInProgress
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedFetchWorkflowOptionalSteps).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("workflow-configured-step-plan-review")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-configured-step-code-review")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-results-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("de-duplicates persisted optional steps that are also default-on", async () => {
|
||||
mockedFetchWorkflowOptionalSteps.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -1380,7 +1413,7 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(screen.queryByTestId("workflow-steps-editor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps default-on optional steps display-only while editor toggles use persisted ids", async () => {
|
||||
it("does not synthesize default-on optional steps when persisted ids are explicit", async () => {
|
||||
mockedFetchWorkflowOptionalSteps.mockResolvedValueOnce([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
@@ -1403,7 +1436,8 @@ describe("WorkflowResultsTab", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("workflow-configured-step-browser-verification")).toHaveTextContent("Browser Verification");
|
||||
await waitFor(() => expect(mockedFetchWorkflowOptionalSteps).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("workflow-configured-step-browser-verification")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
|
||||
const defaultOnCheckbox = within(await screen.findByTestId("workflow-step-checkbox-browser-verification")).getByRole("checkbox") as HTMLInputElement;
|
||||
|
||||
@@ -330,7 +330,8 @@ describe("workflow routes (U4)", () => {
|
||||
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
|
||||
|
||||
const read = await get(`/api/tasks/${task.id}/workflow`);
|
||||
expect((read.body as { workflowId: string }).workflowId).toBe(wfId);
|
||||
expect((read.body as { workflowId: string; enabledWorkflowSteps: string[] }).workflowId).toBe(wfId);
|
||||
expect((read.body as { workflowId: string; enabledWorkflowSteps: string[] }).enabledWorkflowSteps).toEqual([]);
|
||||
});
|
||||
|
||||
it("PUT /tasks/:taskId/workflow rejects an omitted workflowId but clears on explicit null", async () => {
|
||||
@@ -353,6 +354,7 @@ describe("workflow routes (U4)", () => {
|
||||
expect((cleared.body as { workflowId: string | null }).workflowId).toBeNull();
|
||||
const read = await get(`/api/tasks/${task.id}/workflow`);
|
||||
expect((read.body as { workflowId: string | null }).workflowId).toBeNull();
|
||||
expect((read.body as { workflowId: string | null; enabledWorkflowSteps: string[] | null }).enabledWorkflowSteps).toBeNull();
|
||||
});
|
||||
|
||||
it.each([123, true, {}, []])(
|
||||
|
||||
@@ -582,7 +582,10 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
try {
|
||||
const { store } = await getProjectContext(req);
|
||||
const selection = store.getTaskWorkflowSelection(req.params.taskId);
|
||||
res.json({ workflowId: selection?.workflowId ?? null });
|
||||
res.json({
|
||||
workflowId: selection?.workflowId ?? null,
|
||||
enabledWorkflowSteps: selection ? selection.stepIds : null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("MockAgentRuntime", () => {
|
||||
|
||||
it.each([
|
||||
["executor", ["fn_task_show", "fn_task_update", "fn_task_update"]],
|
||||
["triage", ["write", "fn_review_spec"]],
|
||||
["triage", ["write"]],
|
||||
["reviewer", []],
|
||||
["merger", []],
|
||||
["heartbeat", []],
|
||||
@@ -80,10 +80,6 @@ describe("MockAgentRuntime", () => {
|
||||
toolCalls.push("fn_task_update");
|
||||
return { content: [{ type: "text", text: JSON.stringify(args) }], details: {} };
|
||||
});
|
||||
const reviewSpecExecute = vi.fn(async () => {
|
||||
toolCalls.push("fn_review_spec");
|
||||
return { content: [{ type: "text", text: "APPROVE" }], details: {} };
|
||||
});
|
||||
const taskShowExecute = vi.fn(async () => {
|
||||
toolCalls.push("fn_task_show");
|
||||
return { steps: [{ status: "todo" }, { status: "done" }, { status: "todo" }] };
|
||||
@@ -100,7 +96,6 @@ describe("MockAgentRuntime", () => {
|
||||
createTool("write", writeExecute),
|
||||
createTool("fn_task_show", taskShowExecute),
|
||||
createTool("fn_task_update", updateExecute),
|
||||
createTool("fn_review_spec", reviewSpecExecute),
|
||||
],
|
||||
onText,
|
||||
onToolStart,
|
||||
@@ -212,7 +207,6 @@ describe("MockAgentRuntime", () => {
|
||||
return { content: [], details: {} };
|
||||
})),
|
||||
createTool("fn_task_update"),
|
||||
createTool("fn_review_spec"),
|
||||
createTool("fn_task_show"),
|
||||
],
|
||||
taskId,
|
||||
|
||||
@@ -6511,7 +6511,7 @@ describe("SelfHealingManager", () => {
|
||||
});
|
||||
|
||||
describe("recoverApprovedTriageTasks", () => {
|
||||
it("recovers approved planning triage tasks that are not actively processing", async () => {
|
||||
it("recovers specified planning triage tasks that are not actively processing", async () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
@@ -6578,7 +6578,7 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips planning triage tasks whose latest review is not APPROVE", async () => {
|
||||
it("attempts stale planning triage tasks regardless of legacy spec-review log state", async () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
@@ -6607,15 +6607,15 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
const result = await managerWithRecovery.recoverApprovedTriageTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(recoverFn).not.toHaveBeenCalled();
|
||||
expect(result).toBe(1);
|
||||
expect(recoverFn).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-102" }));
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverOrphanedPlanningTasks", () => {
|
||||
it("clears status for orphaned planning tasks without approval", async () => {
|
||||
it("clears status for orphaned planning tasks without a recoverable prompt", async () => {
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
@@ -6677,7 +6677,7 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("skips tasks that have an approved spec (handled by recoverApprovedTriageTasks)", async () => {
|
||||
it("clears status for stale planning tasks after prompt-based recovery has had a chance to run", async () => {
|
||||
const getPlanning = vi.fn().mockReturnValue(new Set<string>());
|
||||
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
@@ -6703,8 +6703,8 @@ describe("SelfHealingManager", () => {
|
||||
|
||||
const result = await managerWithRecovery.recoverOrphanedPlanningTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-202", { status: null });
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
@@ -7594,7 +7594,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("recovers approved task after eviction removes it from planningIds", async () => {
|
||||
it("recovers specified task after eviction removes it from planningIds", async () => {
|
||||
const store = createMockStore();
|
||||
let planningIds = new Set(["FN-100"]);
|
||||
const evictFn = vi.fn().mockImplementation(() => {
|
||||
|
||||
@@ -716,10 +716,11 @@ Some freeform text without checkboxes.`;
|
||||
expect(result).toContain("Please prioritize the API invariant before refactoring.");
|
||||
});
|
||||
|
||||
it("includes fn_task_done instruction at the end", () => {
|
||||
it("does not ask graph-owned step sessions to call task lifecycle tools", () => {
|
||||
const task = makeTaskDetail({ prompt: fullPrompt });
|
||||
const result = buildStepPrompt(task, 1);
|
||||
expect(result).toContain("fn_task_done()");
|
||||
expect(result).toContain("the workflow graph records completion");
|
||||
expect(result).not.toContain("fn_task_done()");
|
||||
});
|
||||
|
||||
it("does not include content from other steps", () => {
|
||||
|
||||
@@ -120,14 +120,12 @@ async function captureBasePrompt(task: Task, store: TaskStore): Promise<string>
|
||||
return capture.basePrompt ?? "";
|
||||
}
|
||||
|
||||
async function runReviewSpec(task: Task, store: TaskStore, rootDir: string): Promise<void> {
|
||||
async function runPlanningSession(task: Task, store: TaskStore, rootDir: string): Promise<void> {
|
||||
mockSession();
|
||||
mockPromptWithFallback.mockImplementationOnce(async (session: any) => {
|
||||
mockPromptWithFallback.mockImplementationOnce(async () => {
|
||||
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
|
||||
await writeFile(promptPath, "# Task: FN-6236\n\n## Mission\n\nVerify fast policy.\n", "utf8");
|
||||
const reviewSpec = session.__customTools.find((tool: any) => tool.name === "fn_review_spec");
|
||||
await reviewSpec.execute();
|
||||
});
|
||||
|
||||
await new TriageProcessor(store, rootDir).specifyTask(task);
|
||||
@@ -202,39 +200,40 @@ describe("fast-mode workflow variant resolution", () => {
|
||||
expect(basePrompt).not.toBe(renderedFastPlanningPrompt);
|
||||
});
|
||||
|
||||
it("auto-approves fast tasks without invoking the reviewer", async () => {
|
||||
it("finalizes fast tasks without invoking a separate spec reviewer", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-fast-"));
|
||||
tempRoots.push(rootDir);
|
||||
const task = createTask({ id: "FN-6236-FAST-REVIEW", executionMode: "fast" });
|
||||
const store = createStore(task);
|
||||
|
||||
await runReviewSpec(task, store, rootDir);
|
||||
await runPlanningSession(task, store, rootDir);
|
||||
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(task.id, "Spec review: APPROVE (auto-approve spec)");
|
||||
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
});
|
||||
|
||||
it("invokes the reviewer for standard tasks without autoApproveSpec", async () => {
|
||||
it("finalizes standard tasks without invoking a separate spec reviewer", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-standard-"));
|
||||
tempRoots.push(rootDir);
|
||||
const task = createTask({ id: "FN-6236-STANDARD-REVIEW", executionMode: "standard" });
|
||||
const store = createStore(task);
|
||||
|
||||
await runReviewSpec(task, store, rootDir);
|
||||
await runPlanningSession(task, store, rootDir);
|
||||
|
||||
expect(mockReviewStep).toHaveBeenCalledTimes(1);
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
});
|
||||
|
||||
it("auto-approves standard tasks when the workflow setting is enabled", async () => {
|
||||
it("ignores legacy autoApproveSpec because workflow Plan Review owns approval", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-setting-"));
|
||||
tempRoots.push(rootDir);
|
||||
const task = createTask({ id: "FN-6236-SETTING-REVIEW", executionMode: "standard" });
|
||||
const store = createStore(task, { autoApproveSpec: true });
|
||||
|
||||
await runReviewSpec(task, store, rootDir);
|
||||
await runPlanningSession(task, store, rootDir);
|
||||
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).toHaveBeenCalledWith(task.id, "Spec review: APPROVE (auto-approve spec)");
|
||||
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
});
|
||||
|
||||
it("preserves user triage prompt override precedence over the fast variant", async () => {
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { TaskStore, TaskDetail, Settings } from "@fusion/core";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
const { mockReviewStep } = vi.hoisted(() => ({
|
||||
mockReviewStep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../reviewer.js", () => ({
|
||||
reviewStep: mockReviewStep,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const { createEngineCoreMock } = await import("../test/mockCore.js");
|
||||
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
|
||||
@@ -65,74 +57,40 @@ const mockTaskDetail: TaskDetail = {
|
||||
comments: [],
|
||||
};
|
||||
|
||||
describe("triage fn_review_spec dangling references", () => {
|
||||
it("short-circuits to REVISE without invoking reviewer", async () => {
|
||||
describe("triage deterministic plan validation for dangling references", () => {
|
||||
it("rejects dangling task-document references without invoking reviewer", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-dangling-"));
|
||||
try {
|
||||
const taskId = "FN-5112";
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(join(rootDir, promptPath), "## Steps\n### Step 0: Preflight\n- Read .fusion/tasks/FN-5112/notes.md\n");
|
||||
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
"## Steps\n### Step 0: Preflight\n- Read .fusion/tasks/FN-5112/notes.md\n",
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
expect(String(result.content[0]?.text)).toContain("REVISE");
|
||||
expect(String(result.content[0]?.text)).toContain("notes.md");
|
||||
expect(verdictRef.current).toBe("REVISE");
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(failure).toContain("notes.md");
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls through to reviewer when referenced file is declared as new artifact", async () => {
|
||||
it("passes when referenced file is declared as new artifact", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-dangling-ok-"));
|
||||
try {
|
||||
const taskId = "FN-5112";
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(
|
||||
join(rootDir, promptPath),
|
||||
"## Steps\n### Step 1: Create\n- Read .fusion/tasks/FN-5112/notes.md\n\n**Artifacts:**\n- `.fusion/tasks/FN-5112/notes.md` (new)\n",
|
||||
);
|
||||
|
||||
mockReviewStep.mockResolvedValueOnce({ verdict: "APPROVE", summary: "ok", review: "" });
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
"## Steps\n### Step 1: Create\n- Read .fusion/tasks/FN-5112/notes.md\n\n**Artifacts:**\n- `.fusion/tasks/FN-5112/notes.md` (new)\n",
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
expect(result.content[0]?.text).toBe("APPROVE");
|
||||
expect(verdictRef.current).toBe("APPROVE");
|
||||
expect(mockReviewStep).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeNull();
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { TaskStore, TaskDetail, Settings } from "@fusion/core";
|
||||
import { TriageProcessor } from "../triage.js";
|
||||
|
||||
const { mockReviewStep } = vi.hoisted(() => ({ mockReviewStep: vi.fn() }));
|
||||
|
||||
vi.mock("../reviewer.js", () => ({ reviewStep: mockReviewStep }));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const { createEngineCoreMock } = await import("../test/mockCore.js");
|
||||
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
|
||||
@@ -61,111 +57,55 @@ const mockTaskDetail: TaskDetail = {
|
||||
comments: [],
|
||||
};
|
||||
|
||||
describe("triage fn_review_spec external integration evidence", () => {
|
||||
beforeEach(() => {
|
||||
mockReviewStep.mockReset();
|
||||
});
|
||||
it("short-circuits to REVISE when evidence is incomplete", async () => {
|
||||
describe("triage deterministic plan validation for external integration evidence", () => {
|
||||
it("rejects incomplete evidence without invoking reviewer", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-"));
|
||||
try {
|
||||
const taskId = "FN-5321";
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
const fabricatedRepo = ["worktrunk", "worktrunk"].join("/");
|
||||
await writeFile(
|
||||
join(rootDir, promptPath),
|
||||
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
`## Mission\nAdd third-party external binary integration.\n## Steps\n- install and probe \`worktrunk\` from release URL https://github.com/${fabricatedRepo}/releases/latest/download/worktrunk.tar.gz\n`,
|
||||
);
|
||||
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
expect(String(result.content[0]?.text)).toContain("REVISE");
|
||||
expect(String(result.content[0]?.text)).toContain("External-integration evidence gaps");
|
||||
expect(verdictRef.current).toBe("REVISE");
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(failure).toContain("External-integration evidence gaps");
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("calls reviewer when dedicated labeled evidence section is complete", async () => {
|
||||
it("passes when dedicated labeled evidence section is complete", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-labeled-ok-"));
|
||||
try {
|
||||
const taskId = "FN-5321";
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(
|
||||
join(rootDir, promptPath),
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
"## Mission\nValidate released third-party external integration.\n\n## External Integration Evidence\n- Canonical upstream repo URL: https://github.com/Runfusion/Fusion\n- Docs / homepage URL: https://github.com/Runfusion/Fusion#readme (npm package page: https://www.npmjs.com/package/@runfusion/fusion)\n- Release / download URL: https://registry.npmjs.org/@runfusion/fusion/-/fusion-0.41.0.tgz\n- Binary / CLI name: `fn`\n- Checksum (dist.integrity for 0.41.0): `sha512-y8BSeK3XUgcE7ceTrz6F/zWQidaiADVgHSHHWKRzwjyR40xeUc8i5ZSolGd1zL/K9AxrBSkRErimkW1xqb/EBw==` (marker: `upstream-pending-verification`)\n\n## Steps\n- Install, download, probe, and run the released external binary.\n",
|
||||
);
|
||||
|
||||
mockReviewStep.mockResolvedValueOnce({ verdict: "APPROVE", summary: "ok", review: "" });
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
expect(result.content[0]?.text).toBe("APPROVE");
|
||||
expect(verdictRef.current).toBe("APPROVE");
|
||||
expect(mockReviewStep).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeNull();
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("calls reviewer when evidence is complete", async () => {
|
||||
it("passes when evidence is complete", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-ok-"));
|
||||
try {
|
||||
const taskId = "FN-5321";
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(
|
||||
join(rootDir, promptPath),
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
"## Mission\nAdd third-party external integration.\n## Context to Read First\n- https://github.com/max-sixty/worktrunk\n- https://worktrunk.dev/\n- WORKTRUNK_PINNED_RELEASE\n## Steps\n- probe and run `wt`\n- release URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz\n- source: upstream-pending-verification\n",
|
||||
);
|
||||
|
||||
mockReviewStep.mockResolvedValueOnce({ verdict: "APPROVE", summary: "ok", review: "" });
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
expect(result.content[0]?.text).toBe("APPROVE");
|
||||
expect(verdictRef.current).toBe("APPROVE");
|
||||
expect(mockReviewStep).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeNull();
|
||||
} finally {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -150,37 +150,25 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
rootDir = undefined;
|
||||
});
|
||||
|
||||
it("reproduces the cold-start symptom and asserts the retry resumes from a non-empty draft", async () => {
|
||||
it("recovers forward from a non-empty PROMPT.md draft after a stuck abort", async () => {
|
||||
const draft = "# Task: FN-7173-T\n\n## Mission\n\nContinue from this already drafted plan.";
|
||||
const task = createTask();
|
||||
rootDir = await createRoot(task.id, draft);
|
||||
const harness = createMutableStore(task);
|
||||
const processor = new TriageProcessor(harness.store, rootDir);
|
||||
let retryPrompt = "";
|
||||
|
||||
mockPromptWithFallback
|
||||
.mockImplementationOnce(async () => {
|
||||
processor.markStuckAborted(task.id);
|
||||
})
|
||||
.mockImplementationOnce(async (_session: unknown, prompt: string) => {
|
||||
retryPrompt = prompt;
|
||||
processor.markStuckAborted(task.id);
|
||||
});
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
expect(harness.currentTask.status).toBe("needs-replan");
|
||||
expect(harness.store.logEntry).toHaveBeenCalledWith(
|
||||
expect(harness.store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
expect(harness.store.logEntry).not.toHaveBeenCalledWith(
|
||||
task.id,
|
||||
"Triage stuck re-queue will resume existing planning draft",
|
||||
expect.stringContaining("Resume from the existing draft"),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(retryPrompt).toContain("Revise this task");
|
||||
expect(retryPrompt).toContain("## Existing Specification");
|
||||
expect(retryPrompt).toContain(draft);
|
||||
expect(retryPrompt).toContain("instead of restarting planning from scratch");
|
||||
});
|
||||
|
||||
it("resumes from a saved plan task document when PROMPT.md is absent", async () => {
|
||||
@@ -189,31 +177,20 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
rootDir = await createRoot(task.id);
|
||||
const harness = createMutableStore(task, {}, { plan: planDocument });
|
||||
const processor = new TriageProcessor(harness.store, rootDir);
|
||||
let retryPrompt = "";
|
||||
|
||||
mockPromptWithFallback
|
||||
.mockImplementationOnce(async () => {
|
||||
processor.markStuckAborted(task.id);
|
||||
})
|
||||
.mockImplementationOnce(async (_session: unknown, prompt: string) => {
|
||||
retryPrompt = prompt;
|
||||
processor.markStuckAborted(task.id);
|
||||
});
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
expect(harness.currentTask.status).toBe("needs-replan");
|
||||
expect(harness.currentTask.stuckKillCount).toBe(1);
|
||||
expect(harness.store.logEntry).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
"Triage stuck re-queue will resume existing planning draft",
|
||||
expect.stringContaining("Resume from the existing draft"),
|
||||
);
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(retryPrompt).toContain("Revise this task");
|
||||
expect(retryPrompt).toContain("## Existing Specification");
|
||||
expect(retryPrompt).toContain(planDocument);
|
||||
expect(retryPrompt).toContain("instead of restarting planning from scratch");
|
||||
});
|
||||
|
||||
it("prefers PROMPT.md over the plan task document when both drafts exist", async () => {
|
||||
@@ -223,22 +200,19 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
rootDir = await createRoot(task.id, promptDraft);
|
||||
const harness = createMutableStore(task, {}, { plan: planDocument });
|
||||
const processor = new TriageProcessor(harness.store, rootDir);
|
||||
let retryPrompt = "";
|
||||
|
||||
mockPromptWithFallback
|
||||
.mockImplementationOnce(async () => {
|
||||
processor.markStuckAborted(task.id);
|
||||
})
|
||||
.mockImplementationOnce(async (_session: unknown, prompt: string) => {
|
||||
retryPrompt = prompt;
|
||||
processor.markStuckAborted(task.id);
|
||||
});
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(retryPrompt).toContain(promptDraft);
|
||||
expect(retryPrompt).not.toContain(planDocument);
|
||||
expect(harness.store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
expect(harness.store.logEntry).not.toHaveBeenCalledWith(
|
||||
task.id,
|
||||
"Triage stuck re-queue will resume existing planning draft",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -249,29 +223,20 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
rootDir = await createRoot(task.id, draft);
|
||||
const harness = createMutableStore(task);
|
||||
const processor = new TriageProcessor(harness.store, rootDir);
|
||||
let retryPrompt = "";
|
||||
|
||||
mockPromptWithFallback
|
||||
.mockImplementationOnce(async () => {
|
||||
processor.markStuckAborted(task.id);
|
||||
})
|
||||
.mockImplementationOnce(async (_session: unknown, prompt: string) => {
|
||||
retryPrompt = prompt;
|
||||
processor.markStuckAborted(task.id);
|
||||
});
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
expect(harness.currentTask.status ?? null).toBeNull();
|
||||
expect(harness.currentTask.stuckKillCount).toBe(1);
|
||||
expect(harness.store.logEntry).not.toHaveBeenCalledWith(
|
||||
task.id,
|
||||
"Triage stuck re-queue will resume existing planning draft",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(retryPrompt).toContain("Specify this task");
|
||||
expect(retryPrompt).not.toContain("## Existing Specification");
|
||||
});
|
||||
|
||||
it("uses the same resume behavior for the outer catch stuck-abort path", async () => {
|
||||
@@ -280,25 +245,15 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
rootDir = await createRoot(task.id, draft);
|
||||
const harness = createMutableStore(task);
|
||||
const processor = new TriageProcessor(harness.store, rootDir);
|
||||
let retryPrompt = "";
|
||||
|
||||
mockPromptWithFallback
|
||||
.mockImplementationOnce(async () => {
|
||||
processor.markStuckAborted(task.id);
|
||||
throw new Error("disposed by stuck detector");
|
||||
})
|
||||
.mockImplementationOnce(async (_session: unknown, prompt: string) => {
|
||||
retryPrompt = prompt;
|
||||
processor.markStuckAborted(task.id);
|
||||
});
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
expect(harness.currentTask.status).toBe("needs-replan");
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(retryPrompt).toContain("Revise this task");
|
||||
expect(retryPrompt).toContain(draft);
|
||||
expect(harness.store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
});
|
||||
|
||||
it("bounds repeated stuck retries by maxStuckKills and pauses failed tasks", async () => {
|
||||
@@ -313,13 +268,11 @@ describe("triage stuck requeue preserves existing PROMPT.md drafts", () => {
|
||||
|
||||
await processor.specifyTask(harness.currentTask);
|
||||
|
||||
expect(harness.currentTask.stuckKillCount).toBe(2);
|
||||
expect(harness.currentTask.status).toBe("failed");
|
||||
expect(harness.currentTask.paused).toBe(true);
|
||||
expect(harness.currentTask.error).toContain("STUCK_LOOP_EXHAUSTED");
|
||||
expect(harness.store.moveTask).toHaveBeenCalledWith(task.id, "todo");
|
||||
expect(harness.currentTask.paused).not.toBe(true);
|
||||
});
|
||||
|
||||
it("leaves already-approved drafts on the approved-spec recovery path", async () => {
|
||||
it("leaves already-written drafts on the prompt-based recovery path", async () => {
|
||||
const task = createTask({
|
||||
id: "FN-7173-APPROVED",
|
||||
status: "planning",
|
||||
|
||||
@@ -721,13 +721,10 @@ describe("FN-5893 invariant regression wording", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a Surface Enumeration section and proves missing sections are blocking REVISEs for bug-fix specs", () => {
|
||||
const missingSectionRevisePattern =
|
||||
/For bug fixes and UI-affordance add\/remove tasks, the spec MUST include a `## Surface Enumeration` section\. During self-review via `fn_review_spec\(\)`, treat a missing section on a bug-fix or UI-affordance add\/remove spec as a blocking REVISE\./;
|
||||
|
||||
it("requires a Surface Enumeration section and routes validation through workflow Plan Review", () => {
|
||||
for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) {
|
||||
expect(prompt).toContain("## Surface Enumeration");
|
||||
expect(prompt).toMatch(missingSectionRevisePattern);
|
||||
expect(prompt).toContain("workflow Plan Review");
|
||||
expect(prompt).toContain("docs/testing.md");
|
||||
expect(prompt).toContain("duplicate / populated data states");
|
||||
expect(prompt).toContain("shared hooks/components/modules/helpers");
|
||||
@@ -740,7 +737,7 @@ describe("FN-5893 invariant regression wording", () => {
|
||||
|
||||
expect(corePromptSource).toContain("## Surface Enumeration");
|
||||
expect(corePromptSource).toContain("spec MUST include a \\`## Surface Enumeration\\` section");
|
||||
expect(corePromptSource).toContain("blocking REVISE");
|
||||
expect(corePromptSource).toContain("workflow Plan Review");
|
||||
expect(corePromptSource).toContain("docs/testing.md");
|
||||
expect(corePromptSource).toContain("duplicate / populated data states");
|
||||
expect(corePromptSource).toContain("shared hooks/components/modules/helpers");
|
||||
@@ -800,7 +797,8 @@ describe("fast-mode triage", () => {
|
||||
expect(typeof FAST_PLANNING_PROMPT).toBe("string");
|
||||
expect(FAST_PLANNING_PROMPT.length).toBeGreaterThan(0);
|
||||
expect(FAST_PLANNING_PROMPT).toContain("This task is running in **fast mode**");
|
||||
expect(FAST_PLANNING_PROMPT).toContain("fn_review_spec()");
|
||||
expect(FAST_PLANNING_PROMPT).toContain("workflow Plan Review");
|
||||
expect(FAST_PLANNING_PROMPT).toContain("Do not call `fn_review_spec()`");
|
||||
expect(FAST_PLANNING_PROMPT).not.toContain("## Review Level");
|
||||
expect(FAST_PLANNING_PROMPT).not.toContain("## Triage subtask breakdown");
|
||||
expect(FAST_PLANNING_PROMPT).not.toContain("## Proactive Subtask Breakdown");
|
||||
@@ -974,116 +972,7 @@ describe("fast-mode triage", () => {
|
||||
expect(capturedSystemPrompt).toContain("## Plugin: plugin-fast");
|
||||
});
|
||||
|
||||
it("auto-approves fn_review_spec in fast mode without calling reviewer", async () => {
|
||||
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-review-");
|
||||
try {
|
||||
const taskId = "FN-FAST-003";
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), "# Task\n\nSpec");
|
||||
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId, comments: [] }),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const verdictRef = { current: null as any };
|
||||
const approvedCommentFingerprintRef = { current: "" };
|
||||
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
`.fusion/tasks/${taskId}/PROMPT.md`,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
verdictRef,
|
||||
approvedCommentFingerprintRef,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(verdictRef.current).toBe("APPROVE");
|
||||
expect(result.content[0]?.text).toBe("APPROVE");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(taskId, "Spec review: APPROVE (auto-approve spec)");
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("threads global fallback model settings into spec reviewer sessions", async () => {
|
||||
const rootDir = await createTriageFixtureRoot("fusion-triage-review-fallback-");
|
||||
try {
|
||||
const taskId = "FN-REVIEW-FALLBACK";
|
||||
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(
|
||||
join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"),
|
||||
"# Task\n\n## Mission\n\nDo the work.\n\n## Steps\n\n### Step 0: Implement\n\nShip it.",
|
||||
);
|
||||
mockReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "ok", summary: "ok" });
|
||||
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
comments: [],
|
||||
modelProvider: "task-executor-provider",
|
||||
modelId: "task-executor-model",
|
||||
validatorModelProvider: "task-reviewer-provider",
|
||||
validatorModelId: "task-reviewer-model",
|
||||
}),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus-4-8",
|
||||
defaultProviderOverride: "openai-codex",
|
||||
defaultModelIdOverride: "gpt-5.5",
|
||||
fallbackProvider: "openai-codex",
|
||||
fallbackModelId: "gpt-5.5",
|
||||
memoryEnabled: false,
|
||||
agentPrompts: { roleAssignments: { reviewer: "custom-reviewer" } },
|
||||
} as Settings),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
`.fusion/tasks/${taskId}/PROMPT.md`,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: "" },
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
const reviewOptions = mockReviewStep.mock.calls[0]?.[7];
|
||||
expect(reviewOptions).toMatchObject({
|
||||
taskValidatorProvider: "task-reviewer-provider",
|
||||
taskValidatorModelId: "task-reviewer-model",
|
||||
projectDefaultOverrideProvider: "openai-codex",
|
||||
projectDefaultOverrideModelId: "gpt-5.5",
|
||||
fallbackProvider: "openai-codex",
|
||||
fallbackModelId: "gpt-5.5",
|
||||
agentPrompts: { roleAssignments: { reviewer: "custom-reviewer" } },
|
||||
});
|
||||
expect(reviewOptions.taskValidatorProvider).not.toBe("task-executor-provider");
|
||||
expect(reviewOptions.taskValidatorModelId).not.toBe("task-executor-model");
|
||||
expect(reviewOptions.settings).toMatchObject({
|
||||
memoryEnabled: false,
|
||||
agentPrompts: { roleAssignments: { reviewer: "custom-reviewer" } },
|
||||
});
|
||||
} finally {
|
||||
mockReviewStep.mockReset();
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes post-session gate in fast mode after fn_review_spec auto-approval", async () => {
|
||||
it("finalizes fast planning without exposing a separate spec-review tool", async () => {
|
||||
const rootDir = await createTriageFixtureRoot("fusion-triage-fast-gate-");
|
||||
try {
|
||||
const task = createTriageTask({ id: "FN-FAST-004", executionMode: "fast" });
|
||||
@@ -1125,10 +1014,8 @@ describe("fast-mode triage", () => {
|
||||
expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(true);
|
||||
expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(true);
|
||||
expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(true);
|
||||
expect(capturedTools.some((tool: any) => tool.name === "fn_review_spec")).toBe(false);
|
||||
await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it.");
|
||||
const reviewTool = capturedTools.find((tool) => tool.name === "fn_review_spec");
|
||||
expect(reviewTool).toBeDefined();
|
||||
await reviewTool.execute({});
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
@@ -1136,7 +1023,6 @@ describe("fast-mode triage", () => {
|
||||
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-FAST-004", "todo");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-FAST-004", "Spec review: APPROVE (auto-approve spec)");
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
}
|
||||
@@ -1528,29 +1414,11 @@ describe("TriageProcessor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("re-reads settings when review_spec runs so reviewer uses the latest validator model", async () => {
|
||||
it("runs deterministic validation without calling the spec reviewer", async () => {
|
||||
const taskId = "FN-001";
|
||||
const testRootDir = await createTriageFixtureRoot("fusion-triage-review-spec-");
|
||||
const testRootDir = await createTriageFixtureRoot("fusion-triage-plan-validation-");
|
||||
try {
|
||||
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
|
||||
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
|
||||
|
||||
const freshSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
validatorProvider: "zai",
|
||||
validatorModelId: "glm-5.1",
|
||||
};
|
||||
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue(freshSettings),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
@@ -1559,46 +1427,13 @@ describe("TriageProcessor", () => {
|
||||
});
|
||||
processor = new TriageProcessor(store, testRootDir);
|
||||
|
||||
mockReviewStep.mockResolvedValue({
|
||||
verdict: "APPROVE",
|
||||
review: "Looks good.",
|
||||
summary: "approved",
|
||||
});
|
||||
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
const failure = await (processor as any).validateGeneratedPrompt(
|
||||
taskId,
|
||||
promptPath,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus-4-6",
|
||||
projectValidatorProvider: "anthropic",
|
||||
projectValidatorModelId: "claude-opus-4-6",
|
||||
},
|
||||
false,
|
||||
"# Spec\n\n## Mission\n\nCurrent prompt",
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
expect(store.getSettings).toHaveBeenCalled();
|
||||
expect(mockReviewStep).toHaveBeenCalledWith(
|
||||
testRootDir,
|
||||
taskId,
|
||||
0,
|
||||
"Specification",
|
||||
"spec",
|
||||
"# Spec\n\nCurrent prompt",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
projectValidatorProvider: "zai",
|
||||
projectValidatorModelId: "glm-5.1",
|
||||
userComments: undefined,
|
||||
}),
|
||||
);
|
||||
expect(failure).toBeNull();
|
||||
expect(mockReviewStep).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(testRootDir);
|
||||
}
|
||||
@@ -1856,7 +1691,7 @@ describe("requirePlanApproval setting", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("approved triage recovery", () => {
|
||||
describe("specified triage recovery", () => {
|
||||
let rootDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -1914,7 +1749,7 @@ describe("approved triage recovery", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Auto-recovered approved specification stuck in planning — moved to todo",
|
||||
"Auto-recovered specified task stuck in planning — moved to todo",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2203,7 +2038,7 @@ Forbidden paths / non-goals:
|
||||
});
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Auto-recovered approved specification stuck in planning — awaiting manual approval",
|
||||
"Auto-recovered specified task stuck in planning — awaiting manual approval",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2723,7 +2558,7 @@ describe("taskCreate tool model inheritance", () => {
|
||||
});
|
||||
|
||||
describe("bounded recovery retries for triage", () => {
|
||||
it("requeues triage with backoff when the agent exits without calling fn_review_spec", async () => {
|
||||
it("requeues triage with backoff when the agent exits without writing PROMPT.md", async () => {
|
||||
const task = {
|
||||
id: "FN-202",
|
||||
description: "Test triage task",
|
||||
@@ -2768,7 +2603,7 @@ describe("taskCreate tool model inheritance", () => {
|
||||
}));
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-202",
|
||||
expect.stringContaining("Spec review not approved (fn_review_spec was never called)"),
|
||||
expect.stringContaining("Generated plan failed deterministic validation (PROMPT.md file not found or empty)"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3644,104 +3479,6 @@ describe("stale approval detection", () => {
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
|
||||
it("captures fingerprint on fn_review_spec APPROVE", async () => {
|
||||
const taskId = "FN-CAP";
|
||||
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
|
||||
|
||||
const comments = [
|
||||
{ id: "c1", text: "Feedback", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
} as Settings),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
comments,
|
||||
}),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
|
||||
mockReviewStep.mockResolvedValue({
|
||||
verdict: "APPROVE",
|
||||
review: "Looks good.",
|
||||
summary: "approved",
|
||||
});
|
||||
|
||||
const approvedCommentFingerprintRef = { current: "" };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
`.fusion/tasks/${taskId}/PROMPT.md`,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
approvedCommentFingerprintRef,
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
// Execute fn_review_spec — should capture fingerprint at APPROVE time
|
||||
await tool.execute({});
|
||||
|
||||
// Verify fingerprint was captured from the user comments at approval time
|
||||
expect(approvedCommentFingerprintRef.current).toBe("c1");
|
||||
});
|
||||
|
||||
it("fingerprint is empty string when fn_review_spec returns REVISE (no capture)", async () => {
|
||||
const taskId = "FN-REV";
|
||||
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
} as Settings),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...mockTaskDetail,
|
||||
id: taskId,
|
||||
comments: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
|
||||
mockReviewStep.mockResolvedValue({
|
||||
verdict: "REVISE",
|
||||
review: "Fix the spec.",
|
||||
summary: "needs work",
|
||||
});
|
||||
|
||||
const approvedCommentFingerprintRef = { current: "" };
|
||||
const tool = (processor as any).createReviewSpecTool(
|
||||
taskId,
|
||||
`.fusion/tasks/${taskId}/PROMPT.md`,
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
{ current: null },
|
||||
approvedCommentFingerprintRef,
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
await tool.execute({});
|
||||
|
||||
// Fingerprint should NOT be captured on REVISE
|
||||
expect(approvedCommentFingerprintRef.current).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause-abort status clearing (bug fix)", () => {
|
||||
|
||||
@@ -208,11 +208,7 @@ const DEFAULT_SCRIPTS: Record<MockSessionPurpose, MockScript> = {
|
||||
await mkdir(join(projectRoot, ".fusion", "tasks", taskId), { recursive: true });
|
||||
await writeFile(promptPath, content, "utf8");
|
||||
}
|
||||
if (ctx.tools.some((tool) => tool.name === "fn_review_spec")) {
|
||||
await ctx.invokeTool("fn_review_spec", {});
|
||||
} else {
|
||||
ctx.options.onText?.("APPROVE");
|
||||
}
|
||||
ctx.options.onText?.("Mock triage wrote PROMPT.md; workflow Plan Review owns optional approval.");
|
||||
},
|
||||
},
|
||||
reviewer: {
|
||||
|
||||
@@ -10381,10 +10381,10 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover triage tasks that already have an approved specification but were
|
||||
* Recover triage tasks that already have a written specification but were
|
||||
* left stuck in `status: "planning"` without an active triage session.
|
||||
*
|
||||
* This catches the mirror-image of executor recovery: the review completed,
|
||||
* This catches the mirror-image of executor recovery: planning completed,
|
||||
* but the final transition to `todo` / `awaiting-approval` never happened.
|
||||
*/
|
||||
async recoverApprovedTriageTasks(): Promise<number> {
|
||||
@@ -10406,27 +10406,26 @@ export class SelfHealingManager {
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!planningIds.has(t.id) &&
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS &&
|
||||
hasLatestSpecReviewApproval(t),
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS
|
||||
);
|
||||
|
||||
if (orphanedApproved.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${orphanedApproved.length} approved triage task(s) stuck in planning`);
|
||||
log.warn(`Found ${orphanedApproved.length} specified triage task candidate(s) stuck in planning`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of orphanedApproved) {
|
||||
log.log(`Recovering approved triage task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
|
||||
log.log(`Recovering specified triage task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
|
||||
const success = await recoverFn(task);
|
||||
if (success) recovered++;
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} approved triage task(s) out of planning`);
|
||||
log.log(`Recovered ${recovered} specified triage task(s) out of planning`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Approved triage recovery failed: ${errorMessage}`);
|
||||
log.error(`Specified triage recovery failed: ${errorMessage}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -10611,12 +10610,12 @@ export class SelfHealingManager {
|
||||
|
||||
/**
|
||||
* Recover triage tasks stuck in `status: "planning"` whose agent session
|
||||
* died before producing an approved spec.
|
||||
* died before producing a recoverable spec.
|
||||
*
|
||||
* These tasks fall through two cracks:
|
||||
* - The stuck task detector only monitors tasks with active tracked sessions.
|
||||
* If the session crashed or was never started, the task is never tracked.
|
||||
* - `recoverApprovedTriageTasks` only handles tasks with an approved spec.
|
||||
* - `recoverApprovedTriageTasks` only handles tasks with a valid written PROMPT.md.
|
||||
*
|
||||
* Recovery clears the status back to `null` so the next triage poll picks
|
||||
* them up for a fresh planning attempt.
|
||||
@@ -10637,13 +10636,12 @@ export class SelfHealingManager {
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!planningIds.has(t.id) &&
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS &&
|
||||
!hasLatestSpecReviewApproval(t),
|
||||
now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS
|
||||
);
|
||||
|
||||
if (orphaned.length === 0) return 0;
|
||||
|
||||
log.warn(`Found ${orphaned.length} orphaned planning triage task(s) without approval`);
|
||||
log.warn(`Found ${orphaned.length} orphaned planning triage task(s) without a recoverable prompt`);
|
||||
|
||||
let recovered = 0;
|
||||
for (const task of orphaned) {
|
||||
@@ -11271,16 +11269,6 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
function hasLatestSpecReviewApproval(task: Task): boolean {
|
||||
for (let i = task.log.length - 1; i >= 0; i--) {
|
||||
const action = task.log[i]?.action ?? "";
|
||||
if (action.startsWith("Spec review: ")) {
|
||||
return action === "Spec review: APPROVE";
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTaskWorkComplete(task: Task): boolean {
|
||||
if (task.steps.length === 0) return false;
|
||||
return task.steps.every((step) => step.status === "done" || step.status === "skipped");
|
||||
|
||||
@@ -488,7 +488,11 @@ export function buildStepPrompt(
|
||||
);
|
||||
}
|
||||
|
||||
parts.push("After completing this step, commit your changes and call fn_task_done(). Do NOT proceed to subsequent steps.");
|
||||
/*
|
||||
* FNXC:WorkflowStepControl 2026-06-29-01:52:
|
||||
* Graph-owned step execution must not ask the per-step worker to operate board lifecycle tools. Step sessions do not receive fn_task_done; they finish the scoped step and return so the workflow graph can mark the step done or route review/rework.
|
||||
*/
|
||||
parts.push("After completing this step, commit your changes, then stop. Do NOT call task lifecycle tools and do NOT proceed to subsequent steps; the workflow graph records completion.");
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -616,7 +620,7 @@ export function buildReducedStepPrompt(taskDetail: TaskDetail, stepIndex: number
|
||||
"IMPORTANT: Your previous attempt hit the context window limit.",
|
||||
"Do NOT repeat work that's already been done.",
|
||||
"Check git status and git log to see what's been committed.",
|
||||
"Complete the remaining work and call fn_task_done().",
|
||||
"Complete the remaining step work, commit your changes, then stop. The workflow graph records completion.",
|
||||
];
|
||||
|
||||
return parts.join("\n").replace(/\n{3,}/g, "\n\n"); // Collapse multiple blank lines
|
||||
|
||||
@@ -81,7 +81,6 @@ import {
|
||||
extractRuntimeHint,
|
||||
resolvePlanningSessionModel,
|
||||
} from "./agent-session-helpers.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "./spec-validation/task-document-references.js";
|
||||
import {
|
||||
@@ -98,7 +97,7 @@ import {
|
||||
} from "./agent-instructions.js";
|
||||
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { planLog, reviewerLog, formatError } from "./logger.js";
|
||||
import { planLog, formatError } from "./logger.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import {
|
||||
isUsageLimitError,
|
||||
@@ -529,7 +528,7 @@ export class TriageProcessor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a triage task whose spec was already approved but the final
|
||||
* Recover a triage task whose PROMPT.md was already written but the final
|
||||
* handoff out of `status: "planning"` never completed.
|
||||
*/
|
||||
async recoverApprovedTask(task: Task): Promise<boolean> {
|
||||
@@ -538,11 +537,7 @@ export class TriageProcessor {
|
||||
}
|
||||
|
||||
if (task.paused === true || task.userPaused === true) {
|
||||
planLog.log(`${task.id} approved-spec recovery skipped — task is paused`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasLatestSpecReviewApproval(task)) {
|
||||
planLog.log(`${task.id} planning recovery skipped — task is paused`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -553,19 +548,25 @@ export class TriageProcessor {
|
||||
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
const written = await readFile(promptPath, "utf-8").catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: failed to read PROMPT.md during approved-spec recovery (${promptPath}): ${msg}`);
|
||||
planLog.warn(`${task.id}: failed to read PROMPT.md during planning recovery (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
if (!written.trim()) {
|
||||
planLog.warn(`${task.id} approved-spec recovery skipped — PROMPT.md missing or empty`);
|
||||
planLog.warn(`${task.id} planning recovery skipped — PROMPT.md missing or empty`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const deterministicSpecFailure = await this.validateGeneratedPrompt(task.id, written);
|
||||
if (deterministicSpecFailure) {
|
||||
planLog.warn(`${task.id} planning recovery skipped — PROMPT.md failed deterministic validation (${deterministicSpecFailure})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.finalizeApprovedTask(task, written, settings, {
|
||||
recoveryLogAction: approvalRequired
|
||||
? "Auto-recovered approved specification stuck in planning — awaiting manual approval"
|
||||
: "Auto-recovered approved specification stuck in planning — moved to todo",
|
||||
? "Auto-recovered specified task stuck in planning — awaiting manual approval"
|
||||
: "Auto-recovered specified task stuck in planning — moved to todo",
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -623,15 +624,13 @@ export class TriageProcessor {
|
||||
return task;
|
||||
});
|
||||
|
||||
if (hasLatestSpecReviewApproval(freshTask)) {
|
||||
const recovered = await this.recoverApprovedTask(freshTask).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: approved-spec recovery failed during stuck-detector ${context} cleanup: ${msg}`);
|
||||
return false;
|
||||
});
|
||||
if (recovered) {
|
||||
return;
|
||||
}
|
||||
const recovered = await this.recoverApprovedTask(freshTask).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: planning recovery failed during stuck-detector ${context} cleanup: ${msg}`);
|
||||
return false;
|
||||
});
|
||||
if (recovered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxStuckSettings = await this.store.getSettings().catch((err: unknown) => {
|
||||
@@ -843,15 +842,9 @@ export class TriageProcessor {
|
||||
/**
|
||||
* Specify a triage task by spawning an AI agent to generate a PROMPT.md.
|
||||
*
|
||||
* After the agent writes the PROMPT.md, it calls `fn_review_spec()` to spawn
|
||||
* an independent reviewer agent that evaluates the specification quality.
|
||||
* The review loop works as follows:
|
||||
* - **APPROVE**: the spec is accepted and the task moves to `todo`
|
||||
* - **REVISE**: the agent revises the spec and calls `fn_review_spec()` again.
|
||||
* If the agent finishes without getting APPROVE, the task is NOT moved to
|
||||
* `todo` — a post-session gate requires an explicit APPROVE verdict.
|
||||
* - **RETHINK**: the conversation rewinds to a pre-planning checkpoint
|
||||
* and the agent starts over with a fundamentally different approach.
|
||||
* After the agent writes PROMPT.md, triage runs deterministic spec hygiene
|
||||
* checks and finalizes. Workflow Plan Review is the single optional AI plan
|
||||
* quality gate before execution; triage does not inject a separate review tool.
|
||||
*/
|
||||
async specifyTask(task: Task): Promise<void> {
|
||||
if (this.processing.has(task.id)) return;
|
||||
@@ -874,7 +867,6 @@ export class TriageProcessor {
|
||||
// FN-6236: this is the only legacy executionMode="fast" bridge. Downstream
|
||||
// triage policy reads resolved workflow flags instead of the raw string.
|
||||
const leanPlanning = settings.leanPlanning === true || isFast;
|
||||
const autoApproveSpec = settings.autoApproveSpec === true || isFast;
|
||||
|
||||
const agentWork = async () => {
|
||||
// Set status only after the semaphore slot has been acquired, so
|
||||
@@ -901,18 +893,6 @@ export class TriageProcessor {
|
||||
},
|
||||
});
|
||||
|
||||
// Mutable ref — populated after createFnAgent, tools access lazily via closure
|
||||
const sessionRef: { current: AgentSession | null } = { current: null };
|
||||
// Checkpoint for RETHINK rewind — captured lazily on first fn_review_spec call
|
||||
const checkpointRef: { current: string | null } = { current: null };
|
||||
// Track the last spec review verdict for post-session enforcement
|
||||
const specReviewVerdictRef: { current: ReviewVerdict | null } = {
|
||||
current: null,
|
||||
};
|
||||
// Track the user-comment fingerprint at the time of APPROVE for stale-approval detection
|
||||
const approvedCommentFingerprintRef: { current: string } = {
|
||||
current: "",
|
||||
};
|
||||
// Track subtasks created during triage when breakIntoSubtasks was requested.
|
||||
const createdSubtasksRef: { current: string[] } = { current: [] };
|
||||
|
||||
@@ -968,16 +948,6 @@ export class TriageProcessor {
|
||||
createListAgentsTool(this.options.agentStore),
|
||||
createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }),
|
||||
] : []),
|
||||
this.createReviewSpecTool(
|
||||
task.id,
|
||||
promptPath,
|
||||
sessionRef,
|
||||
checkpointRef,
|
||||
specReviewVerdictRef,
|
||||
approvedCommentFingerprintRef,
|
||||
settings,
|
||||
autoApproveSpec,
|
||||
),
|
||||
];
|
||||
|
||||
let triageRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
|
||||
@@ -1138,9 +1108,6 @@ export class TriageProcessor {
|
||||
"triage",
|
||||
);
|
||||
|
||||
// Make session available to fn_review_spec tool (for RETHINK rewind)
|
||||
sessionRef.current = session;
|
||||
|
||||
// Register session so the global pause listener can terminate it
|
||||
this.activeSessions.set(task.id, session);
|
||||
|
||||
@@ -1268,151 +1235,10 @@ export class TriageProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Before swapping to the fallback model, give the primary one more
|
||||
// shot with a pointed reminder. The model may have written PROMPT.md
|
||||
// but stopped without calling fn_review_spec — that's recoverable
|
||||
// with a nudge, no need to discard the session and pay the cold-start
|
||||
// tax of a new triage on a different model.
|
||||
const MAX_REVIEW_REMINDERS = 2;
|
||||
let reviewReminders = 0;
|
||||
while (
|
||||
specReviewVerdictRef.current !== "APPROVE" &&
|
||||
!this.pauseAborted.has(task.id) &&
|
||||
!this.stuckAborted.has(task.id) &&
|
||||
createdSubtasksRef.current.length === 0 &&
|
||||
reviewReminders < MAX_REVIEW_REMINDERS
|
||||
) {
|
||||
reviewReminders += 1;
|
||||
const verdictDesc =
|
||||
specReviewVerdictRef.current === null
|
||||
? "fn_review_spec was never called"
|
||||
: `verdict was ${specReviewVerdictRef.current}`;
|
||||
planLog.warn(
|
||||
`${task.id} primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Primary planning model returned without APPROVE (${verdictDesc}) — reminder ${reviewReminders}/${MAX_REVIEW_REMINDERS}`,
|
||||
);
|
||||
const reminder =
|
||||
specReviewVerdictRef.current === null
|
||||
? "You wrote the PROMPT.md but did not call `fn_review_spec()`. Call `fn_review_spec()` now to validate the spec. Do not stop until the verdict is APPROVE."
|
||||
: `Spec review verdict was ${specReviewVerdictRef.current}. Address the feedback, rewrite the PROMPT.md as needed, and call \`fn_review_spec()\` again. Do not stop until the verdict is APPROVE.`;
|
||||
stuckDetector?.recordActivity(task.id);
|
||||
await promptWithFallback(session, reminder);
|
||||
checkSessionError(session);
|
||||
if (this.pauseAborted.has(task.id) || this.stuckAborted.has(task.id)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const planningFallbackProvider = settings.planningFallbackProvider;
|
||||
const planningFallbackModelId = settings.planningFallbackModelId;
|
||||
const canRetryWithPlanningFallback =
|
||||
specReviewVerdictRef.current !== "APPROVE" &&
|
||||
planningFallbackProvider &&
|
||||
planningFallbackModelId &&
|
||||
modelDesc !== `${planningFallbackProvider}/${planningFallbackModelId}`;
|
||||
|
||||
if (canRetryWithPlanningFallback) {
|
||||
const verdictDesc =
|
||||
specReviewVerdictRef.current === null
|
||||
? "fn_review_spec was never called"
|
||||
: `verdict was ${specReviewVerdictRef.current}`;
|
||||
const fallbackDesc = `${planningFallbackProvider}/${planningFallbackModelId}`;
|
||||
planLog.warn(
|
||||
`${task.id} primary planning model produced no approved spec (${verdictDesc}) — retrying with fallback ${fallbackDesc}`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Primary planning model produced no approved spec (${verdictDesc}) — retrying with fallback ${fallbackDesc}`,
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:TokenAnalytics 2026-06-27-14:52:
|
||||
Planning fallback replaces the primary triage session, so record the primary model's token delta before disposal; the shared finally records the fallback session separately.
|
||||
*/
|
||||
await this.recordTriageSessionTokenUsage(task.id, session, { agentId: triageRunContext.agentId });
|
||||
session.dispose();
|
||||
this.activeSessions.delete(task.id);
|
||||
stuckDetector?.untrackTask(task.id);
|
||||
specReviewVerdictRef.current = null;
|
||||
approvedCommentFingerprintRef.current = "";
|
||||
|
||||
const fallbackResult = await createResolvedAgentSession({
|
||||
sessionPurpose: "triage",
|
||||
runtimeHint: triageRuntimeHint,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: triageSystemPromptFinal,
|
||||
systemPromptLayers: triageLayers,
|
||||
tools: "coding",
|
||||
customTools,
|
||||
onText: agentLogger.onText,
|
||||
onThinking: agentLogger.onThinking,
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: planningFallbackProvider,
|
||||
defaultModelId: planningFallbackModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
runAuditor,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:18: Fallback triage uses the same resolved MCP forwarding contract as the primary planning session so model fallback does not silently drop configured servers.
|
||||
mcpServers: (await resolveMcpServersForStore(this.store)).servers,
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "triage",
|
||||
label: "triage",
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
}),
|
||||
});
|
||||
|
||||
session = fallbackResult.session;
|
||||
const fallbackModelDesc = describeModel(session);
|
||||
planLog.log(`${task.id}: using fallback model ${fallbackModelDesc}`);
|
||||
await this.store.logEntry(task.id, `Triage using fallback model: ${fallbackModelDesc}`);
|
||||
await this.store.appendAgentLog(
|
||||
task.id,
|
||||
`Triage using fallback model: ${fallbackModelDesc}`,
|
||||
"text",
|
||||
undefined,
|
||||
"triage",
|
||||
);
|
||||
|
||||
sessionRef.current = session;
|
||||
this.activeSessions.set(task.id, session);
|
||||
stuckDetector?.trackTask(task.id, session);
|
||||
stuckDetector?.recordActivity(task.id);
|
||||
|
||||
await promptWithFallback(
|
||||
session,
|
||||
agentPrompt,
|
||||
imageContents.length > 0 ? { images: imageContents } : undefined,
|
||||
);
|
||||
checkSessionError(session);
|
||||
|
||||
if (createdSubtasksRef.current.length > 0) {
|
||||
const childTaskIds = createdSubtasksRef.current.join(", ");
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Converted into subtasks: ${childTaskIds}`,
|
||||
);
|
||||
// FN-5129 / FN-5131: split-close must unlink lineage children when deleting the parent.
|
||||
await this.store.deleteTask(task.id, {
|
||||
removeLineageReferences: true,
|
||||
auditContext: {
|
||||
agentId: task.assignedAgentId ?? "triage",
|
||||
runId: generateSyntheticRunId("triage-delete", task.id),
|
||||
},
|
||||
});
|
||||
planLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
FNXC:PlanReview 2026-06-29-01:52:
|
||||
Workflow Plan Review is the single operator-controlled AI plan gate. Triage must not remind agents to call fn_review_spec or retry planning only because that legacy tool was not approved; the graph runs optional Plan Review before parse/execution and routes failed plans back to triage.
|
||||
*/
|
||||
|
||||
const written = await readFile(
|
||||
join(this.rootDir, promptPath),
|
||||
@@ -1424,7 +1250,7 @@ export class TriageProcessor {
|
||||
});
|
||||
|
||||
// FN-5220: planning agents that emit a `DUPLICATE: FN-NNNN` redirect
|
||||
// do not call `fn_review_spec()`; short-circuit the APPROVE gate.
|
||||
// short-circuit normal spec finalization.
|
||||
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {
|
||||
isReplan,
|
||||
feedback,
|
||||
@@ -1433,15 +1259,8 @@ export class TriageProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-session APPROVE gate: only advance to todo when the spec
|
||||
// reviewer explicitly approved. Any other verdict (REVISE,
|
||||
// RETHINK, UNAVAILABLE) or a missing review (null) stays in triage
|
||||
// and is retried with bounded backoff instead of immediately failing.
|
||||
if (specReviewVerdictRef.current !== "APPROVE") {
|
||||
const verdictDesc =
|
||||
specReviewVerdictRef.current === null
|
||||
? "fn_review_spec was never called"
|
||||
: `verdict was ${specReviewVerdictRef.current}`;
|
||||
const deterministicSpecFailure = await this.validateGeneratedPrompt(task.id, written);
|
||||
if (deterministicSpecFailure) {
|
||||
const decision = computeRecoveryDecision({
|
||||
recoveryRetryCount: task.recoveryRetryCount,
|
||||
nextRecoveryAt: task.nextRecoveryAt,
|
||||
@@ -1451,7 +1270,7 @@ export class TriageProcessor {
|
||||
const attempt = decision.nextState.recoveryRetryCount;
|
||||
const delay = formatDelay(decision.delayMs);
|
||||
const retryMessage =
|
||||
`Spec review not approved (${verdictDesc}) — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}.`;
|
||||
`Generated plan failed deterministic validation (${deterministicSpecFailure}) — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}.`;
|
||||
planLog.warn(`${task.id} ${retryMessage}`);
|
||||
await this.store.logEntry(task.id, retryMessage);
|
||||
const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null;
|
||||
@@ -1465,10 +1284,10 @@ export class TriageProcessor {
|
||||
}
|
||||
|
||||
const failureMessage =
|
||||
`Specification failed after ${MAX_RECOVERY_RETRIES} unapproved spec reviews (${verdictDesc}). ` +
|
||||
`Specification failed deterministic validation after ${MAX_RECOVERY_RETRIES} retries (${deterministicSpecFailure}). ` +
|
||||
"Retry after adjusting the task prompt or model.";
|
||||
planLog.log(
|
||||
`${task.id} spec review not approved (${verdictDesc}) — retry budget exhausted`,
|
||||
`${task.id} deterministic spec validation failed (${deterministicSpecFailure}) — retry budget exhausted`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
@@ -1483,24 +1302,6 @@ export class TriageProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-approval detection: re-read the task to check if new user
|
||||
// comments arrived after the spec was approved. If the comment
|
||||
// fingerprint changed, the approval is stale and the task needs
|
||||
// re-planning.
|
||||
const latestTask = await this.store.getTask(task.id);
|
||||
const currentFingerprint = computeUserCommentFingerprint(latestTask.comments);
|
||||
if (currentFingerprint !== approvedCommentFingerprintRef.current) {
|
||||
planLog.log(
|
||||
`${task.id} stale approval detected — user comments changed after approval, triggering re-planning`,
|
||||
);
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Spec approval invalidated — new user comments arrived after approval. Task needs re-planning.",
|
||||
);
|
||||
await this.store.updateTask(task.id, { status: "needs-replan" });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.finalizeApprovedTask(task, written, settings, {
|
||||
isReplan,
|
||||
feedback,
|
||||
@@ -1939,274 +1740,37 @@ export class TriageProcessor {
|
||||
return [taskList, taskSearch, taskShow, taskCreate];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `fn_review_spec` tool for the triage agent.
|
||||
*
|
||||
* Spawns an independent reviewer agent to evaluate the generated PROMPT.md.
|
||||
* Verdict handling:
|
||||
* - **APPROVE**: returns "APPROVE" — the triage agent's work is done.
|
||||
* - **REVISE**: returns the review feedback. The triage agent must fix the
|
||||
* PROMPT.md and call `fn_review_spec` again. A post-session gate in
|
||||
* `specifyTask()` prevents moving to `todo` if the last verdict is REVISE.
|
||||
* - **RETHINK**: rewinds the conversation to a pre-planning checkpoint
|
||||
* using `session.navigateTree()`. Returns a re-prompt instructing the agent
|
||||
* to take a fundamentally different approach.
|
||||
*/
|
||||
private createReviewSpecTool(
|
||||
taskId: string,
|
||||
promptPath: string,
|
||||
sessionRef: { current: AgentSession | null },
|
||||
checkpointRef: { current: string | null },
|
||||
specReviewVerdictRef: { current: ReviewVerdict | null },
|
||||
approvedCommentFingerprintRef: { current: string },
|
||||
_settings: {
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
validatorProvider?: string;
|
||||
validatorModelId?: string;
|
||||
},
|
||||
skipSpecReview: boolean,
|
||||
): ToolDefinition {
|
||||
const store = this.store;
|
||||
const rootDir = this.rootDir;
|
||||
const options = this.options;
|
||||
private async validateGeneratedPrompt(taskId: string, promptContent: string): Promise<string | null> {
|
||||
/*
|
||||
FNXC:PlanReview 2026-06-29-01:52:
|
||||
Triage owns only deterministic PROMPT.md hygiene. AI plan quality review is graph-owned by the optional Plan Review step, so this helper must never call reviewer agents or require a fn_review_spec APPROVE verdict.
|
||||
*/
|
||||
if (!promptContent.trim()) {
|
||||
return "PROMPT.md file not found or empty";
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fn_review_spec",
|
||||
label: "Review Specification",
|
||||
description:
|
||||
"Spawn a reviewer agent to evaluate the generated PROMPT.md specification. " +
|
||||
"Returns APPROVE, REVISE, RETHINK, or UNAVAILABLE. " +
|
||||
"Call after writing the PROMPT.md.",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
reviewerLog.log(`${taskId}: spec review requested`);
|
||||
await store.logEntry(taskId, "Spec review requested");
|
||||
const danglingRefs = await detectDanglingTaskDocReferences(promptContent, {
|
||||
rootDir: this.rootDir,
|
||||
taskId,
|
||||
});
|
||||
if (danglingRefs.length > 0) {
|
||||
const diagnostic = formatDanglingDiagnostic(danglingRefs);
|
||||
planLog.warn(`${taskId}: ${diagnostic}`);
|
||||
await this.store.logEntry(taskId, "Generated plan validation failed: dangling task-document references");
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
// Capture checkpoint lazily on first call — at this point the session
|
||||
// has already started and has a valid conversation state to rewind to.
|
||||
if (!checkpointRef.current && sessionRef.current) {
|
||||
checkpointRef.current =
|
||||
sessionRef.current.sessionManager.getLeafId() ?? null;
|
||||
}
|
||||
const evidenceGaps = detectExternalIntegrationEvidenceGaps({
|
||||
promptContent,
|
||||
});
|
||||
if (evidenceGaps.length > 0) {
|
||||
const diagnostic = formatExternalIntegrationEvidenceDiagnostic(evidenceGaps);
|
||||
planLog.warn(`${taskId}: ${diagnostic}`);
|
||||
await this.store.logEntry(taskId, "Generated plan validation failed: external-integration evidence gaps");
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read the generated PROMPT.md from disk
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const promptContent = await readFile(
|
||||
join(rootDir, promptPath),
|
||||
"utf-8",
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${taskId}: failed to read PROMPT.md for fn_review_spec (${promptPath}): ${msg}`);
|
||||
return "";
|
||||
});
|
||||
|
||||
if (!promptContent) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "UNAVAILABLE — PROMPT.md file not found or empty. Write the specification first, then call fn_review_spec.",
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const danglingRefs = await detectDanglingTaskDocReferences(promptContent, {
|
||||
rootDir,
|
||||
taskId,
|
||||
});
|
||||
if (danglingRefs.length > 0) {
|
||||
const diagnostic = formatDanglingDiagnostic(danglingRefs);
|
||||
specReviewVerdictRef.current = "REVISE";
|
||||
planLog.warn(`${taskId}: ${diagnostic}`);
|
||||
await store.logEntry(taskId, "Spec review: REVISE (dangling task-document references)");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: diagnostic }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const evidenceGaps = detectExternalIntegrationEvidenceGaps({
|
||||
promptContent,
|
||||
});
|
||||
if (evidenceGaps.length > 0) {
|
||||
const diagnostic = formatExternalIntegrationEvidenceDiagnostic(evidenceGaps);
|
||||
specReviewVerdictRef.current = "REVISE";
|
||||
planLog.warn(`${taskId}: ${diagnostic}`);
|
||||
await store.logEntry(taskId, "Spec review: REVISE (external-integration evidence gaps)");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: diagnostic }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-read task detail to get latest user comments
|
||||
const currentDetail = await store.getTask(taskId);
|
||||
const currentUserComments = (currentDetail.comments || []).filter(
|
||||
(c: any) => c.author === "user",
|
||||
);
|
||||
|
||||
if (skipSpecReview) {
|
||||
specReviewVerdictRef.current = "APPROVE";
|
||||
approvedCommentFingerprintRef.current = currentUserComments.length > 0
|
||||
? computeUserCommentFingerprint(currentUserComments)
|
||||
: "";
|
||||
planLog.log(`${taskId}: spec review auto-approved (auto-approve spec)`);
|
||||
await store.logEntry(taskId, "Spec review: APPROVE (auto-approve spec)");
|
||||
return { content: [{ type: "text" as const, text: "APPROVE" }], details: {} };
|
||||
}
|
||||
|
||||
// Re-read settings at review time so long-lived triage sessions pick up
|
||||
// model changes made after the session started. Merge per-task effective
|
||||
// workflow settings (U3, KTD-3) so the validator model-lane reads below
|
||||
// pick up workflow values. Behavior-inert when nothing is customized.
|
||||
const currentSettings = await mergeEffectiveSettings(store, currentDetail, await store.getSettings());
|
||||
|
||||
// Spec reviewer runs via semaphore.runNested so it transiently
|
||||
// bumps activeCount for honest observability while bypassing the
|
||||
// wait queue (no fairness regression at low maxConcurrent). See
|
||||
// concurrency.ts:runNested for the contract.
|
||||
const sem = options.semaphore;
|
||||
const invokeReviewer = () => reviewStep(
|
||||
rootDir,
|
||||
taskId,
|
||||
0,
|
||||
"Specification",
|
||||
"spec",
|
||||
promptContent,
|
||||
undefined,
|
||||
{
|
||||
onText: (delta) => options.onAgentText?.(taskId, delta),
|
||||
// Execution defaults as final fallback
|
||||
defaultProvider: currentSettings.defaultProvider,
|
||||
defaultModelId: currentSettings.defaultModelId,
|
||||
// FNXC:ModelResolution 2026-06-28-17:10: Spec review is a reviewer/validator lane, so triage must forward the task reviewer override to reviewStep instead of letting project/global settings mask a per-task validator model.
|
||||
taskValidatorProvider: currentDetail.validatorModelProvider,
|
||||
taskValidatorModelId: currentDetail.validatorModelId,
|
||||
// Project-level validator override
|
||||
projectValidatorProvider: currentSettings.validatorProvider,
|
||||
projectValidatorModelId: currentSettings.validatorModelId,
|
||||
// Project-level validator fallback
|
||||
projectValidatorFallbackProvider: currentSettings.validatorFallbackProvider,
|
||||
projectValidatorFallbackModelId: currentSettings.validatorFallbackModelId,
|
||||
// FNXC:SpecReviewerFallback 2026-06-23-08:50:
|
||||
// Spec review must inherit global/default fallback reviewer model settings when no validator-specific fallback is configured, plus the project settings/prompt payload that reviewer sessions use for memory and custom prompt behavior.
|
||||
fallbackProvider: currentSettings.fallbackProvider,
|
||||
fallbackModelId: currentSettings.fallbackModelId,
|
||||
// Global validator lane
|
||||
globalValidatorProvider: currentSettings.validatorGlobalProvider,
|
||||
globalValidatorModelId: currentSettings.validatorGlobalModelId,
|
||||
// Project-level default override (fallback before execution defaults)
|
||||
projectDefaultOverrideProvider: currentSettings.defaultProviderOverride,
|
||||
projectDefaultOverrideModelId: currentSettings.defaultModelIdOverride,
|
||||
defaultThinkingLevel: currentSettings.defaultThinkingLevel,
|
||||
store,
|
||||
taskId,
|
||||
task: currentDetail,
|
||||
userComments: currentUserComments.length > 0 ? currentUserComments : undefined,
|
||||
agentPrompts: currentSettings.agentPrompts,
|
||||
agentStore: this.options.agentStore,
|
||||
rootDir,
|
||||
settings: currentSettings,
|
||||
// Track the spec reviewer's session under this task so it's
|
||||
// disposed alongside the main triage session on global pause.
|
||||
onSessionCreated: (s) => this.registerSubagentSession(taskId, s),
|
||||
onSessionEnded: (s) => this.unregisterSubagentSession(taskId, s),
|
||||
},
|
||||
);
|
||||
const result = sem
|
||||
? await sem.runNested(invokeReviewer)
|
||||
: await invokeReviewer();
|
||||
|
||||
// Track verdict for post-session enforcement
|
||||
specReviewVerdictRef.current = result.verdict;
|
||||
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Spec review: ${result.verdict}`,
|
||||
result.summary,
|
||||
);
|
||||
reviewerLog.log(`${taskId}: spec review → ${result.verdict}`);
|
||||
|
||||
let text: string;
|
||||
switch (result.verdict) {
|
||||
case "APPROVE":
|
||||
// Capture the user-comment fingerprint at approval time for stale-approval detection
|
||||
approvedCommentFingerprintRef.current = computeUserCommentFingerprint(currentUserComments);
|
||||
text = "APPROVE";
|
||||
break;
|
||||
case "REVISE":
|
||||
text = `REVISE — fix the issues below, rewrite the PROMPT.md, and call fn_review_spec() again.\n\n${result.review}`;
|
||||
break;
|
||||
case "RETHINK": {
|
||||
// Rewind conversation to pre-planning checkpoint
|
||||
const checkpointId = checkpointRef.current;
|
||||
if (checkpointId && sessionRef.current) {
|
||||
try {
|
||||
await sessionRef.current.navigateTree(checkpointId, {
|
||||
summarize: false,
|
||||
});
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — session rewound to checkpoint ${checkpointId}`,
|
||||
);
|
||||
} catch (rewindErr: unknown) {
|
||||
const msg = rewindErr instanceof Error ? rewindErr.message : String(rewindErr);
|
||||
planLog.warn(`${taskId}: RETHINK navigateTree rewind failed, falling back to branchWithSummary: ${msg}`);
|
||||
// Fallback to branchWithSummary
|
||||
try {
|
||||
sessionRef.current.sessionManager.branchWithSummary(
|
||||
checkpointId,
|
||||
`RETHINK: ${result.summary || "Approach rejected by reviewer"}`,
|
||||
);
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — branched from checkpoint ${checkpointId}`,
|
||||
);
|
||||
} catch (branchErr: unknown) {
|
||||
const branchErrMessage = branchErr instanceof Error ? branchErr.message : String(branchErr);
|
||||
planLog.error(
|
||||
`${taskId}: RETHINK session rewind failed: ${branchErrMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
planLog.log(
|
||||
`${taskId}: RETHINK — no session checkpoint, skipping rewind`,
|
||||
);
|
||||
}
|
||||
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`RETHINK: spec rewound — session checkpoint ${checkpointId || "N/A"}`,
|
||||
result.summary,
|
||||
);
|
||||
text = `RETHINK\n\nYour specification was rejected. Here is why:\n\n${result.review}\n\nTake a completely different approach to writing this specification. Do NOT repeat the rejected strategy.`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
text = "UNAVAILABLE — reviewer did not produce a usable verdict.";
|
||||
}
|
||||
|
||||
return { content: [{ type: "text" as const, text }], details: {} };
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
reviewerLog.error(`${taskId}: spec review failed: ${errorMessage}`);
|
||||
await store.logEntry(taskId, `Spec review failed: ${errorMessage}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `UNAVAILABLE — reviewer error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
private async tryFinalizeExplicitDuplicateMarker(
|
||||
@@ -2554,7 +2118,7 @@ export class TriageProcessor {
|
||||
latestTransitionTask = await this.store.getTask(task.id);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`);
|
||||
planLog.warn(`${task.id}: failed to re-read task before planning transition (${message}); proceeding with original task snapshot`);
|
||||
latestTransitionTask = task;
|
||||
}
|
||||
try {
|
||||
@@ -2614,7 +2178,7 @@ export class TriageProcessor {
|
||||
task.id,
|
||||
"Specification approved but task is paused — leaving in triage, will resume on unpause",
|
||||
);
|
||||
planLog.log(`${task.id} approved specification paused — leaving in triage, will resume on unpause`);
|
||||
planLog.log(`${task.id} specified task paused — leaving in triage, will resume on unpause`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2712,16 +2276,6 @@ function shouldReplaceTaskTitleFromPrompt(task: Task, promptDeclaredTitle: strin
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasLatestSpecReviewApproval(task: Task): boolean {
|
||||
for (let i = task.log.length - 1; i >= 0; i--) {
|
||||
const action = task.log[i]?.action ?? "";
|
||||
if (action.startsWith("Spec review: ")) {
|
||||
return action === "Spec review: APPROVE";
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Content read from an attachment file for inlining in the prompt. */
|
||||
export interface AttachmentContent {
|
||||
originalName: string;
|
||||
|
||||
Reference in New Issue
Block a user