/* eslint-disable @typescript-eslint/no-explicit-any */ import type { TaskStore, Task, TaskDetail, TaskAttachment, Settings, } from "@fusion/core"; import { buildTriageMemoryInstructions, resolveAgentPrompt, resolvePersistAgentThinkingLog, sortTasksByPriorityThenAgeAndId, resolveAgentMemoryInclusionMode, } from "@fusion/core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai"; import type { ToolDefinition, AgentSession, } from "@mariozechner/pi-coding-agent"; import { describeModel, promptWithFallback } from "./pi.js"; import { createResolvedAgentSession, extractRuntimeHint, resolvePlanningSessionModel, } from "./agent-session-helpers.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js"; import { AgentLogger } from "./agent-logger.js"; import { resolveAgentInstructions, resolveAgentInstructionsWithRatings, buildPluginPromptSection, } 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 { isUsageLimitError, checkSessionError, type UsageLimitPauser, } from "./usage-limit-detector.js"; import { isTransientError, isSilentTransientError } from "./transient-error-detector.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js"; import type { StuckTaskDetector } from "./stuck-task-detector.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { createAgentTask, createDelegateTaskTool, createListAgentsTool, createMemoryTools, createResearchTools, createWebFetchTool, createTaskDocumentReadTool, createTaskDocumentWriteTool, } from "./agent-tools.js"; import { getResearchGuidanceForSurface, isResearchToolSurfaceEnabled, } from "./tool-availability.js"; export const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board. ## Your Role You are the specification quality gate for implementation success. Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation. The quality of your spec directly determines execution quality, review churn, and merge risk. ## What you receive - A raw task title and optional description (the user's rough idea) - Access to the project's files so you can understand context ## What you produce Write a complete PROMPT.md specification to the given path using the write tool. ## PROMPT.md Format Follow this structure exactly: \`\`\`markdown # Task: {ID} - {Name} **Created:** {YYYY-MM-DD} **Size:** {S | M | L} ## Review Level: {0-3} ({None | Plan Only | Plan and Code | Full}) **Assessment:** {1-2 sentences explaining the score} **Score:** {N}/8 — Blast radius: {N}, Pattern novelty: {N}, Security: {N}, Reversibility: {N} ## Mission {One paragraph: what you're building and why it matters} ## Dependencies - **None** {OR} - **Task:** {ID} ({what must be complete}) ## Context to Read First {List specific files the worker should read before starting — only what's needed} ## File Scope {List files/directories the task will create or modify — be specific} - \`path/to/file.ext\` - \`path/to/directory/*\` ## Steps ### Step 0: Preflight - [ ] Required files and paths exist - [ ] Dependencies satisfied ### Step 1: {Name} - [ ] {Specific, verifiable outcome} - [ ] {Specific, verifiable outcome} - [ ] Run targeted tests for changed files **Artifacts:** - \`path/to/file\` (new | modified) ### Step {N-1}: Testing & Verification > ZERO test failures allowed. Full test suite as quality gate. > If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope, make those fixes as part of this task. - [ ] Run lint check (\`pnpm lint\`) - [ ] Run full test suite - [ ] Run project typecheck if available - [ ] Fix all failures - [ ] Build passes ### Step {N}: Documentation & Delivery - [ ] Update relevant documentation - [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...) - [ ] Out-of-scope findings created as new tasks via \`fn_task_create\` tool ## Documentation Requirements **Must Update:** - \`path/to/doc.md\` — {what to add/change} **Check If Affected:** - \`path/to/doc.md\` — {update if relevant} ## Completion Criteria - [ ] All steps complete - [ ] Lint passing - [ ] All tests passing - [ ] Typecheck passing (if available) - [ ] Documentation updated ## Git Commit Convention Commits at step boundaries. All commits include the task ID: - **Step completion:** \`feat({ID}): complete Step N — description\` - **Bug fixes:** \`fix({ID}): description\` - **Tests:** \`test({ID}): description\` ## Do NOT - Expand task scope - Skip tests - Refuse necessary fixes just because they touch files outside the initial File Scope - Commit without the task ID prefix - Remove, delete, or gut modules, settings, interfaces, exports, or test files outside the File Scope - Remove features as "cleanup" — if something seems unused, create a task via \`fn_task_create\` ## Changeset Requirements If this task REMOVES existing functionality (deleting modules, settings, API endpoints, or exports), a changeset file is REQUIRED: - Create \`.changeset/{task-id}-removal.md\` explaining what was removed and why - This is mandatory for any net-negative change (more deletions than additions to existing files) \`\`\` ## Testing requirements The Testing & Verification step MUST require REAL automated tests — actual test 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 - The final Testing step runs lint, the FULL test suite, and project typecheck when the repo exposes one - Specs must instruct executors to fix lint failures and quality-gate failures directly, even when the required edits extend beyond the original File Scope - If the project has no test framework, the Testing step must include setting one up as part of this task (not just skipping tests) ## Duplicate check Before writing a spec, call \`fn_task_list\` to see existing tasks. If a task already covers the same work (even if worded differently), do NOT write a PROMPT.md. Instead, write a single line to the output file: \`DUPLICATE: {existing-task-id}\` ## Dependency awareness When you plan to list a task in the \`## Dependencies\` section, first call \`fn_task_get\` on that task ID to read its PROMPT.md. Use what you learn — file scope, APIs, patterns, completion criteria — to make the new spec accurate: reference the right paths, avoid conflicting assumptions, and describe what the dependency must deliver before this task starts. If the dependency task has no PROMPT.md yet (not yet specified), note that in the Dependencies section. ## Triage subtask breakdown When the task includes \`breakIntoSubtasks: true\`, first decide whether it should be split. - Split only when the work is meaningfully decomposable into 2-5 independently executable child tasks. - If splitting: use the \`fn_task_create\` tool to create child tasks in triage, include clear descriptions and dependencies between them, then stop. Do NOT write a PROMPT.md for the parent task. - **CRITICAL — subtask dependencies:** the parent task is deleted once all subtasks are created. \`dependencies\` on a new subtask may ONLY reference sibling subtasks you have created earlier in this same split (or unrelated existing tasks). **Never depend on the parent task's id.** If a child conceptually "waits for the parent's remaining work", create a sibling subtask that does that work and depend on the sibling instead. The \`fn_task_create\` tool will reject parent-id dependencies with an error. - If not splitting: proceed with a normal PROMPT.md specification. ## Proactive Subtask Breakdown for M/L Tasks For tasks you assess as Size M or L, consider whether splitting into 2-5 child tasks would improve execution quality. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables. **Consider splitting when ANY of these apply:** - The task will require more than 10 implementation steps - The task affects more than 5 different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change) - Any single step would take more than 3-4 hours to complete - The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people **Splitting guidance:** - Even when \`breakIntoSubtasks\` is not set to \`true\`, apply these thresholds proactively - Keep explicit user intent first: when \`breakIntoSubtasks: true\`, follow the mandatory breakdown flow above - Size S tasks should NOT be split — the overhead outweighs the benefit - A task with 7-10 focused steps within a coherent scope is fine as one unit; do not split it - Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — only split when the parallelism or scope-clarity benefit clearly outweighs it - If you decide not to split an M/L task, proceed with a normal PROMPT.md specification ## Triage tools You have these extra tools during triage: - \`fn_task_list\` — list existing active tasks - \`fn_task_get\` — inspect a task and its PROMPT.md - \`fn_task_create\` — create a child/follow-up task while triaging - \`fn_task_document_write\` — save a planning document (e.g., key="plan") - \`fn_task_document_read\` — read back a previously saved document When the planning conversation produces a structured plan, save it as a document with \`fn_task_document_write(key='plan', content='...')\` so the executor can reference it during implementation. ## Step Design Principles - Each implementation step should produce a testable artifact or observable outcome - Order steps by dependency (foundation before integration, implementation before final validation) - Testing & Verification must run before Documentation & Delivery - Avoid giant catch-all steps; split outcomes so execution can be verified incrementally ## Guidelines - Read the project structure and relevant source files to understand context BEFORE writing - Check package.json/scripts and explicit project commands to align real lint/test/build/typecheck commands - Look for similar completed tasks and existing code patterns before inventing spec structure - Be specific — name actual files, functions, and patterns from the codebase - Steps should express OUTCOMES, not micro-instructions (2-5 checkboxes per step) - Always include a testing step and a documentation step - For tasks whose primary deliverable is documentation (updating docs, writing README, API references), include an explicit step or checkbox instructing the executor to save the final documentation content via \`fn_task_document_write\` - Include a "Do NOT" section with project-appropriate guardrails - Size assessment: S (<2h), M (2-4h), L (4-8h). Split if XL (8h+) - Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2) - 0-1 → Level 0, 2-3 → Level 1, 4-5 → Level 2, 6-8 → Level 3 ## Project commands When the user prompt includes a "Project Commands" section with test and/or build commands, use those EXACT commands in the testing/verification steps and anywhere the spec references running tests or builds. Do NOT guess or infer commands from package.json when explicit commands are provided. ## Spec 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. ## PROMPT.md Quality Bar (Good vs Bad) - Good: concrete mission, realistic file scope, dependency-aware step order, explicit quality gates, and clear non-goals. - Bad: generic wording, vague steps ("implement feature"), missing tests, or file scope that cannot realistically satisfy requested behavior. - Good file scope estimation includes likely touched tests, config, and integration files — not only the obvious implementation file. ## Output Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` for review. ## Task Artifact Location for Forensic / Reconciliation Tasks If the task targets a different task ID (audit, forensic walk, historical reconciliation, task-ID-collision investigation, live task metadata repair, or any work where evidence is another task's \`task.json\` / \`PROMPT.md\` / DB row), include this guidance in the generated PROMPT.md \`## Context to Read First\` and \`## File Scope\`: - Authoritative target-task artifacts live at the **project root**: \`/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs). - Authoritative task DB rows live at the **project root** SQLite file: \`/.fusion/fusion.db\` (WAL mode). Read via \`TaskStore\` APIs; do not instruct direct SQL surgery. - \`.fusion/\` is gitignored, so a fresh worktree from \`main\` does **not** include \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`. The running worktree's own \`.fusion/\` (if present) is scratch/session state for the running task only, not source of truth. - Prefer \`fn_task_get\` / \`fn_task_list\` when the target task ID is known; fall back to project-root filesystem reads only when tools cannot provide needed evidence. ## Frontend UX Criteria Injection If the derived **File Scope** touches any of the following paths: - \`packages/dashboard/**\` - \`packages/*/app/components/**\` - \`packages/*/app/hooks/**\` - Any \`*.css\` or \`*.tsx\` file inside a dashboard-like package …then **PREPEND** a \`## Frontend UX Criteria\` section to the generated PROMPT.md, placed immediately after the \`## Mission\` section. Use this exact checklist (keep it verbatim — do not expand or reorder): \`\`\`markdown ## Frontend UX Criteria - [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.) - [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`) - [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors - [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles - [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability - [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three - [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling - [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page \`\`\` Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`; export const FAST_TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "fn", an AI-orchestrated task board. This task is running in **fast mode** — produce a lean, executable PROMPT.md without heavyweight review scoring or subtask analysis. ## Your Role You are a fast-path spec writer. Keep output lean but executable, with enough precision that an executor can run immediately. Your job: turn a rough task description into a focused PROMPT.md another agent can execute autonomously. ## What you produce Write a complete PROMPT.md specification to the given path using the write tool. ## PROMPT.md Format Follow this structure exactly: \`\`\`markdown # Task: {ID} - {Name} **Created:** {YYYY-MM-DD} **Size:** {S | M} ## Mission {One paragraph: what to build and why it matters} ## Dependencies - **None** {OR} - **Task:** {ID} ({what must be complete first}) ## Context to Read First {List the minimal, specific files needed for implementation} ## File Scope {List exact files/directories expected to change} - \`path/to/file.ext\` - \`path/to/directory/*\` ## Steps ### Step 0: Preflight - [ ] Required files and paths exist - [ ] Dependencies satisfied ### Step 1: {Implementation step name} - [ ] {Specific, verifiable outcome} - [ ] {Specific, verifiable outcome} - [ ] Run targeted tests for changed files **Artifacts:** - \`path/to/file\` (new | modified) ### Step {N-1}: Testing & Verification > ZERO test failures allowed. Full test suite as quality gate. > If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope, make those fixes as part of this task. - [ ] Run lint check (\`pnpm lint\`) - [ ] Run full test suite - [ ] Run project typecheck if available - [ ] Build passes ### Step {N}: Documentation & Delivery - [ ] Update relevant documentation - [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...) - [ ] Create out-of-scope follow-up tasks via \`fn_task_create\` when needed ## Documentation Requirements **Must Update:** - \`path/to/doc.md\` — {what to add/change} **Check If Affected:** - \`path/to/doc.md\` — {update if relevant} ## Completion Criteria - [ ] All steps complete - [ ] Lint passing - [ ] All tests passing - [ ] Typecheck passing (if available) - [ ] Documentation updated ## Git Commit Convention Commits at step boundaries. All commits include the task ID: - **Step completion:** \`feat({ID}): complete Step N — description\` - **Bug fixes:** \`fix({ID}): description\` - **Tests:** \`test({ID}): description\` ## Do NOT - Expand task scope - Skip tests - Refuse necessary fixes just because they touch files outside the initial File Scope - Commit without the task ID prefix - Remove, delete, or gut modules, settings, interfaces, exports, or test files outside the File Scope - Remove features as "cleanup" — if something seems unused, create a task via \`fn_task_create\` ## Changeset Requirements If this task REMOVES existing functionality (deleting modules, settings, API endpoints, or exports), a changeset file is REQUIRED: - Create \`.changeset/{task-id}-removal.md\` explaining what was removed and why - This is mandatory for any net-negative change (more deletions than additions to existing files) \`\`\` ## Testing requirements - Require real automated tests with assertions that run in the project's test runner - Typecheck/build/manual checks are not tests and cannot replace tests - Include targeted tests in implementation steps and full quality-gate runs in final verification ## Duplicate check Before writing a spec, call \`fn_task_list\` to find existing active tasks. If an existing task already covers the same work, do NOT write a PROMPT.md. Instead write exactly: \`DUPLICATE: {existing-task-id}\` ## Dependency awareness When adding a dependency in \`## Dependencies\`, first call \`fn_task_get\` for that task and read its PROMPT.md. Use that context to align file paths, APIs, assumptions, and completion expectations. If the dependency has no PROMPT.md yet, note that explicitly. ## Guidelines - Read relevant source files before writing the spec - Be specific: reference concrete files, modules, and commands from this repo - Keep steps outcome-focused with 2–4 checkboxes per step - Keep file scope realistic: include tests and integration touchpoints likely required for green quality gates - Always include Testing & Verification and Documentation & Delivery steps - Keep fast-mode scope lean and executable; do not add heavyweight review scoring or subtask-analysis sections ## Project commands When the user prompt includes explicit test/build commands, use those exact commands in the generated spec. ## Task Artifact Location for Forensic / Reconciliation Tasks For audit/forensic/historical reconciliation tasks that target a different task ID, explicitly state in generated PROMPT.md context/scope that authoritative artifacts and DB state are at project root, not the worktree. - Target-task files live at \`/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, logs). - Task DB truth lives at \`/.fusion/fusion.db\` (SQLite/WAL) and should be accessed via \`TaskStore\`/task tools, not direct SQL edits. - \`.fusion/\` is gitignored: fresh worktrees from \`main\` do not contain other tasks' \`.fusion/tasks/{TARGET_ID}/\` or \`.fusion/fusion.db\`; worktree-local \`.fusion/\` is running-task scratch/session state only. ## Spec Review After writing the PROMPT.md, call \`fn_review_spec()\` to confirm the spec. Fast-mode specs are auto-approved — the review tool will return APPROVE immediately without spawning an independent reviewer. You do NOT need to wait for or iterate on review feedback. ## Output Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` to confirm.`; export interface TriageProcessorOptions { pollIntervalMs?: number; semaphore?: AgentSemaphore; /** Usage limit pauser — triggers global pause when API limits are detected. */ usageLimitPauser?: UsageLimitPauser; /** Stuck task detector — monitors triage sessions for stagnation and triggers recovery. */ stuckTaskDetector?: StuckTaskDetector; onSpecifyStart?: (task: Task) => void; onSpecifyComplete?: (task: Task) => void; onSpecifyError?: (task: Task, error: Error) => void; onAgentText?: (taskId: string, delta: string) => void; /** AgentStore for resolving per-agent custom instructions. */ agentStore?: import("@fusion/core").AgentStore; /** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */ pluginRunner?: import("./plugin-runner.js").PluginRunner; } /** * Processes tasks in the triage column by running an AI agent to generate * a full PROMPT.md specification. * * **Dynamic poll interval:** On every `poll()` call the processor reads * `pollIntervalMs` from the persisted store settings (`store.getSettings()`). * If the value has changed since the last cycle the `setInterval` timer is * transparently restarted, so dashboard setting changes take effect without * an engine restart. */ export class TriageProcessor { private running = false; private polling = false; private pollInterval: ReturnType | null = null; /** The interval (ms) of the currently active `setInterval` timer. */ private activePollMs: number | null = null; private processing = new Set(); /** Timestamps when tasks entered the `processing` set, for staleness detection. */ private processingSince = new Map(); private wasGlobalPaused = false; private wasEnginePaused = false; /** Active agent sessions per task, used to terminate on pause. */ private activeSessions = new Map void }>(); /** * Reviewer subagent sessions per task. The spec reviewer (`reviewer.ts`) * creates its own AgentSession that isn't part of `activeSessions`, so * without this map it survives a global pause and continues producing * verdicts. Mirrors `TaskExecutor.activeSubagentSessions`. */ private activeSubagentSessions = new Map>(); /** Tasks aborted due to globalPause (to avoid reporting as errors). */ private pauseAborted = new Set(); /** Tasks killed by the stuck task detector (to avoid reporting as errors). */ private stuckAborted = new Set(); /** * @param store — Task store instance (also used to listen for `settings:updated` events) * @param rootDir — Project root directory * @param options — Processor configuration * * Listens for `settings:updated` events: when `globalPause` transitions from * `false` to `true`, all active triage specification sessions are immediately * terminated. When `enginePaused` transitions, only new work dispatch is * affected — running sessions continue to completion. */ constructor( private store: TaskStore, private rootDir: string, private options: TriageProcessorOptions = {}, ) { // When globalPause transitions from false → true, terminate all active triage sessions. store.on("settings:updated", ({ settings, previous }) => { if (settings.globalPause && !previous.globalPause) { this.abortAndDisposeActiveSessions("global pause"); } }); /** * Immediate unpause resume: when `globalPause` transitions from `true` * to `false`, trigger a triage poll right away instead of waiting for * the next poll interval (up to 15 s). Only reacts to true→false * transitions — no-ops on false→false and true→true. * * The re-entrance guard (`this.polling`) inside `poll()` safely drops * the call if a poll-based pass is already in flight. */ store.on("settings:updated", ({ settings, previous }) => { if (previous.globalPause && !settings.globalPause && this.running) { this.poll(); } }); /** * Immediate engine-unpause resume: when `enginePaused` transitions from * `true` to `false`, trigger a triage poll right away instead of * waiting for the next poll interval. Same pattern as the globalPause * unpause handler above. */ store.on("settings:updated", ({ settings, previous }) => { if (previous.enginePaused && !settings.enginePaused && this.running) { this.poll(); } }); } start(): void { if (this.running) return; this.running = true; // Clear stale "planning" statuses left by a prior crash/restart. // No triage agent is actually running at startup, so any task still // marked as "planning" is a leftover from a previous engine lifecycle. // Without this, stale statuses consume concurrency slots and block // new triage work indefinitely. this.clearStaleSpecifyingStatuses().catch((err) => { planLog.error("Failed to clear stale planning statuses:", err); }); const interval = this.options.pollIntervalMs ?? 10_000; this.activePollMs = interval; this.pollInterval = setInterval(() => this.poll(), interval); this.poll(); planLog.log("Processor started"); } private async clearStaleSpecifyingStatuses(): Promise { const tasks = await this.store.listTasks({ column: "triage", slim: true }); const stale = tasks.filter( (t) => t.status === "planning" && !this.processing.has(t.id), ); for (const t of stale) { planLog.log(`Startup sweep: clearing stale 'planning' status on ${t.id}`); await this.store.updateTask(t.id, { status: null }); } if (stale.length > 0) { planLog.log(`Startup sweep: cleared ${stale.length} stale planning task(s)`); } } stop(): void { this.running = false; if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; this.activePollMs = null; } // Tear down any in-flight specify sessions and reviewer subagents so they // don't keep streaming LLM tokens / tool calls past engine shutdown. this.abortAndDisposeActiveSessions("engine stop"); planLog.log("Processor stopped"); } /** * Abort and dispose every active specify session and reviewer subagent. * Used by the global-pause handler and by `stop()`. * * Reviewer subagents are torn down first so they don't keep streaming * verdicts while the main triage session is being disposed. abort() * interrupts any in-flight LLM stream / tool call; dispose() then * releases session resources. */ private abortAndDisposeActiveSessions(reason: string): void { for (const taskId of [...this.activeSubagentSessions.keys()]) { this.disposeSubagentsForTask(taskId, reason); } for (const [taskId, session] of this.activeSessions) { planLog.log(`${reason} — terminating triage session for ${taskId}`); this.pauseAborted.add(taskId); this.options.stuckTaskDetector?.untrackTask(taskId); const sessionWithAbort = session as { abort?: () => Promise; dispose: () => void; }; if (typeof sessionWithAbort.abort === "function") { void sessionWithAbort.abort().catch((err) => { planLog.warn(`Failed to abort triage session for ${taskId}: ${err}`); }); } session.dispose(); } } /** * Mark a task as stuck-aborted so the catch block knows not to treat * the disposed session as a genuine failure. * Called by the stuck task detector's onStuck callback. */ markStuckAborted(taskId: string): void { this.stuckAborted.add(taskId); } /** * Register a reviewer subagent session under its parent task. Used as the * `onSessionCreated` callback passed to `reviewStep`. Mirrors the * TaskExecutor implementation. */ private registerSubagentSession(taskId: string, session: AgentSession): void { let set = this.activeSubagentSessions.get(taskId); if (!set) { set = new Set(); this.activeSubagentSessions.set(taskId, set); } set.add(session); } /** Deregister a reviewer subagent that finished naturally. */ private unregisterSubagentSession(taskId: string, session: AgentSession): void { const set = this.activeSubagentSessions.get(taskId); if (!set) return; set.delete(session); if (set.size === 0) this.activeSubagentSessions.delete(taskId); } /** Dispose all reviewer subagents for a task and remove them from the map. */ private disposeSubagentsForTask(taskId: string, reason: string): void { const set = this.activeSubagentSessions.get(taskId); if (!set || set.size === 0) return; planLog.log(`${taskId}: disposing ${set.size} subagent session(s) — ${reason}`); for (const session of set) { try { session.dispose(); } catch (err) { planLog.warn(`${taskId}: failed to dispose subagent session: ${err}`); } } this.activeSubagentSessions.delete(taskId); } /** * Return a snapshot of tasks currently being specified by this processor. * Used by self-healing maintenance to avoid recovering live sessions. */ getProcessingTaskIds(): Set { return new Set(this.processing); } /** * Maximum time a task can remain in the `processing` set before it's * considered stale (30 minutes). By this point the stuck detector * (default 20-min timeout) should have already killed the session * and the `finally` block should have cleaned up. If it hasn't, * the promise is hung (e.g., `promptWithFallback` never settled * after dispose) and self-healing recovery needs to force-evict it. */ private static readonly STALE_PROCESSING_THRESHOLD_MS = 30 * 60 * 1000; /** * Evict tasks from the `processing` set that have been there longer than * the staleness threshold. This handles the case where a stuck-kill * disposes the session but the `specifyTask` promise never settles * (hung `promptWithFallback`), leaving the task in `processing` forever * and blocking self-healing recovery. * * @returns the set of evicted task IDs */ evictStaleProcessing(): Set { const now = Date.now(); const threshold = TriageProcessor.STALE_PROCESSING_THRESHOLD_MS; const evicted = new Set(); for (const [taskId, since] of this.processingSince) { if (now - since >= threshold) { planLog.warn( `${taskId} has been in processing for ${Math.round((now - since) / 60_000)}min ` + `(threshold: ${Math.round(threshold / 60_000)}min) — evicting (likely hung promise)`, ); this.processing.delete(taskId); this.processingSince.delete(taskId); this.activeSessions.delete(taskId); this.stuckAborted.delete(taskId); evicted.add(taskId); } } return evicted; } /** * Recover a triage task whose spec was already approved but the final * handoff out of `status: "planning"` never completed. */ async recoverApprovedTask(task: Task): Promise { if (task.column !== "triage" || task.status !== "planning") { return false; } if (!hasLatestSpecReviewApproval(task)) { return false; } const settings = await this.store.getSettings(); 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}`); return ""; }); if (!written.trim()) { planLog.warn(`${task.id} approved-spec recovery skipped — PROMPT.md missing or empty`); return false; } await this.finalizeApprovedTask(task, written, settings, { recoveryLogAction: settings.requirePlanApproval ? "Auto-recovered approved specification stuck in planning — awaiting manual approval" : "Auto-recovered approved specification stuck in planning — moved to todo", }); return true; } /** * If `newIntervalMs` differs from the currently active timer, restart * the `setInterval` so the new cadence takes effect immediately. */ private refreshPollInterval(newIntervalMs?: number): void { if (!this.running || !newIntervalMs) return; if (newIntervalMs === this.activePollMs) return; if (this.pollInterval) { clearInterval(this.pollInterval); } this.activePollMs = newIntervalMs; this.pollInterval = setInterval(() => this.poll(), newIntervalMs); planLog.log(`Poll interval updated to ${newIntervalMs}ms`); } /** * Discover triage tasks and dispatch `specifyTask()` for each one. * * **Concurrent dispatch:** `specifyTask()` calls are fired without awaiting, * so multiple triage tasks can be specified concurrently (bounded by the * shared `AgentSemaphore`). The `polling` re-entrance guard prevents * overlapping discovery cycles, but resets as soon as dispatch completes — * well before the dispatched tasks finish — so subsequent polls can discover * newly arrived triage tasks promptly. */ private async poll(): Promise { if (!this.running) return; if (this.polling) return; this.polling = true; try { const settings = await this.store.getSettings(); this.refreshPollInterval(settings.pollIntervalMs); // Global pause (hard stop): halt all triage activity if (settings.globalPause) { if (!this.wasGlobalPaused) { planLog.log("Global pause active — triage halted"); this.wasGlobalPaused = true; } return; } this.wasGlobalPaused = false; // Engine paused (soft pause): halt new triage work, but let agents finish if (settings.enginePaused) { if (!this.wasEnginePaused) { planLog.log( "Engine paused — triage halted (in-flight agents continue)", ); this.wasEnginePaused = true; } return; } this.wasEnginePaused = false; // Fetch all tasks (not just triage) to count active agents across columns. const allTasks = await this.store.listTasks({ slim: true, includeArchived: false }); const now = Date.now(); const eligibleTriageTasks = allTasks.filter( (t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused // Skip tasks awaiting manual plan approval — they should not be auto-discovered && t.status !== "awaiting-approval" // Skip failed specifications until the user explicitly retries them. && t.status !== "failed" && t.status !== "stuck-killed" // Skip tasks with a recovery backoff that hasn't elapsed yet && !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now), ); const triageTasks = sortTasksByPriorityThenAgeAndId(eligibleTriageTasks); // Respect both per-project maxTriageConcurrent and the global semaphore. // Only planning tasks count against the triage limit; execution is governed by maxConcurrent. const maxTriageConcurrent = settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2; const planning = allTasks.filter( (t) => t.column === "triage" && t.status === "planning" && !t.paused, ).length; const activeAgents = planning; const perProjectAvailable = Math.max(0, maxTriageConcurrent - activeAgents); const semaphoreAvailable = this.options.semaphore ? Math.max(0, this.options.semaphore.availableCount) : Infinity; const maxToStart = Math.min(perProjectAvailable, semaphoreAvailable); if (maxToStart <= 0 && triageTasks.length > 0) { planLog.log( `Plan throttled: ${activeAgents} planning agents, limit ${maxTriageConcurrent}`, ); } for (let i = 0; i < Math.min(triageTasks.length, maxToStart); i++) { void this.specifyTask(triageTasks[i]); } } catch (err) { planLog.error("Poll error:", err); } finally { this.polling = false; } } /** * 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. */ async specifyTask(task: Task): Promise { if (this.processing.has(task.id)) return; this.processing.add(task.id); this.processingSince.set(task.id, Date.now()); planLog.log( `Specifying ${task.id}: ${task.title || task.description.slice(0, 60)}`, ); this.options.onSpecifyStart?.(task); try { const detail = await this.store.getTask(task.id); const settings = await this.store.getSettings(); const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`; const isFast = task.executionMode === "fast"; const agentWork = async () => { // Set status only after the semaphore slot has been acquired, so // tasks waiting in the queue don't appear as "planning". await this.store.updateTask(task.id, { status: "planning" }); const stuckDetector = this.options.stuckTaskDetector; const agentLogger = new AgentLogger({ store: this.store, taskId: task.id, agent: "triage", persistAgentToolOutput: settings.persistAgentToolOutput, // Triage runs in a task-scoped ephemeral worker session. persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), onAgentText: (id, delta) => { stuckDetector?.recordActivity(task.id); this.options.onAgentText?.(id, delta); }, onAgentTool: (_id, _name) => { stuckDetector?.recordActivity(task.id); // Tool events are persisted via AgentLogger (tool/tool_result/tool_error) // for fn task logs and agent log history — no stdout spam }, }); // 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: [] }; const assignedAgent = task.assignedAgentId && this.options.agentStore ? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null) : null; const customTools = [ ...this.createTriageTools({ parentTaskId: task.id, allowTaskCreate: true, createdSubtasksRef, }), createTaskDocumentWriteTool(this.store, task.id), createTaskDocumentReadTool(this.store, task.id), ...(isResearchToolSurfaceEnabled(settings) ? createResearchTools({ store: this.store, rootDir: this.rootDir, getSettings: async () => this.store.getSettings(), }) : []), ...createMemoryTools(this.rootDir, settings, assignedAgent ? { agentMemory: { agentId: assignedAgent.id, agentName: assignedAgent.name, memory: assignedAgent.memory, }, } : undefined), createWebFetchTool(), // Agent delegation tools — discover and delegate work to other agents. ...(this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }), ] : []), this.createReviewSpecTool( task.id, promptPath, sessionRef, checkpointRef, specReviewVerdictRef, approvedCommentFingerprintRef, settings, isFast, ), ]; let triageRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig); // Resolve per-agent custom instructions for the triage role or assigned agent. let triageInstructions = ""; if (assignedAgent) { const memoryMode = resolveAgentMemoryInclusionMode({ agent: assignedAgent, projectSettings: settings }).mode; triageInstructions = await resolveAgentInstructionsWithRatings( assignedAgent, this.rootDir, this.options.agentStore, memoryMode, ); } else if (this.options.agentStore) { try { const agents = await this.options.agentStore.listAgents({ role: "triage" }); for (const agent of agents) { triageRuntimeHint ??= extractRuntimeHint(agent.runtimeConfig); if (agent.instructionsText || agent.instructionsPath || agent.soul || agent.memory) { const memoryMode = resolveAgentMemoryInclusionMode({ agent, projectSettings: settings }).mode; triageInstructions = await resolveAgentInstructions(agent, this.rootDir, undefined, memoryMode); break; } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to resolve triage agent instructions, continuing with defaults: ${msg}`); } } planLog.log(`${task.id}: planning in ${isFast ? "fast" : "standard"} mode`); const triageIdentitySection = assignedAgent ? `## Identity\n\nYou are ${assignedAgent.name}${assignedAgent.title?.trim() ? `, ${assignedAgent.title.trim()}` : ""} (agent ID: ${assignedAgent.id}, role: ${assignedAgent.role}).` : ""; // Build structured layers for cross-session prompt caching. const triagePluginContributions = buildPluginPromptSection( "triage", this.options.pluginRunner, ); if (triagePluginContributions) { planLog.log(`${task.id}: applied plugin prompt contributions for triage surface`); } const triageLayers = buildPromptLayers({ basePrompt: resolveAgentPrompt("triage", settings.agentPrompts) || (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT), agentInstructions: [ triageIdentitySection, triageInstructions, isResearchToolSurfaceEnabled(settings) ? getResearchGuidanceForSurface("triage") : "", ].filter((section) => section.trim()).join("\n\n"), pluginContributions: triagePluginContributions, }); const triageSystemPromptFinal = collapsePromptLayers(triageLayers); // Build skill selection context (assigned agent skills take precedence over role fallback) const skillContext = await buildSessionSkillContext({ agentStore: this.options.agentStore!, task, sessionPurpose: "triage", projectRootDir: this.rootDir, pluginRunner: this.options.pluginRunner, }); // Resolve planning model using executor-style precedence: // 1. Assigned durable agent runtime model pair when complete // 2. Task planning override pair // 3. Planning/project/global fallbacks const planningModel = resolvePlanningSessionModel( task.planningModelProvider, task.planningModelId, settings, assignedAgent?.runtimeConfig, ); const planningSessionModelOptions = { defaultProvider: planningModel.provider, defaultModelId: planningModel.modelId, }; let { session } = 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, ...planningSessionModelOptions, fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId ? settings.planningFallbackProvider : settings.fallbackProvider, fallbackModelId: settings.planningFallbackProvider && settings.planningFallbackModelId ? settings.planningFallbackModelId : settings.fallbackModelId, defaultThinkingLevel: settings.defaultThinkingLevel, // Skill selection: use assigned agent skills if available, otherwise role fallback ...(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, }), }); const modelDesc = describeModel(session); planLog.log(`${task.id}: using model ${modelDesc}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.appendAgentLog( task.id, `Triage using model: ${modelDesc}`, "text", undefined, "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); // Register with stuck task detector for heartbeat monitoring stuckDetector?.trackTask(task.id, session); stuckDetector?.recordActivity(task.id); try { // Read attachment contents for inlining in prompt const { attachmentContents, imageContents } = await readAttachmentContents( this.rootDir, detail.id, detail.attachments, ); // Check if this is a re-planning request const isReplan = task.status === "needs-replan"; let existingPrompt: string | undefined; let feedback: string | undefined; if (isReplan) { // Prefer explicit re-specification feedback logged by comment-triggered // and approval-invalidation flows; fall back to legacy revision logs. const feedbackLogEntry = [...task.log] .reverse() .find((entry) => entry.action === "User comment requested re-specification of planned task" || entry.action === "User comment invalidated spec approval — task needs re-specification" || entry.action === "AI spec revision requested" ); feedback = feedbackLogEntry?.outcome; // Ensure the latest user feedback is always actionable for re-plans. if (!feedback) { const latestUserComment = [...(detail.comments || [])] .reverse() .find((comment) => comment.author === "user"); feedback = latestUserComment?.text; } planLog.log( `${task.id} re-planning with feedback: ${feedback?.slice(0, 100)}...`, ); } const agentPrompt = buildSpecificationPrompt( detail, promptPath, settings, attachmentContents, existingPrompt, feedback, ); await promptWithFallback( session, agentPrompt, imageContents.length > 0 ? { images: imageContents } : undefined, ); // Re-raise errors that pi-coding-agent swallowed after exhausting retries. checkSessionError(session); if (this.pauseAborted.has(task.id)) { this.pauseAborted.delete(task.id); planLog.log(`${task.id} aborted by pause — clearing status`); const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort cleanup: ${msg}`); }); return; } if (this.stuckAborted.has(task.id)) { this.stuckAborted.delete(task.id); planLog.log(`${task.id} killed by stuck detector — clearing status for retry`); const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector abort cleanup: ${msg}`); }); return; } if (createdSubtasksRef.current.length > 0) { const childTaskIds = createdSubtasksRef.current.join(", "); await this.store.logEntry( task.id, `Converted into subtasks: ${childTaskIds}`, ); try { await this.store.deleteTask(task.id); planLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`); } catch (err: unknown) { // deleteTask refuses when live tasks still depend on this id. // If fn_task_create's validation worked correctly this branch is // unreachable, but we keep it as defense-in-depth: leaving the // parent alive is always safer than stranding dependents. const msg = err instanceof Error ? err.message : String(err); planLog.error( `${task.id}: cannot close parent after split (${msg}). ` + `Parent kept alive to avoid orphaning dependents; subtasks were still created.`, ); await this.store.logEntry( task.id, `Split-close aborted: ${msg}. Subtasks created but parent kept alive to avoid orphaning dependents.`, ); } 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}`, ); 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, ...(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}`, ); await this.store.deleteTask(task.id); planLog.log(`✓ ${task.id} split into subtasks (${childTaskIds}) and closed`); 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 decision = computeRecoveryDecision({ recoveryRetryCount: task.recoveryRetryCount, nextRecoveryAt: task.nextRecoveryAt, }); if (decision.shouldRetry) { const attempt = decision.nextState.recoveryRetryCount; const delay = formatDelay(decision.delayMs); const retryMessage = `Spec review not approved (${verdictDesc}) — 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; await this.store.updateTask(task.id, { status: restoreStatus, error: null, recoveryRetryCount: decision.nextState.recoveryRetryCount, nextRecoveryAt: decision.nextState.nextRecoveryAt, }); return; } const failureMessage = `Specification failed after ${MAX_RECOVERY_RETRIES} unapproved spec reviews (${verdictDesc}). ` + "Retry after adjusting the task prompt or model."; planLog.log( `${task.id} spec review not approved (${verdictDesc}) — retry budget exhausted`, ); await this.store.logEntry( task.id, failureMessage, ); await this.store.updateTask(task.id, { status: "failed", error: failureMessage, recoveryRetryCount: null, nextRecoveryAt: null, }); 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; } const written = await readFile( join(this.rootDir, promptPath), "utf-8", ).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to read generated PROMPT.md before finalization (${promptPath}): ${msg}`); return ""; }); await this.finalizeApprovedTask(task, written, settings, { isReplan, feedback, }); this.options.onSpecifyComplete?.(task); } finally { this.activeSessions.delete(task.id); stuckDetector?.untrackTask(task.id); await agentLogger.flush(); session.dispose(); } }; const retryableWork = () => withRateLimitRetry(agentWork, { onRetry: (attempt, delayMs, error) => { const delaySec = Math.round(delayMs / 1000); planLog.warn(`⏳ ${task.id} rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`); this.store.logEntry(task.id, `Rate limited — retry ${attempt} in ${delaySec}s`).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to log rate-limit retry entry: ${msg}`); }); }, }); if (this.options.semaphore) { await this.options.semaphore.run(retryableWork, PRIORITY_SPECIFY); } else { await retryableWork(); } } catch (err: unknown) { const { message: errorMessage, detail: errorDetail, stack: errorStack } = formatError(err); // Race condition: task was deleted (e.g. as a duplicate) between listTasks() // and specifyTask(). The file is gone, so just log and skip — no point retrying. if ((err as Record).code === "ENOENT") { planLog.log(`${task.id} no longer exists — skipping`); } else if (this.pauseAborted.has(task.id)) { // Pause (global or engine) — clear planning status without reporting an error this.pauseAborted.delete(task.id); planLog.log(`${task.id} aborted by pause — clearing status`); // For re-planning, restore needs-replan status; otherwise clear to null // so the next poll can re-pick this task up. const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during pause-abort error cleanup: ${msg}`); }); } else if (this.stuckAborted.has(task.id)) { // Stuck task detector killed this session — clear planning status so the // next poll retries the task from scratch without reporting an error. this.stuckAborted.delete(task.id); planLog.log(`${task.id} killed by stuck detector — clearing status for retry`); const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during stuck-detector error cleanup: ${msg}`); }); } else { // Check if the error is a usage-limit error and trigger global pause if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) { await this.options.usageLimitPauser.onUsageLimitHit( "triage", task.id, errorMessage, ); } else if (isTransientError(errorMessage)) { // Transient network/infrastructure error — use bounded recovery policy const decision = computeRecoveryDecision({ recoveryRetryCount: task.recoveryRetryCount, nextRecoveryAt: task.nextRecoveryAt, }); if (decision.shouldRetry) { const attempt = decision.nextState.recoveryRetryCount; const delay = formatDelay(decision.delayMs); // Silent transient errors (e.g., "request was aborted") are noisy — skip logging if (!isSilentTransientError(errorMessage)) { planLog.warn(`⚡ ${task.id} transient error during triage — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${errorMessage}`); await this.store.logEntry(task.id, `Transient error during specification (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${errorMessage}`).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to log transient-error retry entry: ${msg}`); }); } const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus, recoveryRetryCount: decision.nextState.recoveryRetryCount, nextRecoveryAt: decision.nextState.nextRecoveryAt, }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' during transient-error retry scheduling: ${msg}`); }); return; } // Recovery budget exhausted — freeze in triage with error for manual intervention planLog.error(`✗ ${task.id} transient error retries exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`); await this.store.logEntry(task.id, `Specification failed after ${MAX_RECOVERY_RETRIES} transient errors: ${errorMessage}`).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to log transient-error retries-exhausted entry: ${msg}`); }); await this.store.updateTask(task.id, { error: `Specification failed after ${MAX_RECOVERY_RETRIES} transient errors: ${errorMessage}`, recoveryRetryCount: null, nextRecoveryAt: null, }).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${task.id}: failed to persist transient-error retries-exhausted state: ${msg}`); }); this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); return; } // For re-planning, restore needs-replan status so it can be retried; // otherwise clear to null so the next poll can re-pick the task up. const restoreStatus = task.status === "needs-replan" ? "needs-replan" : null; await this.store.updateTask(task.id, { status: restoreStatus }).catch((restoreErr: unknown) => { const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr); planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after planning error: ${msg}`); }); planLog.error(`✗ ${task.id} planning failed:`, errorDetail); if (errorStack) { await this.store.logEntry(task.id, `Specification failed: ${errorMessage}`, errorStack).catch((logErr: unknown) => { const msg = logErr instanceof Error ? logErr.message : String(logErr); planLog.warn(`${task.id}: failed to persist specification-failure stack trace: ${msg}`); }); } this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); } } finally { this.processing.delete(task.id); this.processingSince.delete(task.id); } } private createTriageTools(options: { parentTaskId: string; allowTaskCreate: boolean; createdSubtasksRef: { current: string[] }; }): ToolDefinition[] { const store = this.store; const taskGetParams = Type.Object({ id: Type.String({ description: "Task ID (e.g. KB-001)" }), }); const taskCreatePriorityValues = ["low", "normal", "high", "urgent"] as const; const taskCreateParams = Type.Object({ title: Type.Optional(Type.String({ description: "Short child task title" })), description: Type.String({ description: "Child task description/mission" }), dependencies: Type.Optional( Type.Array(Type.String({ description: "Task ID dependency (e.g. KB-001)" })), ), priority: Type.Optional( Type.Union(taskCreatePriorityValues.map((priority) => Type.Literal(priority)), { description: "Task priority (low, normal, high, urgent)", }), ), }); const taskList: ToolDefinition = { name: "fn_task_list", label: "List Tasks", description: "List all tasks that aren't done. Returns ID, description, column, " + "and dependencies for each. Use to check for duplicates before planning.", parameters: Type.Object({}), execute: async () => { const tasks = await store.listTasks({ slim: true, includeArchived: false }); const active = tasks.filter((t) => t.column !== "done"); if (active.length === 0) { return { content: [{ type: "text" as const, text: "No active tasks." }], details: {}, }; } const lines = active.map((t) => { const desc = t.title || t.description.slice(0, 80); const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : ""; return `${t.id} (${t.column}): ${desc}${deps}`; }); return { content: [{ type: "text" as const, text: lines.join("\n") }], details: {}, }; }, }; const taskGet: ToolDefinition = { name: "fn_task_get", label: "Get Task", description: "Get full details of a specific task including its PROMPT.md content. " + "Use to verify duplicates and to read dependency task specs before writing a new PROMPT.md.", parameters: taskGetParams, execute: async ( _callId: string, params: Static, ) => { try { const task = await store.getTask(params.id); const parts = [ `ID: ${task.id}`, `Column: ${task.column}`, `Description: ${task.description}`, task.dependencies.length ? `Dependencies: ${task.dependencies.join(", ")}` : null, "", "PROMPT.md:", task.prompt || "(not yet specified)", ].filter(Boolean); return { content: [{ type: "text" as const, text: parts.join("\n") }], details: {}, }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${options.parentTaskId}: fn_task_get lookup failed for ${params.id}: ${msg}`); return { content: [ { type: "text" as const, text: `Task ${params.id} not found.` }, ], details: {}, }; } }, }; const taskCreate: ToolDefinition = { name: "fn_task_create", label: "Create Child Task", description: "Create a child task (subtask) while breaking a larger task into smaller pieces. " + "Use this when the work can be split into 2-5 independently executable tasks, " + "either because the user requested subtask breakdown or because the task is " + "genuinely oversized (12+ steps OR multiple clearly independent deliverables that could ship separately). " + "The created task will be a child of the current task being triaged. " + "IMPORTANT: `dependencies` may ONLY reference other subtasks you have created " + "in this same triage session. Never depend on the parent task — the parent is " + "deleted after splitting, and stale dependency ids permanently block the dependent.", parameters: taskCreateParams, execute: async ( _callId: string, params: Static, ) => { // fn_task_create is always available during triage to support both // explicit breakIntoSubtasks and proactive splitting of oversized tasks. try { // Validate dependencies before creating the child: // 1. Cannot depend on the parent (it's about to be deleted). // 2. Each id must either (a) already exist in the store, or // (b) reference a sibling created earlier in this split. // This is the load-bearing guard that prevents the AI from stranding // children behind a never-to-exist parent id. const requestedDeps = params.dependencies || []; const siblings = new Set(options.createdSubtasksRef.current); const validDeps: string[] = []; const rejected: Array<{ id: string; reason: string }> = []; for (const depId of requestedDeps) { if (depId === options.parentTaskId) { rejected.push({ id: depId, reason: "parent task is deleted after splitting; depend on a sibling child task instead", }); continue; } if (siblings.has(depId)) { validDeps.push(depId); continue; } try { await store.getTask(depId); validDeps.push(depId); } catch { rejected.push({ id: depId, reason: "task not found (only existing tasks or siblings created earlier in this split are allowed)", }); } } if (rejected.length > 0) { const summary = rejected .map((r) => ` - ${r.id}: ${r.reason}`) .join("\n"); return { content: [ { type: "text" as const, text: `ERROR: fn_task_create rejected. Invalid dependencies:\n${summary}\n\n` + `Remove or replace these ids and call fn_task_create again.`, }, ], details: { rejectedDependencies: rejected }, }; } // Fetch parent task to inherit model settings let parentTask: Awaited> | undefined; try { parentTask = await store.getTask(options.parentTaskId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${options.parentTaskId}: failed to load parent task for fn_task_create inheritance: ${msg}`); // Parent task not found or error - proceed without inheritance parentTask = undefined; } const newTask = await createAgentTask(store, { title: params.title, description: params.description, dependencies: validDeps, column: "triage", priority: params.priority, // Inherit parent's model settings if available modelProvider: parentTask?.modelProvider, modelId: parentTask?.modelId, validatorModelProvider: parentTask?.validatorModelProvider, validatorModelId: parentTask?.validatorModelId, source: { sourceType: "agent_heartbeat", sourceParentTaskId: options.parentTaskId, }, }, { rootDir: this.rootDir }); // Track the created subtask options.createdSubtasksRef.current.push(newTask.id); return { content: [ { type: "text" as const, text: `Created child task ${newTask.id}: ${params.title || params.description.slice(0, 60)}`, }, ], details: { taskId: newTask.id }, }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); return { content: [ { type: "text" as const, text: `ERROR: Failed to create task: ${errorMessage}`, }, ], details: {}, }; } }, }; return [taskList, taskGet, 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; 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"); // 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; } 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: {}, }; } // 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 (fast mode)`); await store.logEntry(taskId, "Spec review: APPROVE (auto, fast mode)"); 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. const currentSettings = 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, // Project-level validator override projectValidatorProvider: currentSettings.validatorProvider, projectValidatorModelId: currentSettings.validatorModelId, // Project-level validator fallback projectValidatorFallbackProvider: currentSettings.validatorFallbackProvider, projectValidatorFallbackModelId: currentSettings.validatorFallbackModelId, // 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, agentStore: this.options.agentStore, rootDir, // 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: {}, }; } }, }; } private async finalizeApprovedTask( task: Task, written: string, settings: Settings, options: { isReplan?: boolean; feedback?: string; recoveryLogAction?: string; } = {}, ): Promise { const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i); if (dupMatch) { const dupId = dupMatch[1]; planLog.log(`${task.id} is a duplicate of ${dupId} — closing`); await this.store.logEntry( task.id, `Duplicate of ${dupId} — closed`, ); await this.store.deleteTask(task.id); return; } const parsedDeps = await this.store.parseDependenciesFromPrompt(task.id); const taskUpdates: Record = { status: null, error: null }; if (parsedDeps.length > 0) { taskUpdates.dependencies = parsedDeps; planLog.log(`${task.id} dependencies: ${parsedDeps.join(", ")}`); } const parsedSteps = await this.store.parseStepsFromPrompt(task.id); if (parsedSteps.length > 0) { taskUpdates.steps = parsedSteps; } const sizeMatch = written.match(/^\*\*Size:\*\*\s+(S|M|L)\b/m); if (sizeMatch) { taskUpdates.size = sizeMatch[1] as "S" | "M" | "L"; } const reviewMatch = written.match(/^##\s+Review\s+Level:\s+(\d+)/m); if (reviewMatch) { taskUpdates.reviewLevel = parseInt(reviewMatch[1], 10); } // Apply non-title metadata first. The title is held back and applied AFTER // the column transition (see below) because store.updateTask regenerates // PROMPT.md when title/description change, and the triage-stub regen path // would overwrite the freshly-written specification while column='triage'. // The store now also guards that regen against real specs, but we keep this // ordering as defense in depth so a future change to the guard can't // resurrect the regression. const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id); const shouldApplyPromptDeclaredTitle = shouldReplaceTaskTitleFromPrompt(task, promptDeclaredTitle); await this.store.updateTask(task.id, taskUpdates); if (settings.requirePlanApproval) { const approvalUpdates: Record = { status: "awaiting-approval" }; if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { approvalUpdates.title = promptDeclaredTitle; } await this.store.updateTask(task.id, approvalUpdates); await this.store.logEntry( task.id, options.recoveryLogAction ?? "Specification approved by AI — awaiting manual approval", ); planLog.log(`✓ ${task.id} specified and awaiting manual approval`); return; } await this.store.moveTask(task.id, "todo"); if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { await this.store.updateTask(task.id, { title: promptDeclaredTitle }); } if (options.recoveryLogAction) { await this.store.logEntry(task.id, options.recoveryLogAction); planLog.log(`✓ ${task.id} recovered and moved to todo`); return; } if (options.isReplan) { await this.store.logEntry(task.id, "Spec revised by AI", options.feedback); planLog.log(`✓ ${task.id} re-planned and moved to todo`); } else { planLog.log(`✓ ${task.id} specified and moved to todo`); } } } function extractPromptDeclaredTitle(prompt: string, taskId: string): string | null { const headingMatch = prompt.match(/^#\s+Task:\s+([A-Z]+-\d+)\s+-\s+(.+)$/m); if (!headingMatch) return null; const [, headingTaskId, rawTitle] = headingMatch; if (headingTaskId !== taskId) return null; const title = rawTitle.trim().replace(/[\s.!?,;:]+$/g, ""); if (!title) return null; // Conservative guard: do not overwrite metadata with confirmation prose. if (isMalformedTaskTitle(title)) { return null; } return title; } function isMalformedTaskTitle(title: string): boolean { return /^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title.trim()); } function shouldReplaceTaskTitleFromPrompt(task: Task, promptDeclaredTitle: string | null): boolean { if (!promptDeclaredTitle) return false; if ( task.sourceType === "github_import" && task.sourceIssue?.provider === "github" && task.title?.trim() && !isMalformedTaskTitle(task.title) ) { return false; } 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; mimeType: string; /** Text content for text files, null for images (handled via image content blocks). */ text: string | null; } const IMAGE_MIME_TYPES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", ]); const TEXT_INLINE_LIMIT = 50 * 1024; // 50KB /** * Read attachment files from disk, returning text contents for inlining * and image contents for pi image content blocks. */ export async function readAttachmentContents( rootDir: string, taskId: string, attachments?: TaskAttachment[], ): Promise<{ attachmentContents: AttachmentContent[]; imageContents: ImageContent[]; }> { const attachmentContents: AttachmentContent[] = []; const imageContents: ImageContent[] = []; if (!attachments || attachments.length === 0) { return { attachmentContents, imageContents }; } const { readFile } = await import("node:fs/promises"); const { join } = await import("node:path"); for (const att of attachments) { const filePath = join( rootDir, ".fusion", "tasks", taskId, "attachments", att.filename, ); try { if (IMAGE_MIME_TYPES.has(att.mimeType)) { const data = await readFile(filePath); imageContents.push({ type: "image", data: data.toString("base64"), mimeType: att.mimeType, }); attachmentContents.push({ originalName: att.originalName, mimeType: att.mimeType, text: null, }); } else { const data = await readFile(filePath, "utf-8"); const text = data.length > TEXT_INLINE_LIMIT ? data.slice(0, TEXT_INLINE_LIMIT) + "\n... (truncated at 50KB)" : data; attachmentContents.push({ originalName: att.originalName, mimeType: att.mimeType, text, }); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); planLog.warn(`${taskId}: failed to read attachment '${att.filename}', skipping: ${msg}`); // Skip unreadable attachments continue; } } return { attachmentContents, imageContents }; } /** * Compute a deterministic fingerprint from user comments on a task. * Returns a sorted, semicolon-joined string of comment IDs (user-authored only). * Used to detect whether user comments changed after spec approval. */ export function computeUserCommentFingerprint( comments?: import("@fusion/core").TaskComment[], ): string { if (!comments || comments.length === 0) return ""; const userIds = comments .filter((c) => c.author === "user") .map((c) => c.id) .sort(); return userIds.join(";"); } export function buildSpecificationPrompt( task: TaskDetail, promptPath: string, settings?: Settings, attachmentContents?: AttachmentContent[], existingPrompt?: string, feedback?: string, ): string { const hasFeedback = Boolean(feedback?.trim()); const isRevision = Boolean(existingPrompt && hasFeedback); const isFreshRespecification = Boolean(!existingPrompt && hasFeedback); let commandsSection = ""; if (settings?.testCommand || settings?.buildCommand) { const lines = ["## Project Commands"]; if (settings.testCommand) lines.push(`- **Test:** \`${settings.testCommand}\``); if (settings.buildCommand) lines.push(`- **Build:** \`${settings.buildCommand}\``); lines.push("Use these exact commands in testing/verification steps."); commandsSection = "\n\n" + lines.join("\n"); } const completionDocumentationMode = settings?.completionDocumentationMode ?? "off"; let completionDocumentationSection = ""; if (completionDocumentationMode !== "off") { const instruction = completionDocumentationMode === "changeset" ? "If the task changes published-package behavior, require a `.changeset/*.md` entry and call out the repository's changeset workflow." : "Require updating an existing changelog file as part of completion; do not invent a new changelog file when none exists."; completionDocumentationSection = `\n\n## Completion Documentation Preference\nProject setting \`completionDocumentationMode\` is set to \`${completionDocumentationMode}\`. When writing PROMPT.md, add this as an explicit requirement under completion documentation/delivery expectations (not a side note): - ${instruction}`; } // Build project memory section from settings. // When enabled, agents consult project memory for durable project learnings. // Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd) const memoryEnabled = settings?.memoryEnabled !== false; let memorySection = ""; if (memoryEnabled) { memorySection = "\n\n" + buildTriageMemoryInstructions("", settings); } let attachmentsSection = ""; if (attachmentContents && attachmentContents.length > 0) { const parts = ["## Attachments", ""]; for (const att of attachmentContents) { if (att.text === null) { // Image — will be passed via image content blocks parts.push( `- **${att.originalName}** (${att.mimeType}) — included as image below`, ); } else { parts.push( `### ${att.originalName} (${att.mimeType})\n\n\`\`\`\n${att.text}\n\`\`\``, ); } } attachmentsSection = "\n\n" + parts.join("\n"); } // Include user comments as context for the triage agent let userCommentsSection = ""; const userComments = (task.comments || []).filter( (c) => c.author === "user", ); if (userComments.length > 0) { const parts = [ "## User Comments", "", "The following user comments have been posted on this task. **Address every comment** in the specification — each comment represents explicit user feedback or requirements that must be reflected in the PROMPT.md.", "", ]; for (const comment of userComments) { const date = comment.updatedAt || comment.createdAt; parts.push( `- **[${date}]** ${comment.text}`, ); } parts.push( "", "Ensure the specification addresses all of the above comments. Missing comment coverage is a spec quality failure.", ); userCommentsSection = "\n\n" + parts.join("\n"); } let revisionSection = ""; if (isRevision) { revisionSection = ` ## Revision Instructions You are revising an existing task specification based on user feedback. **Important:** Keep the same overall PROMPT.md structure (headings, sections, format) but improve the content to address the feedback below. Do not drastically change the file structure unless necessary. ## Existing Specification \`\`\`markdown ${existingPrompt} \`\`\` ## User Feedback ${feedback} Please revise the specification above to address this feedback. Write the complete revised PROMPT.md to \`${promptPath}\`.`; } else if (isFreshRespecification) { revisionSection = ` ## Re-specification Instructions You are creating a fresh replacement specification based on user feedback. **Important:** Do not reuse stale PROMPT.md content. Treat the current task title and description as required primary inputs, inspect the codebase, and write a complete new specification that addresses the feedback below. ## User Feedback ${feedback} Please write the complete fresh PROMPT.md to \`${promptPath}\`.`; } let subtaskSection = ""; if (task.breakIntoSubtasks) { subtaskSection = ` ## Subtask Breakdown Requested The user has requested that this task be broken into smaller subtasks if it is complex enough to warrant splitting. **When to split:** - Only split when the work is meaningfully decomposable into 2-5 independently executable child tasks - Each child task should be completable on its own with a clear scope and acceptance criteria - Child tasks should have logical dependencies between them if order matters **How to split:** 1. First, analyze the task to determine if it should be split 2. If splitting: use the \\\`fn_task_create\\\` tool to create child tasks in order, setting up dependencies as needed 3. Include clear descriptions and acceptance criteria for each child task 4. After creating all subtasks, stop — do NOT write a PROMPT.md for the parent task 5. If NOT splitting: proceed with a normal PROMPT.md specification for this task **Subtask dependencies rule:** \`dependencies\` on a child may only reference **sibling subtasks created earlier in this same split** or **pre-existing tasks in the store**. They must NEVER reference the parent task being split — the parent is deleted after the split completes, and a dependency on a deleted task permanently blocks the dependent. If a child "needs the rest of the parent's work to finish first", create another sibling subtask for that remaining work and depend on the sibling. The \`fn_task_create\` tool rejects parent-id dependencies. **Important:** If you create subtasks, this parent task will be closed and replaced by the children. Make sure each child is a complete, executable task.`; } else { subtaskSection = ` ## Subtask Consideration The user did not explicitly request subtask breakdown. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables. **Split into 2-5 child tasks when ANY of these apply:** - The task will require more than 10 implementation steps - The task affects more than 5 different packages/modules with distinct concerns (touching multiple packages as a coherent vertical change does NOT count — e.g. types + store + UI + tests for one feature is one task) - Any single step would take more than 3-4 hours to complete - The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people **GOOD TO SPLIT:** - A task that would require 12+ implementation steps spanning genuinely separate concerns - A multi-feature epic where each feature can be shipped independently - A refactor that has both a "rip out the old" phase and an "add the new" phase that can land separately **NOT NECESSARY TO SPLIT (and SHOULD NOT be split):** - A bug fix with clear scope, regardless of how many files it touches - A single-file refactor - A vertical feature that touches core + dashboard + tests as one coherent unit (this is the common case in this monorepo — keep it together) - Any task with 10 or fewer focused steps within a coherent scope **How to decide:** - If you choose to split: use the \\\`fn_task_create\\\` tool to create the child tasks, set dependencies where needed, and then stop without writing a PROMPT.md for the parent task. - **Subtask dependencies must only reference sibling subtasks created earlier in this same split, or pre-existing tasks. NEVER depend on the parent task being split — the parent is deleted after splitting, and the tool will reject parent-id dependencies.** - When in doubt, do NOT split. Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — splitting must clearly pay for itself. - If size is uncertain at first, make a quick assessment from the available context before deciding.`; } return `${isRevision ? "Revise" : isFreshRespecification ? "Re-specify" : "Specify"} this task and write the result to \`${promptPath}\`. ## Task - **ID:** ${task.id} - **Title:** ${task.title || "(none)"} - **Description:** ${task.description} ${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : ""} ${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection} ## Instructions ${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Treat the current task title and description as mandatory primary inputs for a new spec\n3. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n4. Address the user feedback without carrying forward stale assumptions from the old spec\n5. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"} Use the write tool to write the specification file.${commandsSection}${completionDocumentationSection}${memorySection}${attachmentsSection}${userCommentsSection}`; }