diff --git a/.changeset/ce-recover-stale-sessions.md b/.changeset/ce-recover-stale-sessions.md new file mode 100644 index 0000000000..a76727d30e --- /dev/null +++ b/.changeset/ce-recover-stale-sessions.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Recover stale Compound Engineering sessions on plugin load and session reads so persisted active rows without live agent handles no longer leave the dashboard stuck waiting for work that is not running. diff --git a/.changeset/fix-appimage-local-runtime-root.md b/.changeset/fix-appimage-local-runtime-root.md new file mode 100644 index 0000000000..01bb88be47 --- /dev/null +++ b/.changeset/fix-appimage-local-runtime-root.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. diff --git a/.gitignore b/.gitignore index ea785505e5..fc21833572 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,7 @@ packages/dashboard/android/ # Plugin hot-reload scratch artifacts **/.index.reload-*.ts + +# Dolt database files +.dolt/ +*.db diff --git a/AGENTS.md b/AGENTS.md index 5725b07d79..f2427f324f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,20 @@ Any task integrating a third-party tool (CLI, daemon, downloadable binary, insta Missing evidence is a blocking REVISE. Never invent release URLs, binary names, or hashes. +Example evidence section shape: + +```markdown +## External Integration Evidence + +- Canonical upstream repo URL: https://github.com/max-sixty/worktrunk +- Docs / homepage URL: https://worktrunk.dev/ +- Release / download URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz +- Binary / CLI name: `wt` +- Checksum: `sha256-` (or `upstream-pending-verification` until the checksum is pinned) +``` + +See `docs/contributing.md` for the fuller spec-authoring guidance and accepted labeled layout variants. + ### Finalizing Changes When a change affects published `@runfusion/fusion`, add a changeset (example: `.changeset/.md` with `"@runfusion/fusion": patch`). @@ -98,6 +112,7 @@ pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); ### Standing Rule: Fix the Invariant, Not the Repro (FN-5893) - When fixing a bug, the regression test must assert the general invariant across ALL known surfaces — not only the single reported reproduction. +- Symptom-based acceptance is mandatory for bug-class tasks: the final verification must reproduce the original failure condition and assert it no longer occurs via a real automated test. Encode this as a `## Symptom Verification` section in PROMPT.md with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**; green build/tests alone are insufficient. This marker is the contract consumed by the GitHub auto-close gate (FN-6230). - Surface enumeration is now an enforced bug-fix artifact: the spec must include a `## Surface Enumeration` section, planning must REVISE when that section is missing, and review must REVISE any repro-only regression test. - The Surface Enumeration gate also applies to tasks that add or remove UI affordances (icons, buttons, chevrons, toggles, badges, menu entries, click targets), including Review Level 0 cosmetic tasks. - Enumerate the surfaces before filing or closing the fix: every provider/bridge for streaming and agent paths, both desktop and mobile breakpoints for UI behavior, empty/undefined/duplicate/populated data states, and every shared hook/component/module/helper that reuses the affected logic. @@ -111,6 +126,12 @@ pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); Never kill processes on port 4040 and never start test servers on 4040. Use `--port 0` or another free port. +### Never run an unbounded `find` against the system temp directory + +Do not issue a recursive `find` (or any unbounded recursive directory walk) rooted at the OS temp directory — `$TMPDIR`, `/tmp`, or macOS `/var/folders/...` (canonical `/private/var/...`). The temp root can hold an enormous number of entries on CI and long-lived dev hosts, so a broad scan can hang for minutes and pin I/O. + +When you need a Fusion temp artifact, target the known prefix directly and list a single level with a prefix filter — never walk the whole temp tree. The canonical bounded pattern is the engine's own sweep: non-recursive `readdirSync(...)` passes over the configured `/.ai-merge/` root plus legacy `.fusion/ai-merge/` and `tmpdir()` leftovers, filtered by a known prefix such as `fusion-ai-merge-` (`SelfHealingManager.cleanupStaleTempMergeWorktrees()` in `packages/engine/src/self-healing.ts`). Scoped `find` calls under a project worktree or `.fusion/` are fine; only the broad temp-root scan is forbidden. + ### Engine Process Rules #### Never use `execSync` for user-configured commands @@ -168,6 +189,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme ### Run Audit - FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes. +- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move. ## Reference docs (deeper detail) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f8d88662..4951d537a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,201 @@ User-facing release notes aggregated across all packages. This file is auto-synced from each `packages/*/CHANGELOG.md` by `scripts/release.mjs` — do not edit by hand. +## 0.42.0 + +### @fusion/dashboard + +#### Patch Changes + +- Updated dependencies [630b2a8] + - @fusion/engine@0.42.0 + - @fusion/core@0.42.0 + - @fusion/i18n@0.39.4 + - @fusion-plugin-examples/cli-printing-press@0.1.21 + - @fusion-plugin-examples/compound-engineering@0.1.4 + - @fusion-plugin-examples/dependency-graph@0.1.35 + - @fusion-plugin-examples/roadmap@0.1.23 + - @fusion-plugin-examples/cursor-runtime@0.1.23 + - @fusion-plugin-examples/droid-runtime@0.1.30 + - @fusion-plugin-examples/hermes-runtime@0.2.54 + - @fusion-plugin-examples/openclaw-runtime@0.2.54 + - @fusion-plugin-examples/paperclip-runtime@0.2.54 + +### @fusion/desktop + +#### Patch Changes + +- @fusion/dashboard@0.42.0 +- @fusion/core@0.42.0 + +### @fusion/engine + +#### Patch Changes + +- 630b2a8: Allow narrowly scoped plan-only operational tasks to complete without source commits when their prompt or metadata explicitly declares no-source/no-code intent and their recorded evidence satisfies the task. The commit guard still rejects missing commits for normal implementation tasks and still enforces worktree and branch invariants before applying the no-commit exemption. + - @fusion/core@0.42.0 + - @fusion/pi-claude-cli@0.42.0 + +### @fusion/plugin-sdk + +#### Patch Changes + +- @fusion/core@0.42.0 + +### @runfusion/fusion + +#### Minor Changes + +- e22afec: Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30. + + These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings. + +- 039d3ce: Fast-mode triage is now expressed as workflow-declared policy: the lean prompt lives in the built-in `default-triage-fast` agent prompt and `planning-fast` seam, while `leanPlanning` and `autoApproveSpec` are workflow-native settings for prompt selection and spec-review auto-approval. + + The internal `FAST_TRIAGE_SYSTEM_PROMPT` engine constant was removed. Existing `executionMode: "fast"` tasks remain byte-equivalent through a single legacy execution-mode-to-resolved-policy bridge. + +- 167f9b0: Allow engineer-role agents to opt into no-task backlog auto-claim for implementation tasks while preserving executor-only default pickup behavior. +- 1c4ec5f: Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings. +- eb607c6: Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width. +- f7f2cae: Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior. +- 4e6df03: Add a verified no-op/duplicate task completion path so executors can close already-satisfied tasks without fabricating commits by using an audited `fn_task_done` sentinel summary. +- 7ffea9f: Expose Google Generative AI as a selectable custom-provider API type in the dashboard settings UI and documentation. +- 508551c: Allow tasks to be archived from any live board column and restored to their pre-archive column. +- bd87ce7: Add workflow-declared optional steps and expose Browser Verification as the built-in coding workflow's opt-in optional step for task creation and editing. +- 72661fa: Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters. +- 07d5262: Sync workflow setting values across nodes in settings push, pull, receive, and status flows. + +#### Patch Changes + +- 8eb99ed: Quick Entry no longer auto-focuses when the board or dashboard becomes visible. +- 36f5ecd: Skip custom workflow pre-merge prompt, script, and gate nodes when a task runs in fast execution mode. +- 1a716f2: Resolve the standard triage planning prompt from the selected workflow IR planning node instead of the removed engine-side `TRIAGE_SYSTEM_PROMPT` duplicate. The built-in `default-triage` prompt is now the canonical policy source for `builtin:coding`; where the old copies disagreed, the surviving canonical subtask-split threshold is `MORE THAN 7 implementation steps` (with the matching `MORE THAN 3 different packages/modules` guidance). Fast-mode triage continues to use `FAST_TRIAGE_SYSTEM_PROMPT` unchanged. +- fb2c6e5: Resolve the built-in reviewer base prompt from the workflow IR `review` node instead of an engine-local `REVIEWER_SYSTEM_PROMPT` duplicate. The canonical reviewer policy now lives in the `default-reviewer` agent prompt / built-in workflow seam, with reconciled superset content that preserves the FN-5928/FN-6229 surface-enumeration and symptom-verification gates, undersplit-task guidance, test-quality rules, worktree-boundary review, and the embedded port-4040 safety rule. +- c0ff360: Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control. +- 12621aa: Record explicit `builtin:coding` project-default workflow selections even when the compiled built-in has zero materialized steps, while preserving interpreter-deferred `builtin:stepwise-coding` fallback behavior. +- 30e747b: Standalone plugin scaffolds now declare the dev toolchain they generate scripts and config for: `@types/node`, `vitest`, and `typescript`. This lets projects created with `fn plugin new` install, build, test, and load through `fn plugin dev . --once` via the documented external-author path without relying on transitive or hoisted dependencies. + + Manual spot-check for release validation: + + ```sh + npx @runfusion/fusion@latest plugin new proof-point-plugin + cd proof-point-plugin + pnpm install + pnpm build + pnpm test + fn plugin dev . --once + ``` + +- 8c16395: Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run. +- d5b45c8: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. +- f0d2415: Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. + + The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty. + +- a83c2d8: Fix custom provider models not appearing in model dropdowns. The `/models` endpoint filtered results to providers configured in Fusion's auth stores, which excluded custom providers (stored in global settings). Their registry keys are now added to the allowlist so their models surface in pickers. +- cbc3157: Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll mutations were blurring the focused composer textarea: + + 1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus. + + 2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock. + + 3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op. + + 4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor. + +- cbc3157: Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected. +- e5036b1: Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap. +- 535c40d: Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again. +- e35f3dd: Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics. +- 3a729f5: Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks. +- c285f3f: Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution. +- 9a78814: Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed. +- 2085610: Move AI-merge clean-room worktrees into a repo-local cleanup-exempt root, guard cleanup sweeps by active merge ownership, and classify missing clean-room worktree failures as transient so merges can retry cleanly. +- d23c5d9: Fix task detail Pull Request and Review surfaces so they use the live project auto-merge setting instead of a stale modal-open snapshot. Create PR / manual merge affordances now appear immediately when auto-merge is toggled off, and the automatic auto-merge hint returns when it is toggled back on. +- 4fc00b6: Self-heal compound-engineering answer submission for restarted awaiting-input sessions by rehydrating the interactive session before sending the answer. +- 65251d2: Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work. +- bffae81: Add `autoMergeProvenance` so Fusion can distinguish explicit per-task auto-merge overrides from legacy review-entry stamps. Startup now marks ambiguous legacy in-review `autoMerge: true` rows as `legacy-stamp` without changing behavior, and the operator-visible `reconcileLegacyAutoMergeStamps` action (dry-run by default) can clear those legacy stamps so global auto-merge OFF is respected while genuine user overrides are preserved. +- 0897b2a: Add a bounded persisted auto-retry for transient workflow-graph resume failures after engine restart or unpause, while preserving terminal failures for genuine graph errors. +- ec4b247: Re-fire durable-agent assignment wakes that were skipped because the agent was mid-heartbeat, so newly assigned tasks are worked when the active run completes instead of waiting for the next timer tick. +- 751d942: Fix workflow graph execution for the built-in coding workflow's merge-policy primitive region by collapsing any merge-region entry back to the legacy `merge` seam until the workflow interpreter owns merge policy execution. +- 93237c3: Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat. +- 480e55f: Fix non-English Active Agents next-heartbeat translations so localized strings interpolate the provided elapsed heartbeat value instead of showing a raw placeholder. +- 0a135c9: Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates. +- 66591ec: Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup. +- a9b1139: Self-healing now automatically re-dispatches an assigned in-progress task when its durable agent loses both the heartbeat run and active execution session, preventing the task from stranding until the next engine restart. +- f2054d0: Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow. +- 34ada00: Show user-sent task-detail Chat steering messages as You bubbles and keep them visible after steering requests persist. +- 35554e6: Keep the task-detail Chat composer pinned and visible while the transcript scrolls internally on mobile and desktop. +- e0ec3d1: Steering messages sent from task chat now reach active step-session and workflow runs, including parallel step sessions, and the misleading inactive-session "next session" composer copy was removed. +- f68775a: Ensure only explicit user actions unpause user-paused tasks. Engine self-healing, agent resume cascades, dashboard agent-state resume fallback, heartbeat recovery, and approval-decision resume no longer clear `userPaused` or auto-unpause tasks the user paused. +- 4ea9d66: Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults. +- 44b756d: Fix built-in branching workflow selection so interpreter-deferred coding workflows can be selected or used as project defaults without throwing during legacy step materialization. +- e6eef1a: Handle insight extraction agent responses deterministically by accepting prompt return text, falling back to session state, and surfacing a 503 error when no assistant text is produced. +- e305b1a: Respect per-task pause state during triage planning so paused tasks do not auto-advance after specification approval. +- 40cb0d3: Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations. +- f16b038: Add workflow work-item storage primitives for workflow-owned merge migration. + +### runfusion.ai + +#### Patch Changes + +- Updated dependencies [8eb99ed] +- Updated dependencies [36f5ecd] +- Updated dependencies [1a716f2] +- Updated dependencies [e22afec] +- Updated dependencies [fb2c6e5] +- Updated dependencies [039d3ce] +- Updated dependencies [c0ff360] +- Updated dependencies [167f9b0] +- Updated dependencies [1c4ec5f] +- Updated dependencies [12621aa] +- Updated dependencies [30e747b] +- Updated dependencies [eb607c6] +- Updated dependencies [8c16395] +- Updated dependencies [d5b45c8] +- Updated dependencies [f0d2415] +- Updated dependencies [a83c2d8] +- Updated dependencies [cbc3157] +- Updated dependencies [cbc3157] +- Updated dependencies [e5036b1] +- Updated dependencies [535c40d] +- Updated dependencies [e35f3dd] +- Updated dependencies [3a729f5] +- Updated dependencies [c285f3f] +- Updated dependencies [f7f2cae] +- Updated dependencies [9a78814] +- Updated dependencies [2085610] +- Updated dependencies [d23c5d9] +- Updated dependencies [4fc00b6] +- Updated dependencies [65251d2] +- Updated dependencies [4e6df03] +- Updated dependencies [bffae81] +- Updated dependencies [0897b2a] +- Updated dependencies [ec4b247] +- Updated dependencies [7ffea9f] +- Updated dependencies [751d942] +- Updated dependencies [508551c] +- Updated dependencies [93237c3] +- Updated dependencies [bd87ce7] +- Updated dependencies [72661fa] +- Updated dependencies [480e55f] +- Updated dependencies [0a135c9] +- Updated dependencies [66591ec] +- Updated dependencies [a9b1139] +- Updated dependencies [f2054d0] +- Updated dependencies [34ada00] +- Updated dependencies [35554e6] +- Updated dependencies [e0ec3d1] +- Updated dependencies [f68775a] +- Updated dependencies [4ea9d66] +- Updated dependencies [44b756d] +- Updated dependencies [e6eef1a] +- Updated dependencies [07d5262] +- Updated dependencies [e305b1a] +- Updated dependencies [40cb0d3] +- Updated dependencies [f16b038] + - @runfusion/fusion@0.42.0 + ## 0.41.0 ### @fusion/dashboard @@ -8700,6 +8895,14 @@ for reference. - Updated dependencies [a2ed6d0] - @runfusion/fusion@0.1.0 +## 0.39.4 + +### @fusion/i18n + +#### Patch Changes + +- @fusion/core@0.42.0 + ## 0.39.3 ### @fusion/i18n @@ -8724,6 +8927,14 @@ for reference. - @fusion/core@0.40.0 +## 0.11.30 + +### @fusion/droid-cli + +#### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.30 + ## 0.11.29 ### @fusion/droid-cli diff --git a/README.md b/README.md index 54ef74879d..fd66b7f911 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,13 @@ Every task shows its plan, its reviews, its diffs, and its file changes in real | | | |---|---| | 🧠 **AI planning** | Describe a task in plain language. Planning agents turn it into a `PROMPT.md` plan with steps, file scope, and acceptance criteria. | -| 🔁 **Workflow gates** | Plan → Review → Execute → Review on every step. Pre-merge gates block bad code; post-merge gates run informational checks. | +| 🔁 **Workflow gates** | Plan → Review → Execute → Review on every step. Pre-merge gates block bad code; post-merge gates run informational checks; workflow-declared optional steps such as [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) can be enabled per task. | | 🌳 **Worktree isolation** | Each task runs in its own branch and worktree (`fusion/{task-id}`). Parallel tasks. Zero conflicts. Optional [worktrunk](https://github.com/max-sixty/worktrunk) delegation via [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (see [WorktreeBackend abstraction](./docs/architecture.md#worktreebackend-abstraction)). | -| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere. | +| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere, or let tasks follow the live global auto-merge default unless they have an explicit per-task override. | | 🛰️ **Multi-node mesh** | Laptop, Mac mini, Linux server, cloud VM, phone — all synced. Desktop, mobile, web. | -| 🧩 **Any model** | Anthropic, OpenAI, Ollama, and more. Local and cloud coexist. | +| 🧩 **Any model** | Anthropic, OpenAI, Ollama, Google Generative AI, and user-defined [custom providers](./docs/dashboard-guide.md#custom-providers). Local and cloud coexist, with workflow model lanes configurable per project. | | 🏢 **Agent companies** | Import pre-built teams — 440+ agents across 16 companies — and run them autonomously for weeks. | -| 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate. | +| 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate; engineer-role agents can opt into backlog auto-claim when you want implementation help beyond executor-only pickup. | | 🗨️ **Multi-agent Chat Rooms** | Project-scoped group conversations where multiple room members can reply: mentioned members are direct responders, and additional ambient members may respond up to a cap. Currently **experimental** — enable `chatRooms` in **Settings → Experimental Features → Chat Rooms**. ([Chat Rooms docs](./docs/dashboard-guide.md#chat-rooms)) | | 🗺️ **Missions** | Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot and validation contracts. | | 🔬 **Research** | Bounded research runs with web search, GitHub, local docs, and LLM synthesis (plus runtime builtin WebSearch/WebFetch support in planning + synthesis flows when available). Turn findings into tasks. ([Docs](./docs/research.md)) | @@ -299,12 +299,15 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md). - **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria - **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step - **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) -- **Workflow Steps** — Configurable quality gates (pre-merge: blocks merge; post-merge: informational) +- **Workflow Steps** — Configurable quality gates (pre-merge: blocks merge; post-merge: informational), plus workflow-declared optional steps such as opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning (`leanPlanning` / `autoApproveSpec`) and typed triage thresholds are workflow settings, not hard-coded engine constants ([Settings Reference](./docs/settings-reference.md#workflow-native-triage-policy-settings); [fast-mode step behavior](./docs/workflow-steps.md#execution-modes)) - **GitHub Integration** — Import issues, create PRs, real-time PR/issue badges -- **Dashboard** — Real-time kanban board, agent management, terminal, git manager, mission planner +- **Dashboard** — Real-time kanban board, agent management, terminal, git manager, mission planner, custom provider setup, and workflow model lanes - **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, and blocked-handoff semantics - **Multi-Project** — Manage multiple projects from a single installation with project isolation -- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in Project Models and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers); [settings shape](./docs/settings-reference.md#customproviders)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior ([Settings Reference](./docs/settings-reference.md#project-settings)) +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim for implementation tasks ([Settings Reference](./docs/settings-reference.md#project-settings)) - **Chat Rooms (Experimental)** — Project-scoped group chat where mentioned members are routed as direct responders and additional ambient members may reply up to a cap (enable via **Settings → Experimental Features → Chat Rooms**; details in [Dashboard Guide → Chat Rooms](./docs/dashboard-guide.md#chat-rooms)) ### Provider authentication @@ -316,6 +319,7 @@ Fusion supports OAuth-based authentication for AI providers configured via **Set - **Factory AI — via Droid CLI** *(optional)* — requires local Droid CLI install + `droid auth login`; detection follows the effective runtime binary path (default `droid`, or plugin `droidBinaryPath` when configured), then enable in **Settings → Authentication** and restart Fusion - **llama.cpp — via HTTP server** *(optional)* — configure your llama.cpp server URL (default `http://127.0.0.1:8080`) and optional API key, then enable in **Settings → Authentication** - **Other providers** — Authenticate via API key entry in Settings (including Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code aliases) +- **Custom providers** — Add user-defined OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI endpoints from **Settings → Authentication → Custom Providers**; saved model IDs become selectable in project and workflow model lanes ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) ### Model system @@ -329,6 +333,8 @@ Fusion uses a dual-scope model hierarchy with five independent lanes. Global set | Title Summarization | Auto-title generation | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Workflow Step Refinement | AI prompt refinement | (uses `defaultProvider`/`defaultModelId`) | (uses `modelProvider`/`modelId` on WorkflowStep) | +**Workflow lanes:** The default workflow exposes Plan/Triage, Executor, and Reviewer model lanes in **Settings → Project Models**, and advanced workflow settings can declare additional typed model/policy values ([Settings Reference](./docs/settings-reference.md#workflow-settings)). + **Per-Task Overrides:** Tasks can override the executor, validator, and planning lanes with per-task model fields (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`). **Precedence:** Per-task → Project override → Global lane → `defaultProvider`/`defaultModelId` → Automatic resolution. diff --git a/docs/README.md b/docs/README.md index ba6b26520c..7896ab59ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,7 +55,6 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Storage](./storage.md) | Storage architecture, migration, archive system, and SQLite schema | | [DAG Architecture Deliverables](./dag/) | Milestone A DAG architecture documents plus Milestone B prototype scaffold docs (schema migration plan, DagCoordinator design, implementation checklist) | | [Dev Server Module Audit](./dev-server-modules.md) | Analysis of parallel dashboard dev-server module families, production wiring, and consolidation guidance | -| [Beads and Dolt Evaluation for Fusion Node Sync](./beads-dolt-sync-evaluation.md) | Evaluation of Beads and Dolt for node sync, with a recommendation for Fusion-native sync design | | [Shared Mesh Replication Protocol](./shared-mesh-protocol.md) | Canonical multi-leader replication/write-coordination contract (versioning, quorum, leases/fencing, queue/replay, reconciliation, and degraded-read semantics) | | [Multi-Project Sequencing and Dependency Analysis](./multi-project-sequencing.md) | Sequencing guidance for FN-3448/FN-3449/FN-3503/FN-3182, including identity boundaries and recommended board dependency edges | | [Contributing](./contributing.md) | Local development setup, testing, release flow, and contributor conventions | @@ -116,6 +115,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Test Speed Audit (FN-5048)](./test-speed-audit-FN-5048.md) | Measured baseline test performance, offender list, and optimization priorities | | [Soft-Delete Verification Matrix](./soft-delete-verification-matrix.md) | Authoritative checklist for the FN-5105 → FN-5143 soft-delete stream: scenario × layer coverage | | [Self-Healing Backward Move Audit](./self-healing-backward-move-audit.md) | Audit of self-healing backward-move safety checks and edge-case validation | +| [Workflow Policy Ownership Map](./workflow-policy-ownership-map.md) | U1 characterization map classifying production merge, retry, scheduling, and recovery policy branches before workflow-policy migration cutover | | [Test-Speed Baseline (2026-06-03)](./test-speed-baseline-2026-06-03.md) | Measured per-file test timing baseline and optimization targets (successor to FN-5048 audit) | | [ACP Runtime Contract](./acp-contract.md) | Agent Client Protocol plugin launch/readiness contract and failure taxonomy | | [Mission Completion Gate Contract](./missions-completion-contract.md) | Decision record for mission completion gate invariants and acceptance flow | diff --git a/docs/agents.md b/docs/agents.md index bdab5b387e..bd61143c97 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -260,23 +260,23 @@ Programmatic equivalent: ### Assigned-agent runtime model precedence for task execution -When a task is executed by an assigned durable agent, executor session model selection now prefers that agent's explicit runtime model when it is fully specified. +When a task is executed by an assigned durable agent, executor session model selection prefers fresh task and settings values before the agent's stored runtime model. Executor precedence for task runs: -1. Assigned agent `runtimeConfig` model pair (combined `runtimeConfig.model = "provider/modelId"` or separate `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are present -2. Task `modelProvider` + `modelId` -3. Project/global execution lane fallbacks (same resolution as unassigned runs) +1. Task `modelProvider` + `modelId` +2. Project/global execution lane fallbacks (same resolution as unassigned runs) +3. Assigned agent `runtimeConfig` model pair (combined `runtimeConfig.model = "provider/modelId"` or separate `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no task/settings pair is configured -If the assigned agent runtime model is missing or incomplete, Fusion falls back to the normal task/settings execution hierarchy. +If the assigned agent runtime model is missing or incomplete, Fusion continues to automatic provider/model resolution without mixing partial runtime fields into the selected pair. ### Durable-agent heartbeat model precedence and unavailable-provider behavior -Heartbeat sessions for durable agents resolve models with heartbeat-specific fallback semantics: +Heartbeat sessions for durable agents resolve models with the same fresh-settings-first rule: -1. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present -2. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) +1. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) +2. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no execution/default pair is configured -When the runtime model is present and differs from execution-lane settings, heartbeat passes the execution-lane model as a fallback pair for session creation. +Heartbeat no longer passes a stale runtime model ahead of a saved execution lane or project default override. Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root. Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist. @@ -511,6 +511,7 @@ The `runtimeConfig` field on agents supports the following options: | `enabled` | `boolean` | `true` | Whether heartbeat triggers are enabled for this agent | | `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) | | `autoClaimRelevantTasks` | `boolean` | `true` | During no-task heartbeats, opportunistically claim unowned relevant todo tasks that align with the agent's role/soul | +| `engineerBacklogAutoClaim` | `boolean` | inherits project (`false`) | Opt this engineer-role agent into no-task backlog auto-claim for implementation tasks. Executor-role agents remain eligible by default; explicit routing/delegation is unchanged. | | `autoClaimCandidatesInPrompt` | `number` | `5` | Per-agent override for no-task candidate lines rendered in prompts. Integer `0-10`; `0` suppresses candidate injection. | | `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) | | `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent | @@ -527,6 +528,10 @@ The `runtimeConfig` field on agents supports the following options: | `modelId` | `string` | — | AI model ID override for heartbeat session | | `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings | +Assignment-triggered heartbeats are completion-resilient: if an `agent:assigned` wake is skipped only because the durable agent already has an active heartbeat run, Fusion records the latest assigned task as a pending assignment and re-fires that assignment wake once the active run completes. This prevents assigned work from being stranded by long heartbeat intervals or `skipHeartbeatWhenIdle`; disabled agents (`enabled === false`) and budget-exhausted agents still do not defer assignment wakes. + +Self-healing also covers abnormal run/session loss for assigned `in-progress` work. If the task remains assigned but the durable agent has no active heartbeat run and no active executor session after the orphan grace window, `reattach-orphaned-assigned-executions` re-dispatches the task forward via `executor.resumeTaskForAgent(agentId)` without pausing, failing, or moving the task backward. + Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms). Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat timing globally: both the heartbeat interval (`pollIntervalMs`) and unresponsive timeout base (`heartbeatTimeoutMs`) are multiplied. Per-agent `heartbeatIntervalMs`/`heartbeatTimeoutMs` remain base values before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed". @@ -556,6 +561,8 @@ When an identity-bearing, non-ephemeral agent wakes with no assigned task and `r Guardrails: - Only unpaused, unassigned, unchecked-out todo tasks with satisfied dependencies are considered - Claims are rejected for terminal/paused/owned/conflicting tasks +- Implementation-task backlog pickup is executor-only by default. Engineer-role agents may opt in through project setting `engineerBacklogAutoClaim` or per-agent `runtimeConfig.engineerBacklogAutoClaim`; the per-agent value overrides the project default in both directions. +- Explicit task routing/delegation is not affected by the backlog auto-claim opt-in gate. - Checkout safety is preserved (`checkout_conflict` paths are non-fatal skips) - On successful claim, the same heartbeat run switches into task-scoped execution (no nested run re-entry) @@ -1222,7 +1229,7 @@ Effects: - Agent state transitions `running/active → paused → active` - Orphan reconcile uses `3 × heartbeatTimeoutMs` where the timeout is likewise multiplier-scaled first - `pauseReason` is set to `heartbeat-unresponsive` during recovery and cleared on resume -- Assigned tasks are auto-paused with `pausedByAgentId` during pause, then only those same tasks are auto-unpaused on resume +- Assigned tasks are not paused or unpaused by agent sleep/heartbeat recovery; unpaused work stays eligible for scheduler re-dispatch, while tasks already paused by a user retain their existing pause state - Resume triggers one on-demand heartbeat restart only when `runtimeConfig.enabled !== false` - `onTerminated` is a run-level callback for terminated heartbeat runs and is not used by unresponsive recovery diff --git a/docs/architecture.md b/docs/architecture.md index 6319f2e9d8..5b89610817 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -617,6 +617,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then dependency-unblock fanout within the same priority class (FN-4969), then FIFO (`createdAt` ascending) with task-id fallback. `urgent` always stays ahead of lower priorities, and overlap/file-scope blockers are excluded from fanout weighting. - `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state. - Dependency-cycle invariant (FN-5256): task dependency graphs are acyclic at write time (`DependencyCycleError` in `TaskStore` for `createTask`, `createTaskWithReservedId`, `updateTask`, and `applyReplicatedTaskCreate`) with `task:dependency-cycle-rejected` audit evidence. Self-healing batch 2 adds `reconcileDependencyCycles`, which emits `task:dependency-cycle-detected`, auto-repairs only bounded umbrella-back-edge loops via `task:auto-reconciled-dependency-cycle`, and leaves ambiguous cycles untouched with `task:dependency-cycle-unrepaired` for operator inspection. + - Dependency-blocking lease invariant (FN-6292): an `in-progress` task with unmet scheduling dependencies must not contribute an active file-scope lease in scheduler lease maps. This prevents a holder from queueing its own dependency behind its lease and creating a circular wait. #### BlockedBy stamping invariants - Scheduler writes overlap-based `blockedBy` only when overlap gating is active and there is a live overlapping active scope; otherwise overlap logic does not stamp blockers. @@ -625,6 +626,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - When the blocker must change, selection is deterministic: active overlap candidates are ordered by task ID and the first overlapping task is chosen, removing tick-order churn. - Writes are idempotent: scheduler updates `status/blockedBy` only when values change, reducing per-tick churn and audit noise. - Self-healing remains responsible for terminal/missing blocker cleanup (`clearStaleBlockedBy()`), while scheduler overlap stamping now focuses on stable active-overlap attribution. +- `reconcileDependencyBlockingLeases()` (FN-6292) unwinds existing dependency/lease circular waits: when an `in-progress` holder has unmet scheduling dependencies and an unmet dependency is blocked by the holder's stale file-scope lease, self-healing gates the backward move with triple proof, moves the holder back to `todo` with progress/worktree/resume state preserved, and emits `task:reconcile-dependency-blocking-lease` (or `task:reconcile-dependency-blocking-lease-no-action` when proof fails). Engine rebounds do not set `userPaused`. - `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution - `createTaskUpdateTool()` (`executor.ts`) emits a diagnostic warning when an agent marks step N `in-progress` while another step on the same task is already `in-progress`; the update still proceeds so operators get evidence without changing task semantics. - `TaskCompletion` (`task-completion.ts`) — completion gate helpers @@ -670,15 +672,21 @@ Runtime action-gate flow (v1): - `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification - `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`). - - Batch 1 also sweeps stale AI merge clean-room worktrees under `tmpdir()` whose names start with `fusion-ai-merge-`. The default age gate is 2 hours, but task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and treats missing/deleted task rows as immediately stale. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. It is intentionally native even when `worktrunk.enabled` because these temp-dir worktrees are outside the worktrunk-managed project layout. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. + - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. + - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. + - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. + - `reattach-orphaned-assigned-executions` is a forward-resume safety net for durable-agent assignments. During startup recovery and periodic maintenance, after orphaned-agent and stale-heartbeat-run repairs, self-healing finds `in-progress` tasks with an `assignedAgentId` whose agent has no active heartbeat run and no active executor session after the orphan grace window. It re-dispatches in place via `executor.resumeTaskForAgent(agentId)` (the same seam used by clean `HeartbeatMonitor.onRunCompleted` and guarded by executor double-execution checks), emits `task:reattach-orphaned-execution`, and never moves the task backward. This complements engine-start `executor.resumeOrphaned()` and leaves unassigned/role-based execution recovery to the existing startup/limbo/stuck-task paths. - Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`. #### Stuck-loop exhaustion terminal contract When stuck-kill retries are exhausted, `checkStuckBudget()` marks the task `status: "failed"`, moves it to `in-review`, and writes an error that starts with `STUCK_LOOP_EXHAUSTED:`. The error and final task-log line both include the kill count/max and last stuck reason (`loop` or `inactivity`). `StuckTaskDetector` also untracks the task and refuses to re-track it while that failed terminal error remains, preventing further automatic kill/requeue churn. The final log line explicitly states that no further automatic retries will run and directs operators to manually retry, pause, or move the task back to triage to resume work. + +If loop recovery times out during compact-and-resume and the executor does not unwind within the bounded force-requeue grace window, `TaskExecutor.markStuckAborted()` now hard-cancels the hung task before clearing execution guards: spawned child agents are terminated, `awaitAbortInFlightTaskWork()` reaps API/step/workflow/configured-command/subagent/CLI surfaces, the task worktree is removed with `RemovalReason.ExecutorStuckKilled`, stale in-memory worktree/loop/paused/stuck state is cleared, and then the task is moved back to `todo` with the configured `preserveProgressOnStuckRequeue` semantics. The path preserves the concurrent-recovery guard: if the latest task column is no longer `in-progress`, it only clears the execution guard and does not reap/remove resources that a self-healing recovery now owns. Task logs distinguish loop detection, compaction timeout, force-kill cleanup start, force-requeue, and cleanup completion/failure. - `recoverMissingWorktreeReviewFailures()` is a narrow failed-review recovery: only `status: "failed"` `in-review` tasks with the explicit session-start signature `Refusing to start coding agent in missing worktree:` (from `assertValidWorktreeSession()`) are requeued. Recovery clears stale session metadata (`worktree`, `branch`, `sessionFile`, transient failure state), preserves valid step progress/retry counters, logs the auto-recovery reason, and moves the task back to `todo` for a clean retry. - `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`. - `recoverAlreadyMergedReviewTasks()` auto-finalizes retry-exhausted `in-review` tasks when self-healing can prove their work already landed on the merge target. On this landed-content path it clears soft blockers (`paused`, stale `status: "failed"`, and residual `error`) before moving to `done`; true hard blockers (for example incomplete steps, awaiting-user-review, or failed pre-merge workflow steps) still park the task in stable `in-review/failed` state with a blocker error instead of entering an auto-finalize loop. + - `recoverTransientMergeFailures()` handles retry-exhausted `in-review` merge failures only when `classifyTransientMergeError()` returns a bounded transient class: `lease-handoff-target-not-queued`, `spurious-concurrent-advance-same-sha`, or `process-spawn-failure` (`spawn ENOTDIR`, `spawn … ENOENT`, or a clean-room path reported as `is not a working tree`). Recovery resets `mergeRetries`, clears transient `status`/`error`, increments `mergeDetails.transientRecoveryCount`, and requeues auto-merge so the next attempt recreates the AI-merge clean room. The budget stays capped by `MAX_TRANSIENT_MERGE_RECOVERIES`; exhausted tasks remain parked with the `merger:transient-failure-budget-exhausted` audit path so real structural failures cannot loop forever. FN-6278 makes this recovery mostly after-the-fact insurance for cwd spawn faults: the merge runner now preflights reuse integration roots and repairs/reacquires missing or de-registered task worktrees before the first git spawn, so a stale `task.worktree` should not consume the transient recovery budget by repeatedly producing `spawn git ENOENT`. - `reconcileTaskWorktreeMetadata()` (FN-4962) reconciles stale `task.worktree`/`task.branch` rows against authoritative `git worktree list --porcelain` branch mappings during startup recovery, periodic maintenance, and completion fan-out. The stage must run before `reclaim-stale-active-branches`: stale rows rebound to live `fusion/` worktrees emit `task:auto-recover-worktree-metadata-rebound`; stale rows with no live branch mapping are nulled (`worktree=null`, `branch=null`, `baseCommitSha` unchanged) and emit `task:auto-recover-worktree-metadata-cleared`. - `recoverInProgressLimbo()` (FN-5219) is the safety net for stranded executor rows: reset/requeue paths must never leave a task in `in-progress` without a runnable execution context. After metadata reconcile, stale `in-progress` tasks with null branch, missing/cleared worktree metadata, no live executor claim, and all-pending steps are audited and moved back to `todo`. @@ -1062,15 +1070,17 @@ Mesh configuration and post-provision managed-node operations are registered sep ### Run Audit API The run-audit system records every mutation performed by the engine across four domains: - **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued). -- **Git** — worktree:create, commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry). +- **Git** — worktree:create, worktree:remove, `worktree:remove-fallback` (metadata `{ fallback: "filesystem-non-empty", error }` when native git removal falls back to filesystem removal + admin prune), commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry). - **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360). - **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `..HEAD` has zero attributable own commits but the source `fusion/` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`. - **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`). - **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`. - **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put). +- **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution. - **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`. - **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution. -- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition. +- **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list. +- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`, `task:reconcile-dependency-blocking-lease-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition. - **Filesystem** — file:write, prompt:write, attachment:create, etc. - **Sandbox** — backend lifecycle events from `SandboxBackend` wiring in executor/merger/routine-runner (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) introduced after FN-4636. @@ -1211,6 +1221,11 @@ Task steps use statuses: `pending`, `in-progress`, `done`, `skipped`. - **Pre-merge** steps run in executor (`runWorkflowSteps()`) — bypassed in fast mode - **Post-merge** steps run in merger (`runPostMergeWorkflowSteps()`) +### Task pause ownership +- Only explicit user actions pause ordinary tasks: the dashboard/CLI task pause controls and manual `in-progress → todo` moves. System safety pauses remain reserved for explicit approval waits and bounded guardrails such as token-budget, worktrunk-failure, and dispatch-oscillation protection. +- Agent pause/sleep and heartbeat recovery never pause assigned tasks. Assigned tasks stay in their current column and retain their existing `paused`/`pausedByAgentId` state so the scheduler can re-dispatch unpaused work and user-paused work remains intentionally parked. +- Only explicit user unpause actions may clear `task.userPaused`; engine self-healing, heartbeat/agent resume cascades, and approval resume paths must leave user-paused tasks parked. + ### User cancel via move-to-todo - `TaskStore.moveTask()` accepts `moveSource: "user" | "engine"` (default `"engine"`) and emits `task:moved` with `source` so listeners can distinguish manual moves from engine rebounds. - Manual `in-progress → todo` moves (dashboard route `/tasks/:id/move` with `moveSource: "user"`) atomically set `task.userPaused = true`; engine/default rebounds do not. @@ -1259,6 +1274,7 @@ The columns/traits track moved *board* policy (transitions, capacity, hold, merg - A `parse-steps` node reads a workflow-declared **artifact** (PROMPT.md is just the default workflow's declared `step-source` artifact) and runs a registry **parser** (`step-headings`, `json-steps`, or a plugin-contributed parser) to write `Task.steps[]`. It is the only graph-side step-list writer and must dominate any `foreach`. Parsers fail closed to a routable `outcome:parse-error`. - A `foreach(source:"task-steps")` node instantiates an inline template subgraph once per planned step, with `mode` (sequential/parallel) and `isolation` (shared/worktree) as explicit axes and per-instance run-state pinned + persisted for crash-safe resume. +- Resume-limbo graph failures are retried only through a narrow persisted counter (`Task.graphResumeRetryCount`, max 2). The executor classifies a failure as transient only when it happens immediately after the engine restart/unpause resume log marker, reports no graph `reason`, has no completed step progress, and the task has no durable `lastError`/`failureReason`; it clears transient `status`/`error`, logs the auto-retry, and schedules one more graph execution. Any explicit graph reason, completed step progress, durable task error, missing resume marker, or exhausted counter remains a genuine `status:"failed"` disposition and goes to review handoff, preserving the FN-5704 anti-loop contract. - A `step-review` node surfaces reviewer verdicts (APPROVE/REVISE/RETHINK/UNAVAILABLE) as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route REVISE/RETHINK back to `step-execute`, with RETHINK traversal triggering the reset seam. - A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic — the same trust tier as project-local script steps. @@ -1298,6 +1314,7 @@ Limits are controlled by project settings (`maxSpawnedAgentsPerParent`, `maxSpaw - timer - task assignment - on-demand runs +- Assignment triggers skipped because a heartbeat run is already active are deferred and re-fired from `HeartbeatMonitor.onRunCompleted`, preserving the existing completion recovery path while avoiding timer-dependent stalls. ### Custom instructions `packages/engine/src/agent-instructions.ts` resolves per-agent instruction text/path with path-traversal and extension validation. @@ -1593,7 +1610,7 @@ The GitHub tracking state listener now attaches to every registered project stor - Worktrunk layout is authoritative on create: after `wt switch --create`, Fusion resolves the actual registered worktree path via `git worktree list --porcelain` and uses that path (instead of assuming `resolveTaskWorktreePath` alignment). - Delegated operation surface in the interface: `create`, `sync`, `prune`, `remove` (plus backend path resolution via `resolveWorktreePath`). - Executor acquisition paths (`worktree-acquisition.ts`) resolve backend selection centrally, so create flow stays backend-agnostic above the pool/acquisition layer. -- Worktree removal is backend-mediated across merger, self-healing, worktree-pool, executor, and step-session cleanup paths via `removeWorktree(...)` (`WorktreeBackend.remove()`). +- Worktree removal is backend-mediated across merger, self-healing, worktree-pool, executor, and step-session cleanup paths via `removeWorktree(...)` (`WorktreeBackend.remove()`). Native removal first runs `git worktree remove --force`; when git reports recoverable on-disk cleanup failures such as `Directory not empty`, `failed to delete`, or modified/untracked content, it falls back to async filesystem removal and `git worktree prune` (`pruneWorktreeAdminEntries`) so both the directory and dangling admin entry are cleared. - Self-healing is worktrunk-aware for failure recovery: tasks paused with `pausedReason: "worktrunk_operation_failed"` are explicitly skipped in reclaim sweeps (`self-healing.ts`) until operator intervention. - Failure contract: delegated worktrunk errors preserve stderr context (`WorktrunkOperationError`) and are handled by `worktrunk.onFailure` — `"fail"` pauses the task, while `"fallback-native"` retries on the native backend and emits one-shot fallback telemetry. - Install contract: Fusion only auto-installs from a source-of-truth manifest. The shipped placeholder manifest intentionally stays in `upstream-pending-verification` until a human verifies upstream asset URLs and checksums, so install attempts fail closed rather than guessing release metadata. @@ -1615,7 +1632,7 @@ The GitHub tracking state listener now attaches to every registered project stor - Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`) - `aiMergeTask()` in `merger.ts` performs merge flow - FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5846 extends the same contract to deterministic/self-healing finalize paths (`recoverAlreadyMergedReviewTasks`, interrupted/deadlock/misbound finalizers, and the `mergeConfirmed` fast path): a resolvable shared member is re-routed to the group branch before reachability checks, `mergeTargetSource`/`mergeTargetBranch` are stamped by the finalizer, `recordBranchGroupMemberLanded` is called, and a defensive audit event is emitted if a path would otherwise evaluate the member against the project default branch. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry; FN-5830 lands the completion gate + promotion machinery via `evaluateBranchGroupCompletion` and idempotent `promoteBranchGroup` (single shared→default merge/PR with finalized status and PR tracking persistence). -- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`. +- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-6278 adds a stable cwd preflight before root-derived git spawns: in `reuse-task-worktree` mode, an empty, missing, incomplete, or de-registered `task.worktree` is repaired/reacquired before the first spawn, while `cwd-integration-branch` remains a no-op project-root path. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`. - `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior) - Supports workflow-step execution after merge (post-merge phase) - Deterministic verification now runs a bootstrap preamble (`node scripts/ensure-test-artifacts.mjs`) before configured `testCommand`/`buildCommand`, then self-heals Vite `Failed to resolve entry for package "@fusion/..."` workspace-entry faults by rebuilding the missing package once and retrying the failed command. If that retry still reports the same missing-entry fault, merger raises a typed environment fault and `ProjectEngine` leaves the task in-review (no verificationFailureCount increment or in-progress bounce) so the next recovery sweep can retry after other runs rebuild artifacts. @@ -1775,8 +1792,9 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight. - **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat. - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. +- **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused. - **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. -- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. +- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). - **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off. - **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter. diff --git a/docs/beads-dolt-sync-evaluation.md b/docs/beads-dolt-sync-evaluation.md deleted file mode 100644 index b2f8c00967..0000000000 --- a/docs/beads-dolt-sync-evaluation.md +++ /dev/null @@ -1,501 +0,0 @@ -# Beads and Dolt Evaluation for Fusion Node Sync - -[← Docs index](./README.md) - -## Summary - -Recommendation: **do not switch Fusion wholesale to either Beads or Dolt for node sync right now**. - -Use them as design references or optional experiments, but keep Fusion’s current SQLite + filesystem hybrid model and add an explicit Fusion-native sync layer. - -| Option | Recommendation | -|---|---| -| Beads | Useful inspiration for local-first issue/task sync, but too domain-specific to become Fusion’s persistence or sync substrate. | -| Dolt | Technically interesting for versioned relational data and SQL merge semantics, but too heavy and operationally different from Fusion’s embedded SQLite model. Consider only as an experimental backend or audit/export target. | -| Best path | Keep SQLite. Add a Fusion-native sync protocol based on append-only change events, per-record revisions, deterministic conflict policies, and blob transfer for `.fusion/tasks/*`. | - -## Fusion’s Current Sync Requirements - -Fusion is more than a task board. Current persistence includes: - -- Project DB: `.fusion/fusion.db` -- Central DB: `~/.fusion/fusion-central.db` -- Filesystem blobs: `.fusion/tasks/{ID}/PROMPT.md`, logs, attachments -- Project tables for tasks, agents, activity logs, missions, roadmaps, workflow steps, chat, insights, audit events, and more -- Central multi-node metadata including `nodes`, `peerNodes`, `settingsSyncState`, project routing, health, and global concurrency - -Node sync needs to handle: - -1. Task metadata -2. Task lifecycle transitions -3. Agent state and heartbeats -4. Settings sync -5. Mission, roadmap, and todo data -6. Attachments and task files -7. Conflict detection -8. Offline edits -9. Partial peer availability -10. Security and authentication per node - -The sync layer needs to be more general than an issue tracker sync model. - -## Beads Evaluation - -Beads is attractive because it appears philosophically aligned with Fusion: - -- Local-first -- CLI-friendly -- Task/issue oriented -- Git-friendly or file/db-backed sync model -- Human-readable workflows -- Good fit for small project issue tracking - -### Potential Uses - -Beads could be useful as inspiration for: - -- Task IDs -- Dependency graph semantics -- Syncing issue-like records -- Minimal local-first UX -- Conflict-tolerant issue updates -- Import/export interoperability - -### Problems as Fusion’s Backend - -#### Domain mismatch - -Fusion tasks are only one part of the data model. Fusion also has: - -- Agents -- Agent ratings -- Heartbeat runs -- AI sessions/messages -- Workflow steps -- Missions, milestones, slices, and features -- Roadmaps -- Settings -- Node registry -- Run audit events -- Attachments and logs - -Beads is likely optimized around issues, not full distributed orchestration state. - -#### Schema constraints - -If Fusion uses Beads as the substrate, Fusion either: - -- Adopts Beads’ issue model and loses domain expressiveness, or -- Stores Fusion-specific JSON payloads inside Beads, turning Beads into an awkward blob store. - -Neither is ideal. - -#### Sync granularity mismatch - -Fusion needs record-level and event-level sync with explicit lifecycle semantics. - -Example: - -- Node A moves `FN-123` from `todo` to `in-progress` -- Node B edits title/description -- Node C assigns `nodeId` -- An agent heartbeat writes progress -- A reviewer moves the task to `in-review` - -Fusion needs deterministic merge rules per field and per event type. Beads is unlikely to provide that across Fusion’s full schema. - -#### Runtime state should not sync like tasks - -Some Fusion data is operational and ephemeral: - -- Agent heartbeat timestamps -- In-progress run state -- Local worktree paths -- Scheduler locks -- Checkout leases - -A general issue tracker sync model may accidentally replicate data that should remain node-local. - -### Beads Verdict - -Do not use Beads as Fusion’s storage backend. - -Potential uses: - -- Study its local-first task model. -- Build an importer/exporter if useful. -- Reuse similar concepts for task dependency syncing. -- Do not couple Fusion core storage or node sync to it. - -## Dolt Evaluation - -Dolt is effectively “Git for SQL databases”: relational tables with branches, diffs, commits, remotes, merges, conflicts, and SQL access. - -### What Dolt Is Good At - -Dolt provides: - -- Versioned relational data -- SQL access -- Branch/merge workflow -- Diff/commit history -- Conflict detection -- Remote push/pull semantics -- MySQL-compatible protocol -- Data provenance - -This sounds compelling because Fusion already stores structured metadata in SQLite. - -### Why Dolt Is Tempting - -Fusion needs distributed relational sync. Dolt provides many adjacent primitives: - -- Nodes could have branches. -- Sync could be pull/merge/push. -- Conflicts could be represented explicitly. -- Settings/task diffs could be inspected. -- History could be queryable. -- Multi-node sync could use Dolt remotes. - -For structured metadata, Dolt is more relevant than Beads. - -### Major Tradeoffs - -#### Dolt is not an embedded SQLite replacement - -Fusion currently uses SQLite through `node:sqlite`. - -This is simple: - -- No server -- No external daemon -- Small operational footprint -- Works inside a published npm CLI -- Easy local project DB -- WAL mode concurrency -- Files live in `.fusion/fusion.db` - -Dolt is operationally different: - -- Usually accessed as a Dolt database/server or CLI-managed repo -- MySQL-compatible, not SQLite-compatible -- Requires different drivers and query behavior -- Adds a heavier binary dependency -- Is harder to bundle into `@runfusion/fusion` - -For a globally installed npm CLI, this matters. - -#### Migration cost is high - -Fusion’s persistence layer is deeply SQLite-oriented: - -- `packages/core/src/db.ts` -- `TaskStore` -- `CentralCore` -- migrations -- tests using real SQLite -- project-local DB assumptions -- `.fusion/fusion.db` file layout - -Switching to Dolt would likely require a storage abstraction layer or a major rewrite. - -#### SQL merge is not domain merge - -Dolt can tell you there is a data conflict. It does not know what Fusion should do. - -Example conflict: - -```text -task.status: -Node A: in-progress -Node B: done -``` - -Dolt can surface a conflict, but Fusion still needs to decide: - -- Is `done` allowed if `in-progress` happened elsewhere? -- Was review skipped? -- Which transition wins? -- Should this create a merge-resolution task? -- Should the losing transition be preserved in activity log? - -Fusion has domain-level lifecycle rules. SQL merge does not replace those rules. - -#### Some tables should not be globally merged - -Fusion has mixed data classes: - -| Data type | Sync behavior | -|---|---| -| Tasks | Sync | -| Task documents | Sync | -| Settings | Selectively sync | -| Missions/roadmaps | Sync | -| Activity log | Append-only | -| Run audit | Append-only or local-origin | -| Agent heartbeats | Usually local-only or summarized | -| Checkout leases | Local-only or TTL-based | -| Local worktree paths | Local-only | -| Auth secrets | Special encrypted sync only | - -A generic database merge risks syncing the wrong things unless carefully partitioned. - -#### Filesystem blobs remain unsolved - -Fusion stores large task artifacts in `.fusion/tasks/{ID}/`. - -Dolt can store data in tables, but putting logs, prompts, attachments, and large blobs into Dolt would be undesirable. Fusion would still need a blob sync protocol. - -#### Operational burden - -Dolt introduces product questions: - -- How is Dolt installed? -- Is it bundled with the npm package? -- What about Windows/macOS/Linux binaries? -- How are DB upgrades handled? -- Does the dashboard start a Dolt SQL server? -- What port does it use? -- How does this work with `fn serve`? -- How does it interact with user Git repos? -- How are backups handled? -- How are conflicts exposed in the dashboard? - -That is a large product surface. - -### Dolt Verdict - -Dolt is technically promising, but should not become Fusion’s primary storage engine now. - -Better uses: - -1. **Experimental sync backend** - - Add a prototype adapter for selected tables. - - Try syncing `tasks`, `activityLog`, and `settingsSyncState`. - - Measure complexity. -2. **External export format** - - Export Fusion state into Dolt for audit/history/diff. - - Do not make runtime depend on it. -3. **Admin/enterprise mode** - - Potentially useful later for teams wanting SQL history and data provenance. - -## Recommended Fusion-Native Sync Design - -Keep the current SQLite architecture and add a dedicated sync layer. - -### Core Idea - -Use **append-only change events** plus **materialized SQLite state**. - -Fusion already has adjacent concepts: - -- `activityLog` -- `runAuditEvents` -- node registry -- settings sync state -- task lifecycle transitions -- central DB node metadata - -Build on those instead of replacing storage. - -### Stable Node Identity - -Each node should have: - -```ts -nodeId -publicKey? -apiKey / auth credentials -lastSeen -syncCursorByPeer -``` - -This is already partially represented by `nodes` and `peerNodes`. - -### Change Log Table - -Add a project-level sync log: - -```sql -CREATE TABLE sync_events ( - id TEXT PRIMARY KEY, - originNodeId TEXT NOT NULL, - seq INTEGER NOT NULL, - entityType TEXT NOT NULL, - entityId TEXT NOT NULL, - operation TEXT NOT NULL, - payloadJson TEXT NOT NULL, - baseRevision TEXT, - resultingRevision TEXT NOT NULL, - createdAt TEXT NOT NULL -); -``` - -This becomes the canonical stream nodes exchange. - -### Per-Entity Revision Metadata - -For synced tables: - -```sql -CREATE TABLE entity_revisions ( - entityType TEXT NOT NULL, - entityId TEXT NOT NULL, - revision TEXT NOT NULL, - updatedAt TEXT NOT NULL, - updatedByNodeId TEXT NOT NULL, - PRIMARY KEY (entityType, entityId) -); -``` - -Revision options: - -- Lamport timestamp -- Hybrid logical clock -- Compact vector clock -- Content hash plus origin sequence - -### Conflict Policies by Entity and Field - -Do not rely on generic last-write-wins everywhere. - -| Entity | Conflict policy | -|---|---| -| Task title/description | Last-write-wins or field-level merge | -| Task status | Lifecycle-aware transition merge | -| Task labels | Set union | -| Task dependencies | Set union with cycle detection | -| Activity log | Append-only | -| Run audit | Append-only | -| Checkout lease | Local-only or TTL conflict | -| Agent heartbeat | Node-local by default | -| Settings | Scoped, field-level, explicit push/pull | -| Auth | Encrypted explicit sync only | -| Attachments | Content-addressed blob transfer | - -### Blob Sync - -For files under `.fusion/tasks/{ID}/`, use content-addressed metadata: - -```sql -CREATE TABLE sync_blobs ( - digest TEXT PRIMARY KEY, - taskId TEXT, - relativePath TEXT NOT NULL, - size INTEGER NOT NULL, - mimeType TEXT, - createdAt TEXT NOT NULL -); -``` - -Then transfer blobs separately: - -```text -GET /api/sync/blobs/:digest -PUT /api/sync/blobs/:digest -``` - -Avoid stuffing large logs and attachments into relational sync. - -### Peer Protocol - -Potential endpoints: - -```text -GET /api/sync/summary -GET /api/sync/events?since= -POST /api/sync/events -GET /api/sync/blobs/:digest -POST /api/sync/blobs -POST /api/sync/resolve-conflict -``` - -### Conflict Visibility - -Conflicts should become first-class Fusion objects: - -- Shown in the dashboard -- Resolvable by user or agent -- Optionally converted into `FN-*` tasks -- Include both versions and proposed resolution - -## Dolt vs Fusion-Native Sync - -### Dolt Advantages - -- Existing versioned SQL system -- Built-in diff/merge/push/pull -- Strong data history -- Good for auditable relational datasets -- Mature conceptual model - -### Dolt Disadvantages for Fusion - -- Heavy runtime dependency -- Not SQLite-compatible -- Requires major persistence refactor -- Does not handle Fusion domain conflicts automatically -- Does not solve file/blob sync cleanly -- Harder npm distribution story -- Risky for local CLI UX - -### Fusion-Native Advantages - -- Keeps current SQLite -- Minimal disruption -- Tailored conflict semantics -- Can sync only the right tables -- Easier dashboard integration -- Easier to bundle and test -- Works with current `.fusion/` layout -- Can evolve incrementally - -### Fusion-Native Disadvantages - -- More custom code -- Conflict logic must be carefully designed -- Sync cursor correctness is hard -- Requires robust test coverage -- More engineering effort than delegating to an existing system - -However, Fusion’s domain is specialized enough that custom sync semantics are likely unavoidable either way. - -## Decision - -Do not switch to Beads. - -Do not replace SQLite with Dolt yet. - -Build native sync first. - -Recommended phased plan: - -1. **Classify all tables** - - synced - - append-only - - local-only - - encrypted/explicit - - blob-backed -2. **Add sync event log** - - append-only - - origin node - - sequence/cursor - - entity revision -3. **Implement task/settings sync first** - - tasks - - task documents - - settings - - activity log -4. **Add blob sync** - - content-addressed task files -5. **Add dashboard conflict UI** - - task conflicts - - settings conflicts - - agent-assisted resolution -6. **Prototype Dolt separately** - - feature flag or branch only - - measure install size, query compatibility, performance, and conflict ergonomics - -Final recommendation: - -> Keep SQLite as Fusion’s embedded source of truth. Build a Fusion-native sync layer. Treat Dolt as an optional future backend or audit/export target. Treat Beads as product inspiration, not infrastructure. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 80da35e9c7..a13999123f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -577,6 +577,10 @@ fn task unarchive FN-001 fn task delete FN-001 --force ``` +Notes: +- `fn task archive` accepts any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) and preserves the original column for restore. +- `fn task unarchive` restores to the saved pre-archive column when available, with legacy archives falling back to `done`. + ### Branch conflict handling When executor branch allocation fails because `fusion/` is already checked out, Fusion marks the task failed/investigable and logs conflict details (existing worktree path, tip SHA, stranded commits). Operators should inspect and resolve conflicting local branches/worktrees with standard git tooling, then retry the task. @@ -587,6 +591,8 @@ Create a pull request for a task with `fn pr create `. Alias: `fn task pr-create ` +Maintenance: `fn pr automerge-cleanup` performs a dry run of legacy auto-merge stamps left by older `in-review` task behavior and prints affected task IDs/columns. Add `--apply` to clear those stamps after reviewing the list, and `--json` for machine-readable output. + Flags: - `--title `: Set the PR title. - `--base <branch>`: Target base branch (default from repo/CLI settings). @@ -601,6 +607,8 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo fn pr create FN-001 fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main fn task pr-create FN-001 --title "Fix login race" --body "Prevents duplicate session refresh." --base main +fn pr automerge-cleanup --json +fn pr automerge-cleanup --apply fn task import owner/repo --labels bug --limit 10 fn task import owner/repo --interactive ``` diff --git a/docs/contributing.md b/docs/contributing.md index f97cec31a2..d88893348b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -94,7 +94,7 @@ GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard - `pnpm test:full` remains the explicit full workspace suite; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`. - `pnpm verify:workspace` remains the deep opt-in lint -> test -> build verification. -`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test -- --shard <index>/<count>`. This keeps coverage reproducible while improving shard balance. +`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test --shard <index>/<count>` (no bare `--`, because Vitest's cac parser would otherwise treat the shard flag as a filter separator). This keeps coverage reproducible while improving shard balance. `pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It runs the merge-gate suite first, then the affected set. The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`); shared-infrastructure changes and unresolvable diffs widen the affected set but never escalate to an implicit full-suite run (the old escalation was the local OOM path). @@ -112,6 +112,34 @@ Fusion tests must run against disposable test data, never live local state: If you add or change test entrypoints, keep this isolation guard path intact and ensure guard + test execution share the same disposable HOME so changed/full/cached paths stay consistent. +## Spec authoring: provenance evidence for outside tooling + +Any task that wires in an outside command-line program, daemon, separately-fetched program, or package-managed dependency must include provenance evidence in its `PROMPT.md`. The deterministic spec-validation gate (`detectExternalIntegrationEvidenceGaps`) REVISEs specs that mention this kind of outside tooling without enough provenance to audit where it comes from and what command or artifact is expected. + +Use a dedicated `## External Integration Evidence` or `## External-Integration Evidence` section when possible. The gate accepts semantically labeled bullets; labels may include or omit a trailing `URL`/`name`, may use `/` or `:` separators (for example `Docs / homepage URL:` or `Docs/homepage:`), and URLs may be bare or backtick-wrapped. + +Include all five evidence fields: + +1. Canonical upstream repo URL — a GitHub URL with distinct owner/repo; duplicate owner/owner placeholders are rejected. +2. Docs / homepage URL — a distinct non-GitHub, non-artifact URL. +3. Release / download URL — a GitHub `…/releases/…` URL, a generic `…download…` URL, an npm `registry.npmjs.org/<pkg>/-/<name>-<ver>.tgz` URL, or any `.tgz`/`.tar.gz` artifact URL. +4. Binary / CLI name — the command name in backticks, such as `` `wt` ``. +5. Checksum — a `sha256`/`sha512` digest, a pinned-manifest token, or the literal `upstream-pending-verification` marker. The marker is accepted for the checksum field only; never use it in place of source, docs, or artifact URLs. + +Never fabricate source URLs, command names, release locations, or checksums. Cite real provenance, or use `upstream-pending-verification` only for the checksum field while the digest is being pinned. + +<!-- evidence-example:start --> +```markdown +## External Integration Evidence + +- Canonical upstream repo URL: https://github.com/max-sixty/worktrunk +- Docs / homepage URL: https://worktrunk.dev/ +- Release / download URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz +- Binary / CLI name: `wt` +- Checksum: `sha256-<digest>` (or `upstream-pending-verification` until the checksum is pinned) +``` +<!-- evidence-example:end --> + ## Quality Gate Checklist Before submitting changes, verify: diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 23115dedaf..1c3e8743ca 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -110,13 +110,78 @@ Behavior: - Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels - Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology. - The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring. -- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those dropdown controls write workflow setting values for the active default workflow. -- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata +- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. +- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs. - On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available. -- Mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. -- Mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Settings keeps the Definitions/Values tab split. +- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. +- Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split. - The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens +## Custom Providers + +Custom Providers live in **Settings → Authentication → Custom Providers**, inside the **Advanced: Custom Providers** disclosure. Use this section to add user-defined model providers that speak an OpenAI-compatible API, the OpenAI Responses API, an Anthropic-compatible API, or Google Generative AI. After a provider is saved with models, those models become selectable in model dropdowns, including **Settings → Project Models** lanes and workflow model lanes. + +Supported **API type** values match the dropdown in the form: + +- **OpenAI-compatible** +- **OpenAI Responses** +- **Anthropic-compatible** +- **Google Generative AI** + +The custom-provider form uses these fields: + +- **Provider name** — the display name for the provider. +- **API type** — one of the supported API types above. +- **Base URL** — the provider endpoint base URL. It must be a valid `http` or `https` URL, for example `https://api.example.com/v1`. +- **API key** — optional credential for providers that require authentication. +- **Available models** — comma-separated model IDs, for example `gpt-4, gpt-3.5-turbo`. + +Use **Detect Models** to auto-fill **Available models** from the provider's `/models` endpoint. Detection requires a **Base URL** and may require an **API key**, depending on the provider. + +### Add a custom provider + +1. Open **Settings → Authentication → Custom Providers**. +2. Expand **Advanced: Custom Providers** if it is collapsed. +3. Select **Add Custom Provider**. +4. Enter a **Provider name**. +5. Choose the correct **API type**: **OpenAI-compatible**, **OpenAI Responses**, **Anthropic-compatible**, or **Google Generative AI**. +6. Enter the provider **Base URL**. The value must be a valid `http` or `https` URL. +7. If the provider requires authentication, enter its **API key**. +8. Populate **Available models** by either: + - entering comma-separated model IDs manually, or + - selecting **Detect Models** to query the provider's `/models` endpoint and prepend detected model IDs to the field. +9. Select **Save Provider**. + +Expected outcome: the provider appears in the Custom Providers list with its API type and base URL. Each saved model is then available in model dropdowns as a `{provider}/{modelId}` option, including **Settings → Project Models** default-workflow lanes and workflow model lanes in the workflow editor. + +### Edit a custom provider + +1. Open **Settings → Authentication → Custom Providers** and expand **Advanced: Custom Providers**. +2. Find the provider in the list and select its pencil **Edit** action. +3. Update **Provider name**, **API type**, **Base URL**, **API key**, or **Available models** as needed. +4. Select **Detect Models** again if you want to refresh or add model IDs from the provider's `/models` endpoint. +5. Select **Save Changes**. + +Expected outcome: the provider list refreshes, and model dropdowns use the updated model list. If you rename the provider or change model IDs, update any **Project Models** or workflow model lane selections that should use the new `{provider}/{modelId}` value. + +### Delete a custom provider + +1. Open **Settings → Authentication → Custom Providers** and expand **Advanced: Custom Providers**. +2. Find the provider in the list and select its trash **Delete** action. +3. Confirm the prompt: `Delete custom provider "<name>"?`. + +Expected outcome: the provider is removed from the list, and its models are no longer offered as selectable options in model dropdowns. Review any **Project Models** or workflow model lane values that previously selected that provider. + +### Masked API key behavior + +Saved API keys are stored in settings but are masked in API responses and UI-loaded provider records. When you edit an existing Custom Provider, the **API key** field starts blank and shows the hint **Leave blank to keep current key** if a key is already saved. + +- Leave **API key** blank to preserve the saved key. +- Enter a new **API key** value to replace the saved key. +- The masked value shown in responses is never reused or submitted as a real credential by the edit form. + +For the stored settings shape, see [`customProviders` in the Settings Reference](./settings-reference.md#customproviders). For the API behavior, including masked keys in responses, see [Architecture → Custom Provider endpoints](./architecture.md#custom-provider-endpoints). + ## Planning Mode Planning Mode now includes branch controls on the summary screen before you create a task. @@ -625,6 +690,7 @@ For related global/project configuration behavior, see [Settings reference](./se Inspect task definition, logs, review feedback, comments, documents, workflow outcomes, model overrides, and task routing from a single modal. - Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form. +- The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. - Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form. - These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group. @@ -635,6 +701,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab. +- Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior. - The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass. - The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. @@ -644,6 +711,12 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - For shared `branch_groups` (tasks with `branchContext.groupId`), PR merge mode opens and tracks one group-level PR from the group integration branch to the project default branch; member tasks share that PR state. - In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs. +### Legacy auto-merge stamp cleanup + +Settings → Merge includes **Legacy auto-merge stamp cleanup** for operators auditing tasks that inherited historical in-review `autoMerge` stamps. The panel loads a dry-run candidate list, shows task IDs and current columns, and only reveals the destructive **Clear legacy stamps** action when candidates exist. Applying the cleanup requires the browser confirmation prompt, calls the maintenance apply endpoint, and then refreshes the dry-run list so cleared tasks disappear. + +Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows before relying on per-task auto-merge overrides. It only targets stamps tagged as legacy provenance; explicit user overrides remain intact. + ### Identifying high-impact blockers Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact: @@ -662,6 +735,8 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig ### Logs → Agent Log view +The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. + The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions: - Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation. @@ -1115,7 +1190,7 @@ Dark/light modes via `data-theme`; 54 color themes via `data-color-theme` (lazy- Reuse existing primitives from `styles.css`: - **Buttons**: `.btn`, `.btn-primary`, `.btn-danger`, `.btn-warning`, `.btn-sm`, `.btn-icon`, `.btn-icon--active`, `.btn-badge`. All inherit `:focus-visible` via `--focus-ring-strong` and `:active` via `transform: scale(0.97)`. -- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors. +- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors. Resizable modals using `useModalResizePersist(...)` get a shared bottom-right touch/mouse resize grip on tablet and desktop; mobile sheets stay full-screen and grip-free. - **Forms**: `.form-group`, `.input`, `.select`, `.checkbox-label`, `.form-error`. Inputs in `.form-group` get focus styles automatically. - **Cards**: `.card`, `.card-header`, `.card-id`, `.card-title`, `.card-meta`, `.card-status-badge--{triage,todo,in-progress,in-review,done,archived}`. - **Utility**: `.touch-target` (44px min), `.visually-hidden`. @@ -1130,7 +1205,7 @@ Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-wi **Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar stays pinned to page bottom cross-platform; the executor footer keyboard-collapse pin is iOS-only. On Android (`interactive-widget=resizes-content`), the footer keeps its stacked position above the nav bar to avoid overlap after keyboard dismiss. -**Footer-safe fill layouts:** View wrappers that reserve footer/mobile-nav space (for example `.project-content`) should be flex containers with `min-height: 0` / `min-width: 0`, and child surfaces like `.board` should use `flex: 1 1 auto` plus the same min-size guards. This keeps the board/columns stretched between the header and fixed bottom bars across desktop, tablet, and mobile while allowing internal scroll regions to own overflow. +**Footer-safe fill layouts:** View wrappers that reserve footer/mobile-nav space (for example `.project-content`) should be flex containers with `min-height: 0` / `min-width: 0`, and child surfaces like `.board` should use `flex: 1 1 auto` plus the same min-size guards. Workflow-mode board wrappers (`.board-workflow-view` → `.board-workflow-columns`) also keep a definite `height: 100%`/`max-height: 100%` chain so the workflow toolbar and columns split the available space on tablet as well as desktop/mobile. This keeps the board/columns stretched between the header and fixed bottom bars across desktop, tablet, and mobile while allowing internal scroll regions to own overflow. **Touch targets:** Standing button-freeze directive supersedes per-button touch-target guidance. For non-button elements, primary controls (nav bar, FAB, tab action rows, modal CTAs, list-row tap targets, form controls) must be ≥36px on mobile. Secondary controls inside a card/list-row where the row itself is the tap target stay compact (24–28px or small chips). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 176cea2633..1e67cbaff2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -140,3 +140,21 @@ FN-5416 extends resume-correlation coverage to stream-focused hooks and their pr - Route shells - `DevServerView`: `remount` / `route-active` / `route-inactive` - `ResearchView`: `remount` / `route-active` / `route-inactive` + +## Merge temp worktree cleanup classification (`[merger]`) + +Fusion merge cleanup treats a narrow class of temporary merge/post-merge worktree removal failures as non-fatal only after Git admin state proves there is no registered worktree leak. + +- Applies to Fusion-created temp merge paths such as `fusion-ai-merge-*` and post-merge paths such as `post-merge-*` during `merger-cleanup` / `merger-post-merge` removal. +- Trigger shape: `git worktree remove --force <path>` fails with validation text such as `fatal: validation failed, cannot remove working tree: '<path>/.git' is not a .git file`. +- Recovery proof: Fusion runs `git worktree prune`, then inspects `git worktree list --porcelain`. +- Harmless classification: if the target path is absent from porcelain after prune, the merger logs that cleanup remove failed but no registered worktree remains. If a directory still exists, Fusion reports it as residue for operator inspection; it does not delete arbitrary `/var/folders` content. +- Leak classification: if the target path is still present in porcelain after prune, the cleanup failure remains visible as a real registered-worktree leak. + +Operator verification command: + +```bash +git worktree list --porcelain | grep -F "<temp-worktree-path>" +``` + +No output means Git no longer registers that temp path; matching `worktree <temp-worktree-path>` output means the leak is still registered and needs operator cleanup. diff --git a/docs/plans/2026-06-09-002-refactor-workflow-owned-merge-retry-scheduling-plan.md b/docs/plans/2026-06-09-002-refactor-workflow-owned-merge-retry-scheduling-plan.md new file mode 100644 index 0000000000..bf6beaac8a --- /dev/null +++ b/docs/plans/2026-06-09-002-refactor-workflow-owned-merge-retry-scheduling-plan.md @@ -0,0 +1,279 @@ +--- +title: "refactor: Workflow-owned merge, retry, and scheduling policy" +type: refactor +status: active +date: 2026-06-09 +depth: deep +origin: none (solo planning bootstrap; focuses docs/plans/2026-06-09-001-refactor-big-bang-workflow-native-execution-plan.md on merge, retry, and scheduling ownership) +--- + +# refactor: Workflow-owned merge, retry, and scheduling policy + +## Summary + +Move Fusion's merge policy, retry policy, task scheduling decisions, and git operation ownership into workflow IR/runtime instead of keeping them as hidden engine behavior. The engine remains the substrate for durable storage, leases, capacity accounting, process supervision, timers, routing, and audit plumbing. Workflow nodes own the git and merge capability modules they invoke, including checkout preparation, branch integration, conflict handling, squash/finalize flows, retry routing, and manual holds. + +This plan does not require rewriting the existing merger algorithms up front. The first cut relocates the merger and git helpers behind workflow node capabilities while preserving the same guard rails. The shipped target is that production lifecycle and git/merge policy is authored in built-in workflow graphs and custom workflows, not in `ProjectEngine`, `Scheduler`, self-healing sweeps, or merge queue special cases. + +--- + +## Problem Frame + +Fusion now has a workflow runtime, but merge, retry, and scheduling still leak through engine-owned control paths: + +- The scheduler knows task lifecycle concepts and merge-specific eligibility instead of only claiming runnable workflow work. +- `ProjectEngine` and self-healing sweeps directly mutate task lifecycle or re-enqueue tasks for merge based on engine-side interpretations. +- The merge queue is a separate procedural control plane, so workflow state and merge state can disagree. +- Retry behavior is spread across retry helpers, task counters, rate-limit handling, manual retry reset, transient merge classification, and self-healing recovery. +- Dashboard badges can reflect stale engine classifications because the workflow run is not the single source of truth for waiting/retry/merge states. + +The desired architecture is simpler: a workflow run owns task policy and git/merge operation flow. The engine supplies reliable execution mechanics and non-bypassable guard services. + +--- + +## Requirements + +- R1. Built-in workflow IR expresses default merge, retry, and scheduling policy explicitly. +- R2. Custom workflows can model their own waiting, retry, git, and merge gates using the same runtime state model and guarded node capabilities. +- R3. The engine keeps only substrate responsibilities: durable queues/work items, leases, capacity limits, timers, process supervision, persistence, routing, storage, transition guard services, and audit plumbing. +- R4. The scheduler dispatches generic runnable workflow work items. It does not decide task lifecycle policy, merge eligibility, retry routing, or self-healing outcomes. +- R5. Merge work is represented as workflow work or workflow node state, not as an independent hidden merge queue with separate lifecycle semantics. +- R6. Retry budgets are scoped to workflow nodes/runs and surfaced through workflow runtime state. Legacy task-level retry fields may remain as compatibility summaries, not policy authority. +- R7. Self-healing and restart recovery emit typed workflow recovery events or wake workflow nodes. They do not directly requeue, fail, pause, unpause, or merge tasks except through guarded workflow primitives. +- R8. Existing invariants remain non-configurable: `autoMerge:false` is terminal-until-human-merged except the shared-branch member integration exception, `moveTask(in-progress -> todo)` is a hard cancel, file-scope/squash guards remain authoritative, branch-group target rules remain intact, and user pauses are respected. +- R9. Dashboard/API/CLI surfaces show workflow-native reasons for queued, blocked, retrying, merging, manually held, stalled, failed, and recovered states. +- R10. Tests assert the invariant across default coding, stepwise coding, custom workflows, PR workflows, branch groups, auto-merge off, manual retry, pause/cancel, restart recovery, transient merge errors, and stale work recovery. +- R11. Git operations are workflow node capabilities. Engine code may supervise child processes and provide guard services, but it must not own checkout, branch integration, conflict resolution, squash, finalize, or recovery policy. + +--- + +## Scope Boundaries + +### In Scope + +- Extract scheduler policy into workflow-owned runnable work item selection. +- Convert merge queue/merge request behavior into workflow run/node/work-item state. +- Add built-in merge subgraph nodes for merge gates, merge attempts, manual holds, retry branches, post-merge finalization, and recovery routing. +- Move retry budgets and retry-after decisions into workflow node policies. +- Convert self-healing from lifecycle mutation to workflow event publication and wakeup. +- Update dashboard/API/CLI state derivation to read workflow runtime state first. +- Move merger and git operation ownership into workflow node capability modules while preserving transition and repository safety guards as shared guard services. + +### Out of Scope + +- Rewriting the low-level git merge algorithm. +- Replacing SQLite or the task identity model. +- Removing branch groups, PR workflows, workflow steps, or custom workflow authoring. +- Redesigning the dashboard beyond the state display needed for workflow-native merge/retry/scheduling. +- Changing release mechanics. + +--- + +## Key Technical Decisions + +- KTD-1. Workflow owns policy and git operation flow; engine owns execution mechanics. A workflow node may run `prepareCheckout`, `integrateBranch`, `attemptMerge`, `finalizeSquash`, `runAgentSession`, or `scheduleRetry`, but the choice to call them and the route after success/failure belongs to workflow IR/runtime. +- KTD-2. Git and merge become workflow node capabilities. Existing files like `merger.ts`, `merger-ai.ts`, `merger-integration-worktree.ts`, `worktree-acquisition.ts`, and base-commit helpers should move behind workflow node capability modules rather than remain engine lifecycle primitives. +- KTD-3. Scheduling becomes generic runnable-work claiming. The scheduler reads workflow work items, leases one, checks capacity/routing, starts the workflow runtime, and records audit. It does not inspect task statuses to infer merge or retry policy. +- KTD-4. Retry state is node-scoped. Attempts, retry-after, transient/permanent classification, exhausted budgets, and manual retry resets live on workflow node/run/work-item state. Task-level retry summaries are projections. +- KTD-5. Recovery is event-driven. Restart recovery, stale detection, and self-healing write typed events such as `run-stale`, `merge-work-stale`, `agent-session-lost`, `retry-after-expired`, or `already-landed`; workflow recovery nodes consume them. +- KTD-6. Manual merge and `autoMerge:false` are workflow holds. The hold is visible, durable, and terminal until a human action releases or completes it. +- KTD-7. Branch groups are workflow subgraphs. Member-to-shared-branch integration and shared-branch-to-default promotion are separate merge nodes with separate auto-merge gates. +- KTD-8. Migration is staged internally, but the final shipped state has no production fallback where old engine merge/retry/scheduling policy can race the workflow runtime. +- KTD-9. Repository safety remains centralized as guard services. File-scope checks, squash overlap checks, worktree ownership checks, and branch target validation are not optional workflow author logic; nodes call guard services before mutating git state. + +--- + +## Target Architecture + +```mermaid +flowchart TB + Event[task event / timer / user action / recovery sweep] --> WorkItem[workflow work item] + WorkItem --> Scheduler[generic scheduler claim] + Scheduler --> Capacity[capacity + routing + lease] + Capacity --> Runtime[WorkflowTaskRuntime] + Runtime --> Graph[WorkflowGraphExecutor] + Graph --> Policy[workflow nodes own policy] + Policy --> MergeGate[merge/manual hold node] + Policy --> Retry[retry/backoff node policy] + Policy --> Recovery[recovery router node] + Policy --> Wait[hold/wait/capacity node] + MergeGate --> GitNodes[git/merge node capabilities] + Retry --> WorkItem + Recovery --> WorkItem + Wait --> WorkItem + GitNodes --> Guards[repository guard services] + GitNodes --> Store[(TaskStore / DB)] + GitNodes --> Git[git/worktrees] + GitNodes --> Audit[run audit] +``` + +The scheduler should be boring. The interesting state transitions are encoded in the workflow graph and persisted as workflow runtime state. + +--- + +## Implementation Units + +### U1. Inventory And Ownership Map + +- **Goal:** Produce a concrete source map of every merge, retry, and scheduling policy branch that must move. +- **Requirements:** R1, R3, R4, R5, R6, R7. +- **Files:** `packages/engine/src/project-engine.ts`, `packages/engine/src/scheduler.ts`, `packages/engine/src/self-healing.ts`, `packages/engine/src/merger.ts`, `packages/engine/src/group-merge-coordinator.ts`, `packages/engine/src/transient-merge-error-classifier.ts`, `packages/engine/src/retry-with-backoff.ts`, `packages/engine/src/rate-limit-retry.ts`, `packages/core/src/store.ts`, `packages/core/src/task-merge.ts`, `packages/core/src/retry-summary.ts`, `packages/core/src/manual-retry-reset.ts`, `docs/architecture.md`, `docs/workflow-steps.md`. +- **Approach:** Classify each branch as substrate, workflow policy, compatibility projection, or delete. Record non-bypassable guards separately from policy. This map becomes the checklist for later deletion gates. +- **Test scenarios:** Add a narrow characterization/search test or documented checklist that fails review if a known policy branch is left unclassified. +- **Verification:** Every existing merge queue recovery, self-healing merge requeue, scheduler retry, transient retry, manual retry, branch-group merge, and auto-merge branch has a target workflow node capability, workflow policy node, or shared guard service. + +### U2. Workflow Work Items For Scheduling, Merge, Retry, And Recovery + +- **Goal:** Add a durable workflow work-item model that represents runnable, held, retrying, merge, and recovery work generically. +- **Requirements:** R2, R4, R5, R6, R7, R9. +- **Files:** `packages/core/src/db.ts`, `packages/core/src/store.ts`, `packages/core/src/types.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-graph-executor.ts`, `packages/core/src/__tests__/central-db.test.ts`, `packages/core/src/__tests__/store-workflow-runtime.test.ts` (new), `packages/core/src/__tests__/merge-request-record.test.ts`. +- **Approach:** Introduce or consolidate a work-item table keyed by workflow run, task, node, and kind. Minimum fields should cover `kind`, `state`, `runId`, `taskId`, `nodeId`, `attempt`, `retryAfter`, `lease`, `lastError`, `blockedReason`, `createdAt`, and `updatedAt`. Existing merge request records can be migrated into or projected from this model during cutover. +- **Test scenarios:** A coding completion creates merge work; a transient merge error creates retrying merge work with `retryAfter`; `autoMerge:false` creates manual hold work; recovery events create recovery work; duplicate wakeups are idempotent; expired leases can be reclaimed; completed work cannot be re-enqueued by self-healing. +- **Verification:** Store tests prove the engine can find runnable work without inspecting task lifecycle policy. + +### U3. Generic Scheduler Substrate + +- **Goal:** Convert `Scheduler` into a generic workflow work dispatcher. +- **Requirements:** R3, R4, R8, R10. +- **Files:** `packages/engine/src/scheduler.ts`, `packages/engine/src/project-engine.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-authoritative-driver.ts`, `packages/engine/src/workflow-parity-observer.ts`, `packages/engine/src/__tests__/scheduler.test.ts`, `packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts` (new). +- **Approach:** Scheduler polling should select runnable workflow work items, apply capacity/agent routing/lease checks, and invoke the runtime. Remove special cases that decide whether `todo`, `in-progress`, `in-review`, merge-queued, retrying, or failed tasks should advance. Those decisions are represented by work-item state and workflow node outcomes. +- **Test scenarios:** Scheduler claims only due work items; capacity blocks produce held work state instead of task mutation; retry-after items are skipped until due; user-paused tasks are not claimed; hard-cancelled work is aborted and parked; engine restart reclaims stale leases; no merge-specific scheduler branch is needed. +- **Verification:** Scheduler tests use workflow work items as inputs and do not construct merge queue policy directly. + +### U4. Git And Merge Node Capabilities + +- **Goal:** Move checkout, branch integration, merge, squash, finalize, and conflict-handling operation ownership into explicit workflow node capability modules. +- **Requirements:** R1, R2, R5, R8, R10, R11. +- **Files:** `packages/engine/src/merger.ts`, `packages/engine/src/merger-ai.ts`, `packages/engine/src/merger-integration-worktree.ts`, `packages/engine/src/group-merge-coordinator.ts`, `packages/engine/src/merge-trait.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/workflow-merge-nodes.ts` (new), `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-pr-workflow-ir.ts`, `packages/engine/src/__tests__/interpreter-merge-seam.test.ts`, `packages/engine/src/__tests__/dual-observe-merge-seam.test.ts`, branch-group merge tests. +- **Approach:** Define node capabilities for checkout preparation, base/fork-point capture, branch integration, merge eligibility, manual hold, merge attempt, conflict/revision routing, post-squash audit, finalize, and already-landed recovery. Move git operation orchestration out of engine lifecycle modules and into these capabilities. The capability modules call shared guard services for file-scope checks, squash checks, worktree ownership, branch target validation, and audit correlation before mutating repository state. +- **Test scenarios:** Completed implementation enters merge node; checkout preparation is driven by a workflow node; `autoMerge:false` routes to manual hold; file-scope violation fails through workflow outcome; already-on-main routes to finalize; transient merge failure routes to retry; non-transient conflict routes to revision or manual hold; branch-group member integration uses member auto-merge; group promotion uses group auto-merge; no engine lifecycle loop can perform branch integration directly. +- **Verification:** No production caller starts a merge attempt except a workflow merge node or an explicit human/manual API that records the equivalent workflow event. + +### U5. Workflow-Owned Retry Policies + +- **Goal:** Move retry attempts, budgets, backoff, manual retry reset, and retry exhaustion into workflow runtime state. +- **Requirements:** R2, R6, R7, R9, R10. +- **Files:** `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/retry-with-backoff.ts`, `packages/engine/src/rate-limit-retry.ts`, `packages/engine/src/transient-merge-error-classifier.ts`, `packages/core/src/retry-summary.ts`, `packages/core/src/manual-retry-reset.ts`, `packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts`, `packages/engine/src/__tests__/workflow-graph-step-rerun.test.ts`, `packages/engine/src/__tests__/executor-retry-storm.test.ts`, `packages/engine/src/__tests__/workflow-node-retry-policy.test.ts` (new). +- **Approach:** Attach retry policy to node definitions or built-in node configs. Persist attempt count, last error, classification, retry-after, and exhaustion on workflow node/work-item records. Manual retry clears the relevant node/run retry state and emits a workflow wake event. Keep task-level retry summary as a derived dashboard field. +- **Test scenarios:** Coding node transient failure retries within budget; merge transient failure retries merge node only; rate-limit retry uses due time; exhausted retry routes to workflow failure/manual hold; manual retry resets exactly the failed node; retry state survives engine restart; retry storm protection remains enforced by runtime substrate. +- **Verification:** Tests prove that no retry branch is controlled solely by task status/counters. + +### U6. Self-Healing As Workflow Events + +- **Goal:** Remove lifecycle-mutating self-healing decisions and replace them with typed workflow recovery events. +- **Requirements:** R7, R8, R9, R10. +- **Files:** `packages/engine/src/self-healing.ts`, `packages/engine/src/restart-recovery-coordinator.ts`, `packages/engine/src/recovery-policy.ts`, `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/__tests__/self-healing.test.ts`, `packages/engine/src/__tests__/reliability-interaction-backstops.test.ts`, `packages/engine/src/__tests__/workflow-recovery-events.test.ts` (new). +- **Approach:** Sweeps detect facts and publish events. Recovery nodes decide routes. Example facts: stale lease, missing session, merge work stale, already landed, no active work item, cancelled worktree, manual hold still valid, auto-merge disabled. Self-healing should no-op when workflow state already explains the task. +- **Test scenarios:** In-review merge work is not re-enqueued repeatedly; `autoMerge:false` in-review tasks remain terminal; stale running node wakes recovery node; already landed task finalizes; user-paused task is not mutated; duplicate recovery events are deduped; completed/held workflow work is not marked stalled. +- **Verification:** Existing false recovery strings like "Auto-recovered: eligible in-review task re-enqueued for merge" are replaced by workflow event audit when applicable and disappear for valid held/queued states. + +### U7. Built-In Workflow Migration + +- **Goal:** Encode default coding, stepwise coding, and PR workflows with explicit scheduling, retry, merge, and recovery regions. +- **Requirements:** R1, R2, R8, R10. +- **Files:** `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, `packages/core/src/builtin-pr-workflow-ir.ts`, `packages/core/src/builtin-workflows.ts`, `packages/core/src/workflow-ir-types.ts`, `packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts`, `packages/core/src/__tests__/builtin-stepwise-coding-workflow-ir.test.ts`, `packages/core/src/__tests__/builtin-pr-workflow-ir.test.ts`. +- **Approach:** Add explicit graph regions for queue/hold, implementation retry, review, merge gate, merge retry, manual hold, branch-group integration, finalization, and recovery. Keep compatibility with existing workflow column/trait semantics. +- **Test scenarios:** Built-in workflows validate; every legacy lifecycle phase has a node; fast execution mode still preserves required post-merge checks; PR response workflow routes review/fix/merge correctly; stepwise workflow retries per-step without retrying the entire task when possible. +- **Verification:** Built-in workflow fixtures become the source of truth for default lifecycle behavior. + +### U8. Branch Group And Shared Branch Workflows + +- **Goal:** Model branch-group member integration and group promotion as workflow-owned merge subgraphs. +- **Requirements:** R5, R7, R8, R10. +- **Files:** `packages/engine/src/group-merge-coordinator.ts`, `packages/engine/src/merge-trait.ts`, `packages/engine/src/merger-integration-worktree.ts`, `packages/core/src/builtin-coding-workflow-ir.ts`, branch-group tests under `packages/engine/src/__tests__/`. +- **Approach:** Split merge target resolution into workflow node capability calls guarded by repository safety services, then let workflow nodes route member-to-group and group-to-default promotion. Preserve the scoped `autoMerge:false` exception for shared-branch group members. +- **Test scenarios:** Shared member integrates to group branch while global auto-merge is off; group promotion remains blocked when group/global auto-merge is off; conflicting member integration routes to recovery/revision; final group promotion uses file-scope and squash guards; group merge work is visible as workflow work. +- **Verification:** No branch-group coordinator loop owns task lifecycle independent of workflow runtime. + +### U9. Dashboard, API, And CLI State Projection + +- **Goal:** Show workflow-native merge, retry, waiting, and stalled reasons everywhere users inspect tasks. +- **Requirements:** R6, R7, R9. +- **Files:** `packages/dashboard/app/components/TaskCard.tsx`, task detail components, reliability views, task API routes, CLI task output files, `packages/core/src/retry-summary.ts`, `packages/core/src/task-merge.ts`, dashboard tests for task cards/reliability. +- **Approach:** Derive badges and details from workflow run/work-item state first. Keep compatibility projections for older rows during migration, but do not let stale merge queue classifications override valid workflow holds or queued merge work. +- **Test scenarios:** Merge queued shows queued/merge work state, not stalled; retrying shows attempt and retry-after; manual hold shows human action required; recovery event shows event reason; completed work hides stale stalled badges; branch-group merge work identifies target branch. +- **Verification:** UI tests cover task card and detail surfaces for queued, retrying, manual hold, merge failed, and recovered states. + +### U10. Cutover And Deletion Gates + +- **Goal:** Remove production engine-owned policy paths after workflow parity is proven. +- **Requirements:** R3, R4, R5, R6, R7, R10. +- **Files:** `packages/engine/src/project-engine.ts`, `packages/engine/src/scheduler.ts`, `packages/engine/src/self-healing.ts`, `packages/engine/src/merger.ts`, `packages/core/src/store.ts`, focused search tests under `packages/engine/src/__tests__/`. +- **Approach:** Delete or demote engine branches that directly requeue for merge, classify merge lifecycle, schedule task lifecycle by status, mutate retry state, or self-heal by setting terminal statuses. Add search/structure tests for forbidden production patterns where practical. +- **Test scenarios:** Search tests fail on direct merge queue lifecycle mutation from self-healing; scheduler tests fail if task-status policy reappears; merge attempts require workflow node context; retry writes require workflow run/node id; manual retry emits workflow wake event. +- **Verification:** Final branch has one lifecycle control plane: workflow runtime. + +### U11. Documentation And Release Notes + +- **Goal:** Update architecture and user-facing docs to match the new ownership model. +- **Requirements:** R1, R3, R9. +- **Files:** `docs/architecture.md`, `docs/workflow-steps.md`, `docs/dashboard-guide.md`, `docs/settings-reference.md`, `CONCEPTS.md`, `.changeset/<name>.md`. +- **Approach:** Document the workflow/substrate boundary, workflow work items, merge nodes, retry policy, recovery events, dashboard state meanings, and compatibility projections. Add a patch changeset if behavior changes affect published `@runfusion/fusion`. +- **Verification:** Docs mention the same state names used in API/UI tests. + +--- + +## Acceptance Examples + +- AE1. A task finishing implementation creates/continues a workflow merge node. No engine merge queue loop independently decides to merge it. +- AE2. A transient merge failure records retry state on the merge node/work item with `retryAfter`; the scheduler only wakes it when due. +- AE3. `autoMerge:false` routes the task to a visible manual merge hold and self-healing leaves it there. +- AE4. A valid queued merge item is never repeatedly marked "Auto-recovered: eligible in-review task re-enqueued for merge." +- AE5. Moving an active task from `in-progress` to `todo` aborts active workflow work and parks the workflow according to hard-cancel semantics. +- AE6. Branch-group member integration can run while shared-branch assembly is allowed, but shared-branch promotion to default remains gated by group/global auto-merge. +- AE7. A manual retry clears the failed workflow node's retry state and creates a due work item without resetting unrelated workflow progress. +- AE8. Dashboard cards and detail views show queued, retrying, manual hold, failed, and recovery states from workflow runtime state. + +--- + +## Rollout Sequence + +1. **Characterize:** Land U1 and focused tests around current merge/retry/scheduler behavior. +2. **State model:** Land U2 without changing production routing; project existing merge request state into workflow work items. +3. **Generic dispatch:** Convert scheduler to claim workflow work items while still producing equivalent behavior. +4. **Git/merge nodes:** Route built-in checkout, branch integration, merge, squash, and finalize behavior through workflow node capabilities. +5. **Retry nodes:** Move retry budgets and manual retry reset to workflow node/run state. +6. **Recovery events:** Convert self-healing to facts/events plus workflow recovery nodes. +7. **Dashboard projection:** Switch UI/API/CLI to workflow-native state. +8. **Deletion gate:** Remove old engine-owned merge/retry/scheduling policy paths and add regression/search tests. +9. **Docs and changeset:** Update docs and add a changeset if published behavior changed. + +--- + +## Risks And Mitigations + +- **Hidden merger invariants:** Start by moving existing merger code behind workflow node capabilities and characterize behavior before routing changes. +- **Queue starvation:** Make workflow work-item selection explicit and test retry-after, capacity, and lease ordering. +- **Double execution during migration:** Use idempotent work-item creation and explicit final deletion gates; do not leave two production controllers active. +- **Custom workflow expressiveness gaps:** Add built-in node kinds for non-authorable primitives rather than forcing users to script around core merge/retry mechanics. +- **State table bloat:** Add retention/pruning rules for terminal workflow work items while preserving audit. +- **Manual hold confusion:** Surface hold reason and release action consistently in dashboard/API/CLI. +- **Branch-group regressions:** Treat member integration and group promotion as separate acceptance surfaces with separate auto-merge tests. + +--- + +## Verification Plan + +- `pnpm test:gate` +- `pnpm lint` +- `pnpm build` +- Targeted engine/core suites for scheduler, workflow runtime, workflow graph executor, merge nodes, branch groups, self-healing, retry policies, manual retry reset, and dashboard task state projection. +- Manual verification with a local Fusion project: + - one ordinary auto-merge task, + - one `autoMerge:false` task, + - one transient merge failure, + - one manual retry, + - one branch-group member integration, + - one engine restart during queued merge work. + +--- + +## Done Criteria + +- Built-in workflows explicitly model git, merge, retry, and scheduling policy. +- Scheduler is a generic workflow work dispatcher. +- Checkout preparation, branch integration, merge attempts, squash, and finalize operations are invoked by workflow nodes or explicit human actions recorded as workflow events. +- Retry state is node/run scoped and visible through workflow runtime state. +- Self-healing emits workflow recovery events instead of directly mutating lifecycle. +- UI/API/CLI state projections come from workflow state, with compatibility fallback only for old rows. +- Production engine code no longer contains independent git/merge/retry/scheduling policy paths that can race workflow runtime. diff --git a/docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md b/docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md new file mode 100644 index 0000000000..50254bac85 --- /dev/null +++ b/docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md @@ -0,0 +1,537 @@ +--- +title: "refactor: Workflow-owned merge full migration slices" +type: refactor +status: active +date: 2026-06-09 +depth: deep +origin: docs/plans/2026-06-09-002-refactor-workflow-owned-merge-retry-scheduling-plan.md +--- + +# refactor: Workflow-owned merge full migration slices + +## Summary + +This plan turns the workflow-owned merge/retry/scheduling architecture into a +sequence of PR-sized migration slices. The target state is unchanged from the +origin plan: workflow IR/runtime owns merge policy, retry policy, scheduling +policy, recovery routing, and git operation flow; the engine keeps substrate +responsibilities such as storage, leases, timers, process supervision, routing, +capacity, guard services, and audit plumbing. + +The migration should ship in independently reviewable slices, but the final +cutover must not leave two production control planes. Compatibility projections +are allowed while slices are in flight. Production fallback paths are removed at +the deletion gates. + +## Requirements Trace + +- R1. Built-in workflow IR expresses default merge, retry, scheduling, and + recovery policy explicitly. +- R2. Workflow work state replaces hidden merge queue and retry routing as the + policy authority. +- R3. Scheduler claims generic workflow work; it does not infer task lifecycle + advancement from columns. +- R4. Git and merge operations are workflow node capabilities guarded by shared + repository safety services. +- R5. Retry state is node/run scoped; task retry fields are compatibility + projections only. +- R6. Self-healing publishes typed workflow recovery facts and wakes recovery + nodes; it does not directly mutate merge/retry lifecycle. +- R7. Dashboard/API/CLI state derives from workflow state first. +- R8. Existing invariants remain non-configurable: `autoMerge:false`, hard + cancel on `in-progress -> todo`, file-scope/squash guards, branch-group target + rules, and user pauses. +- R9. Branch-group member integration and group promotion are workflow-owned + subgraphs with separate gates. +- R10. Final deletion tests fail if engine-owned merge/retry/scheduling policy + reappears. + +## Current Baseline + +The starting checkpoint is `docs/workflow-policy-ownership-map.md`. It classifies +today's ownership of merge queue enqueue/dequeue, scheduler in-review policy, +merge request shadow state, git/merge procedures, retry helpers, manual retry, +self-healing recovery, built-in workflow IR, and dashboard projections. + +Keep that map updated through the migration. A slice is not complete if it moves +policy without updating the map or adding the corresponding deletion gate. + +## Slice Strategy + +- Keep each slice mergeable and behavior-preserving unless the slice is an + explicit cutover gate. +- Prefer characterization-first on legacy policy before moving it. +- Introduce workflow-native state and projections before changing production + routing. +- Route one ownership surface at a time, then delete the old owner. +- Run the merge gate for every slice: `pnpm test:gate`. +- Add `pnpm lint` and `pnpm build` for every behavior-bearing slice. +- Add a changeset only when a slice changes published `@runfusion/fusion` + behavior. + +## Migration Slices + +### S0. Ownership Map And Guard + +- **Goal:** Keep the migration inventory explicit and enforced. +- **Status:** Done by the origin PR. +- **Files:** `docs/workflow-policy-ownership-map.md`, + `packages/engine/src/__tests__/workflow-policy-ownership-map.test.ts`, + `packages/engine/vitest.config.ts`. +- **Tests:** `packages/engine/src/__tests__/workflow-policy-ownership-map.test.ts`. +- **Exit gate:** Every known current policy surface is classified as + `substrate`, `workflow-policy`, `capability`, `compat-projection`, or + `delete-after-cutover`. + +### S1. Workflow Work-Item Schema And Store API + +- **Goal:** Add durable workflow work items that can represent runnable, held, + retrying, merge, manual-hold, and recovery work without changing production + routing yet. +- **Depends on:** S0. +- **Files:** `packages/core/src/db.ts`, `packages/core/src/store.ts`, + `packages/core/src/types.ts`, `packages/core/src/index.ts`, + `packages/core/src/__tests__/central-db.test.ts`, + `packages/core/src/__tests__/store-workflow-runtime.test.ts` (new), + `packages/core/src/__tests__/merge-request-record.test.ts`. +- **Decisions:** Work items are keyed by workflow run, task, node, and kind. + Minimum fields: `id`, `runId`, `taskId`, `nodeId`, `kind`, `state`, + `attempt`, `retryAfter`, `leaseOwner`, `leaseExpiresAt`, `lastError`, + `blockedReason`, `createdAt`, `updatedAt`. +- **Test scenarios:** create runnable work; create merge work; transition to + held/retrying/manual-required/succeeded/cancelled/exhausted; reclaim expired + lease; duplicate wakeups are idempotent; completed work cannot be requeued. +- **Exit gate:** Store can find due runnable work without reading task columns + for merge/retry policy. + +### S2. Merge Request Projection Onto Work Items + +- **Goal:** Project existing merge request records into workflow work-item state + so dashboards and schedulers can dual-read before cutover. +- **Depends on:** S1. +- **Files:** `packages/core/src/store.ts`, `packages/core/src/task-merge.ts`, + `packages/core/src/types.ts`, + `packages/core/src/__tests__/merge-request-record.test.ts`, + `packages/core/src/__tests__/store-workflow-runtime.test.ts`, + `packages/engine/src/__tests__/dual-observe-merge-seam.test.ts`. +- **Decisions:** Existing `mergeRequestContractShadowEnabled` remains a + compatibility switch during this slice. Work-item state is the new shape; + merge request rows remain the old projection. +- **Test scenarios:** queued/running/retrying/manual-required/succeeded rows + project to equivalent work items; task hard-cancel cancels active merge work; + exhausted merge request maps to terminal failed work; projection is + idempotent across restart. +- **Exit gate:** Every merge request state has a lossless workflow work-item + equivalent. + +### S3. Generic Scheduler Claim Path + +- **Goal:** Teach `Scheduler` to claim due workflow work items while preserving + existing task dispatch behavior. +- **Depends on:** S1. +- **Files:** `packages/engine/src/scheduler.ts`, + `packages/engine/src/workflow-task-runtime.ts`, + `packages/engine/src/project-engine.ts`, + `packages/engine/src/__tests__/scheduler.test.ts`, + `packages/engine/src/__tests__/scheduler-node-routing.test.ts`, + `packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts` (new). +- **Decisions:** Scheduler remains substrate. It may apply capacity, routing, + leases, global pause, engine pause, and remote-node dispatch. It must not own + merge eligibility, retry routing, or recovery outcome. +- **Test scenarios:** claim only due runnable work; skip `retryAfter` until due; + hold on capacity without task mutation; user-paused work is not claimed; stale + leases are reclaimable; remote node receives workflow runtime work. +- **Exit gate:** A workflow work item can be dispatched end to end in tests + without constructing a merge queue branch. + +### S4. Built-In Merge/Retry/Recovery IR Regions + +- **Goal:** Add explicit merge, retry, manual hold, branch-group, and recovery + regions to built-in workflow IR. +- **Depends on:** S1, S2. +- **Files:** `packages/core/src/builtin-coding-workflow-ir.ts`, + `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, + `packages/core/src/builtin-pr-workflow-ir.ts`, + `packages/core/src/builtin-workflows.ts`, + `packages/core/src/workflow-ir-types.ts`, + `packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts`, + `packages/core/src/__tests__/builtin-stepwise-coding-workflow-ir.test.ts`, + `packages/core/src/__tests__/builtin-pr-workflow-ir.test.ts`. +- **Decisions:** Use built-in node kinds for non-authorable primitives: + merge gate, merge attempt, manual merge hold, retry/backoff, branch-group + member integration, group promotion, finalize, and recovery router. +- **Test scenarios:** built-in workflows validate; default coding has a merge + gate; stepwise coding has per-step retry plus merge retry; PR workflow routes + review/fix/merge; `autoMerge:false` routes to manual hold; branch-group member + integration and group promotion are separate nodes. +- **Exit gate:** Built-in IR is the source of truth for all default + merge/retry/recovery policy, even if production handlers are not wired yet. + +### S5. Runtime Work-Item Driver + +- **Goal:** Let `WorkflowTaskRuntime` start from a workflow work item and persist + node/work-item outcomes. +- **Depends on:** S1, S3, S4. +- **Files:** `packages/engine/src/workflow-task-runtime.ts`, + `packages/engine/src/workflow-graph-executor.ts`, + `packages/engine/src/workflow-node-handlers.ts`, + `packages/engine/src/__tests__/workflow-task-runtime.test.ts`, + `packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts`, + `packages/engine/src/__tests__/workflow-node-handlers.test.ts`. +- **Decisions:** Runtime receives `{ workItemId, runId, taskId, nodeId }` and + returns a typed outcome that updates work item state. Task column updates are + side effects of workflow primitives, not scheduler policy. +- **Test scenarios:** runnable work completes; failing node creates retrying + work; manual hold node creates held work; runtime restart resumes from stored + work; duplicate start of same work item is refused by lease. +- **Exit gate:** Runtime can progress workflow work without old merge queue + callbacks. + +### S6. Git And Merge Capability Extraction + +- **Goal:** Put checkout preparation, branch integration, merge attempt, squash, + finalize, and conflict classification behind workflow node capability modules. +- **Depends on:** S4, S5. +- **Files:** `packages/engine/src/merger.ts`, + `packages/engine/src/merger-ai.ts`, + `packages/engine/src/merger-integration-worktree.ts`, + `packages/engine/src/workflow-merge-nodes.ts` (new), + `packages/engine/src/workflow-node-handlers.ts`, + `packages/engine/src/merge-trait.ts`, + `packages/engine/src/__tests__/interpreter-merge-seam.test.ts`, + `packages/engine/src/__tests__/dual-observe-merge-seam.test.ts`, + `packages/engine/src/__tests__/workflow-merge-nodes.test.ts` (new). +- **Decisions:** This slice does not rewrite low-level merge algorithms. It + extracts orchestration boundaries so workflow nodes call existing guarded + operations. +- **Test scenarios:** merge node calls checkout preparation; file-scope + violation returns workflow failure; already-on-main routes to finalize; + transient merge error returns retry outcome; non-transient conflict routes to + revision/manual hold; no production caller can bypass guard service in tests. +- **Exit gate:** A merge attempt can be driven by a workflow node capability in + tests with the same guard behavior as `merger.ts`. + +### S7. Completion Handoff Creates Merge Work + +- **Goal:** Replace task-moved `in-review` auto-enqueue as the policy authority + with workflow completion handoff creating merge work. +- **Depends on:** S2, S5, S6. +- **Files:** `packages/engine/src/project-engine.ts`, + `packages/engine/src/merger.ts`, + `packages/core/src/store.ts`, + `packages/engine/src/__tests__/workflow-interpreter-cutover.test.ts`, + `packages/engine/src/__tests__/completion-fanout-x-self-healing.test.ts`, + `packages/engine/src/__tests__/merge-reuse-task-worktree.slow.test.ts`. +- **Decisions:** During this slice the old queue can remain as a projection, but + merge work creation happens through workflow handoff. `autoMerge:false` creates + a manual hold work item. +- **Test scenarios:** coding completion creates merge work; `autoMerge:false` + creates manual hold and does not enqueue merge; duplicate handoff is + idempotent; soft-deleted task cancels handoff; startup projection does not + create duplicate merge work. +- **Exit gate:** New task completions produce workflow merge work before any old + queue processing path runs. + +### S8. Workflow-Owned Merge Queue Processing + +- **Goal:** Process merge work items through workflow runtime instead of + `ProjectEngine`'s in-memory merge queue loop. +- **Depends on:** S3, S6, S7. +- **Files:** `packages/engine/src/project-engine.ts`, + `packages/engine/src/scheduler.ts`, + `packages/engine/src/merger.ts`, + `packages/core/src/store.ts`, + `packages/engine/src/__tests__/merger-merge-lifecycle.test.ts`, + `packages/engine/src/__tests__/merger-post-merge.test.ts`, + `packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts`, + `packages/engine/src/__tests__/workflow-merge-nodes.test.ts`. +- **Decisions:** Keep queue fairness and serialization as substrate leases. The + policy route after success/failure belongs to workflow node outcomes. +- **Test scenarios:** queued merge work claims one at a time; successful merge + finalizes task; transient failure schedules retrying merge work; permanent + conflict routes to revision/manual hold; active merge lease blocks duplicate + processing; hard cancel cancels running merge work. +- **Exit gate:** Production merge processing no longer depends on a hidden + `mergeQueue` dequeue loop. + +### S9. Workflow-Owned Retry State + +- **Goal:** Move retry attempts, budgets, backoff, retry-after, exhaustion, and + manual retry reset into workflow node/work-item state. +- **Depends on:** S5, S8. +- **Files:** `packages/engine/src/workflow-graph-executor.ts`, + `packages/engine/src/workflow-node-handlers.ts`, + `packages/engine/src/retry-with-backoff.ts`, + `packages/engine/src/rate-limit-retry.ts`, + `packages/engine/src/transient-merge-error-classifier.ts`, + `packages/core/src/retry-summary.ts`, + `packages/core/src/manual-retry-reset.ts`, + `packages/engine/src/__tests__/workflow-node-retry-policy.test.ts` (new), + `packages/core/src/__tests__/manual-retry-reset.test.ts`. +- **Decisions:** Task retry fields remain as derived display summaries until + deletion. Manual retry emits a workflow wake and clears only targeted failed + node state. +- **Test scenarios:** implementation node retry stays within budget; merge node + retry does not reset implementation progress; rate-limit error persists due + time; exhausted retry routes to failure/manual hold; manual retry clears only + failed node; retry state survives restart. +- **Exit gate:** No retry branch is controlled solely by task counters. + +### S10. Self-Healing Recovery Events + +- **Goal:** Convert self-healing merge/retry lifecycle mutations into typed + workflow recovery events and node wakes. +- **Depends on:** S5, S8, S9. +- **Files:** `packages/engine/src/self-healing.ts`, + `packages/engine/src/restart-recovery-coordinator.ts`, + `packages/engine/src/recovery-policy.ts`, + `packages/engine/src/workflow-task-runtime.ts`, + `packages/engine/src/__tests__/self-healing.test.ts`, + `packages/engine/src/__tests__/workflow-recovery-events.test.ts` (new), + `packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts`, + `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts`. +- **Decisions:** Sweeps detect facts. Recovery nodes decide routes. Non-task + agent/heartbeat cleanup may remain engine-owned when it is not task lifecycle + policy. +- **Test scenarios:** mergeable in-review task gets recovery event, not direct + requeue; stale merge status emits event; transient merge failure emits retry + event; already landed emits finalize event; `autoMerge:false` remains terminal; + duplicate recovery events are deduped. +- **Exit gate:** Self-healing no longer directly requeues, pauses, fails, + unpauses, or moves merge/retry tasks except through guarded workflow + primitives. + +### S11. Branch Group Workflow Subgraphs + +- **Goal:** Move branch-group member integration and group promotion into + workflow-owned merge subgraphs. +- **Depends on:** S6, S8, S10. +- **Files:** `packages/engine/src/group-merge-coordinator.ts`, + `packages/engine/src/merge-trait.ts`, + `packages/engine/src/merger-integration-worktree.ts`, + `packages/core/src/builtin-coding-workflow-ir.ts`, + `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.slow.test.ts`, + `packages/engine/src/__tests__/workflow-branch-group-merge.test.ts` (new). +- **Decisions:** Member-to-shared-branch integration and shared-branch-to-default + promotion are distinct workflow nodes with distinct auto-merge gates. +- **Test scenarios:** shared member integrates while global auto-merge is off + under the scoped exception; group promotion remains blocked when group/global + auto-merge is off; conflicting member integration routes to recovery/revision; + final group promotion runs file-scope and squash guards. +- **Exit gate:** Branch-group coordinator no longer owns task lifecycle + independent of workflow runtime. + +### S12. Dashboard/API/CLI Workflow Projection + +- **Goal:** Surface workflow-native queued, retrying, merging, manual-hold, + failed, stalled, and recovered reasons across user inspection surfaces. +- **Depends on:** S1, S2, S7, S9, S10. +- **Files:** `packages/dashboard/app/components/TaskCard.tsx`, + task detail components, reliability views, task API routes, + CLI task output files, `packages/core/src/retry-summary.ts`, + `packages/core/src/task-merge.ts`, + `packages/dashboard/app/components/__tests__/TaskCard.test.tsx`, + reliability/dashboard API tests. +- **Decisions:** Workflow state wins over stale task fields. Legacy task fields + remain fallback for old rows only. +- **Test scenarios:** merge queued shows workflow merge work, not stalled; + retrying shows attempt and due time; manual hold shows human action required; + recovery event shows reason; completed work hides stale stalled badges; + branch-group merge work identifies target branch. +- **Exit gate:** UI/API/CLI tests prove workflow state is the first projection + source. + +### S13. Scheduler Policy Deletion + +- **Goal:** Delete scheduler branches that infer lifecycle, merge eligibility, + retry routing, or in-review dependency behavior from task columns. +- **Depends on:** S3, S7, S8, S12. +- **Files:** `packages/engine/src/scheduler.ts`, + `packages/core/src/task-merge.ts`, + `packages/engine/src/__tests__/scheduler.test.ts`, + `packages/engine/src/__tests__/scheduler-overlap-requeue.test.ts`, + `packages/engine/src/__tests__/workflow-scheduler-policy-deletion.test.ts` (new). +- **Decisions:** Dependency satisfaction should use completion handoff/workflow + state. In-review scope leases are replaced by workflow work leases and guard + services. +- **Test scenarios:** scheduler cannot satisfy dependency only because a task is + `in-review`; retry due time comes from work item; overlap lease comes from + workflow work; PR monitor behavior remains as watch substrate, not lifecycle + owner. +- **Exit gate:** Search/structure test fails if scheduler reintroduces + task-column merge/retry policy. + +### S14. ProjectEngine Merge Queue Deletion + +- **Goal:** Remove production `ProjectEngine` merge queue policy and retain only + explicit human/manual event entry points plus substrate helpers. +- **Depends on:** S8, S11, S13. +- **Files:** `packages/engine/src/project-engine.ts`, + `packages/engine/src/runtimes/in-process-runtime.ts`, + `packages/core/src/store.ts`, + `packages/engine/src/__tests__/merger-merge-lifecycle.test.ts`, + `packages/engine/src/__tests__/workflow-merge-policy-deletion.test.ts` (new). +- **Decisions:** Manual merge APIs record a workflow event or create due workflow + work; they do not enqueue hidden engine work. +- **Test scenarios:** no startup in-review scan enqueues hidden merge work; + unpause wakes workflow work; manual merge event wakes merge node; stale + `mergeActive` state cannot block workflow work; old queue APIs are absent or + compatibility-only. +- **Exit gate:** No production caller starts merge processing outside workflow + runtime. + +### S15. Self-Healing Policy Deletion + +- **Goal:** Delete self-healing direct lifecycle mutations for merge/retry tasks + after recovery events cover all cases. +- **Depends on:** S10, S11, S14. +- **Files:** `packages/engine/src/self-healing.ts`, + `docs/self-healing-backward-move-audit.md`, + `packages/engine/src/__tests__/self-healing.test.ts`, + `packages/engine/src/__tests__/workflow-recovery-events.test.ts`, + `packages/engine/src/__tests__/workflow-self-healing-policy-deletion.test.ts` (new). +- **Decisions:** Metadata reconciliation and non-task agent cleanup can remain. + Task lifecycle repair becomes recovery events plus workflow node outcomes. +- **Test scenarios:** direct calls to `moveTask(..., "todo")`, + `updateTask({ paused: true })`, merge requeue callbacks, and merge retry resets + are absent for merge/retry surfaces; valid held states are no-ops; recovery + facts carry audit context. +- **Exit gate:** Search tests fail on direct self-healing merge/retry lifecycle + mutation patterns. + +### S16. Legacy Retry Field Demotion + +- **Goal:** Demote task-level retry/merge counters to projections and remove + policy reads that still treat them as authority. +- **Depends on:** S9, S12, S15. +- **Files:** `packages/core/src/types.ts`, `packages/core/src/retry-summary.ts`, + `packages/core/src/manual-retry-reset.ts`, `packages/core/src/store.ts`, + `packages/engine/src/project-engine.ts`, `packages/engine/src/self-healing.ts`, + `packages/core/src/__tests__/manual-retry-reset.test.ts`, + `packages/engine/src/__tests__/workflow-node-retry-policy.test.ts`. +- **Decisions:** Do not remove fields until all compatibility surfaces can read + workflow projections. Removal can be a later cleanup; this slice removes policy + authority. +- **Test scenarios:** retry summaries derive from workflow node/work state; + manual retry emits workflow wake; old task fields changing alone cannot cause + scheduler/recovery/merge action. +- **Exit gate:** Task retry fields are display-only compatibility data. + +### S17. End-To-End Cutover Matrix + +- **Goal:** Prove the full workflow-owned invariant across all known production + surfaces before removing dual-read compatibility. +- **Depends on:** S13, S14, S15, S16. +- **Files:** focused tests across `packages/engine/src/__tests__/`, + reliability interactions under + `packages/engine/src/__tests__/reliability-interactions/`, core store tests, + dashboard projection tests, `docs/testing.md`. +- **Test matrix:** default coding auto-merge; stepwise coding; custom workflow; + PR workflow; plugin workflow extension; `autoMerge:false`; manual retry; + user hard cancel; engine restart during merge work; transient merge failure; + permanent conflict; branch-group member integration; branch-group promotion; + stale recovery; already-landed finalization; dashboard task card/detail; + CLI task output. +- **Exit gate:** `pnpm test:gate`, `pnpm lint`, `pnpm build`, and targeted matrix + suites pass. No old engine merge/retry/scheduling policy path can race workflow + runtime in production. + +### S18. Documentation, Settings, And Release Notes + +- **Goal:** Update architecture, settings, dashboard, CLI, and testing docs for + workflow-owned policy and compatibility projections. +- **Depends on:** S17. +- **Files:** `docs/architecture.md`, `docs/workflow-steps.md`, + `docs/dashboard-guide.md`, `docs/settings-reference.md`, `docs/testing.md`, + `CONCEPTS.md`, `.changeset/<name>.md`. +- **Decisions:** Document the new source of truth, remaining compatibility fields, + recovery event vocabulary, manual hold behavior, branch-group routing, and + deletion gates. +- **Test scenarios:** docs inventory/search tests if applicable; lazy view + inventory unchanged unless dashboard imports change. +- **Exit gate:** User-facing docs use the same state names as API/UI tests, and a + patch changeset exists if published `@runfusion/fusion` behavior changed. + +## Dependency Graph + +```mermaid +flowchart TB + S0 --> S1 + S1 --> S2 + S1 --> S3 + S2 --> S4 + S3 --> S5 + S4 --> S5 + S5 --> S6 + S6 --> S7 + S7 --> S8 + S8 --> S9 + S9 --> S10 + S8 --> S11 + S10 --> S11 + S7 --> S12 + S9 --> S12 + S10 --> S12 + S12 --> S13 + S13 --> S14 + S11 --> S14 + S14 --> S15 + S15 --> S16 + S16 --> S17 + S17 --> S18 +``` + +## Release And Merge Strategy + +- **Preferred PR count:** 18 slices, one PR per slice. +- **Can combine:** S1+S2 if schema and projection are small; S13+S14 if deletion + is purely mechanical after S8. +- **Do not combine:** S8 with S14, or S10 with S15. Route through workflow first, + then delete old owner in a separate reviewable PR. +- **Branch policy:** Each slice branches from current `main`, not from a stale + feature stack. Drop duplicate commits before merging. +- **Changesets:** Add only when published CLI behavior changes. Internal docs, + CI config, and behavior-preserving refactors do not require changesets. + +## Cutover Safety Gates + +- Gate A after S4: built-in IR expresses all planned policy regions. +- Gate B after S8: workflow runtime can process merge work without hidden queue + ownership. +- Gate C after S10: self-healing emits recovery events for merge/retry surfaces. +- Gate D after S12: dashboard/API/CLI read workflow state first. +- Gate E after S17: deletion tests and end-to-end matrix prove no production + legacy control plane remains. + +## Rollback Strategy + +- Before S13, rollback is disabling workflow work dispatch and relying on legacy + projections. +- After S13, rollback is revert-by-slice, not runtime fallback. Do not ship a + production dual-controller fallback after deletion gates begin. +- Keep old fields as compatibility projections through S17 so data downgrade is + not required for ordinary slice rollback. + +## Verification Commands + +- `pnpm test:gate` +- `pnpm lint` +- `pnpm build` +- Targeted suites named in each slice. +- `pnpm test:full` only for explicit final matrix verification or release-adjacent + confidence, not as the normal merge gate. + +## Done Criteria + +- Workflow work items are the durable source of runnable, held, retrying, merge, + and recovery work. +- Built-in workflows express default merge, retry, scheduling, branch-group, and + recovery policy. +- Scheduler dispatches generic workflow work only. +- Git/merge operations are invoked by workflow nodes or explicit human/manual + events recorded as workflow events. +- Retry budgets and manual retry reset are node/run scoped. +- Self-healing publishes recovery facts and wakes workflow recovery nodes. +- Dashboard/API/CLI projections read workflow state first. +- Deletion tests prevent reintroducing engine-owned merge/retry/scheduling + policy. diff --git a/docs/plans/2026-06-09-004-chore-workflow-owned-merge-stacked-prs-plan.md b/docs/plans/2026-06-09-004-chore-workflow-owned-merge-stacked-prs-plan.md new file mode 100644 index 0000000000..9ba73cf4a3 --- /dev/null +++ b/docs/plans/2026-06-09-004-chore-workflow-owned-merge-stacked-prs-plan.md @@ -0,0 +1,65 @@ +--- +title: "chore: Workflow-owned merge stacked PR creation" +type: chore +status: active +date: 2026-06-09 +depth: shallow +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +--- + +# chore: Workflow-owned merge stacked PR creation + +## Summary + +Create a linear GitHub PR stack for the remaining workflow-owned merge, +retry, scheduling, recovery, projection, deletion, and release slices. The stack +does not claim future implementation is complete. Each PR carries a durable +slice handoff document and is opened as a draft against the previous slice +branch so reviewers can see ordering, dependency, and milestone intent. + +## Requirements Trace + +- R1. Every migration slice S0-S18 from the origin plan is represented in the + PR stack. +- R2. Existing PR #1571 remains the stack base for S0/S1. +- R3. Remaining slices S2-S18 each receive a dedicated branch and draft PR. +- R4. Each branch has a non-empty, reviewable diff that records the slice goal, + milestone, dependencies, file scope, tests, and exit gate. +- R5. PR bodies link back to the full migration plan and identify their base + branch so the stack is reconstructible. + +## Scope + +In scope: + +- Add `docs/plans/workflow-owned-merge-stack/sXX-*.md` handoff files. +- Create and push one branch per remaining slice. +- Open draft PRs stacked linearly from S2 through S18. +- Update PR #1571 with the complete slice/milestone list when needed. + +Out of scope: + +- Implementing S2-S18 code changes in this turn. +- Merging the stack. +- Rewriting existing PR #1571 commits. + +## Stack Shape + +- S0/S1: existing PR #1571, branch + `feature/workflow-owned-merge-retry-scheduling-plan`, base `main`. +- S2: base S0/S1 branch. +- S3-S18: each branch is based on the immediately preceding slice branch. + +This is intentionally linear even though the origin dependency graph has some +parallelizable edges. A linear stack gives GitHub a straightforward review and +landing path; implementation branches can still be split or rebased later if a +slice needs to move independently. + +## Verification + +- `git status --short --branch` is clean after all branches are pushed. +- `gh pr view` succeeds for each created PR. +- Every created PR body includes the slice number, milestone, dependency, full + plan link, and base branch. +- `gh pr checks` is inspected for the current stack base and any newly opened + PR checks that are immediately available. diff --git a/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md b/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md new file mode 100644 index 0000000000..59b86eba17 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md @@ -0,0 +1,46 @@ +--- +title: "S02: merge request projection onto work items" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S02 +milestone: "Foundation" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-retry-scheduling-plan +--- + +# S02: merge request projection onto work items + +## Stack Role + +This draft PR reserves the S02 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Foundation + +## Depends On + +S1 workflow work-item schema and store API. + +## Goal + +Project existing merge request records into workflow work-item state so dashboards and schedulers can dual-read before cutover. + +## Expected File Scope + +packages/core/src/store.ts; packages/core/src/task-merge.ts; packages/core/src/types.ts; merge-request and dual-observe tests. + +## Expected Tests + +Projection tests for queued/running/retrying/manual-required/succeeded/exhausted states, hard cancel cancellation, and restart idempotency. + +## Exit Gate + +Every merge request state has a lossless workflow work-item equivalent. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plans/workflow-owned-merge-stack/s03-generic-scheduler-claim.md b/docs/plans/workflow-owned-merge-stack/s03-generic-scheduler-claim.md new file mode 100644 index 0000000000..dfa89bbd7f --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s03-generic-scheduler-claim.md @@ -0,0 +1,46 @@ +--- +title: "S03: generic scheduler claim path" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S03 +milestone: "Foundation" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s02-merge-request-projection +--- + +# S03: generic scheduler claim path + +## Stack Role + +This draft PR reserves the S03 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Foundation + +## Depends On + +S1 workflow work-item schema and store API. + +## Goal + +Teach Scheduler to claim due workflow work items while preserving existing task dispatch behavior. + +## Expected File Scope + +packages/engine/src/scheduler.ts; packages/engine/src/workflow-task-runtime.ts; packages/engine/src/project-engine.ts; scheduler/workflow dispatch tests. + +## Expected Tests + +Due-work claiming, retryAfter delay, capacity holds, user pause exclusion, stale lease reclaim, and remote dispatch. + +## Exit Gate + +A workflow work item can be dispatched end to end in tests without constructing a merge queue branch. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md b/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md new file mode 100644 index 0000000000..ebf0d9cf0d --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s04-builtin-ir-regions.md @@ -0,0 +1,46 @@ +--- +title: "S04: built-in merge retry recovery IR regions" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S04 +milestone: "Gate A" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s03-generic-scheduler-claim +--- + +# S04: built-in merge retry recovery IR regions + +## Stack Role + +This draft PR reserves the S04 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Gate A + +## Depends On + +S1 workflow work items and S2 merge request projection. + +## Goal + +Add explicit merge, retry, manual hold, branch-group, and recovery regions to built-in workflow IR. + +## Expected File Scope + +packages/core/src/builtin-*-workflow-ir.ts; packages/core/src/workflow-ir-types.ts; built-in workflow IR tests. + +## Expected Tests + +Built-in workflow validation for merge gates, retry nodes, manual holds, PR workflow routing, autoMerge false, and branch-group nodes. + +## Exit Gate + +Built-in IR is the source of truth for default merge/retry/recovery policy. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md index c74a391e49..4d7194a06b 100644 --- a/docs/plugins/compound-engineering.md +++ b/docs/plugins/compound-engineering.md @@ -49,8 +49,15 @@ sessions resume/retry back to their current question. Turn execution is **detached**: start/answer/resume return as soon as the session row reflects the request, with the agent turn running in the background -(failures persist into session state — never an unhandled rejection). While a -turn runs, the engine streams mid-turn progress (thinking/text deltas + tool +(failures persist into session state — never an unhandled rejection). **Close** +only leaves the flow UI; it does not stop the detached agent. **Cancel** is the +explicit stop action for `launching`/`active`/`awaiting_input` sessions: it +aborts any live in-process handle, flushes live working output into history, and +keeps the session row as terminal `interrupted` with `Cancelled by user` so the +conversation can be inspected or resumed. **Discard** is different: it removes a +settled session row entirely after disposing any live handle. + +While a turn runs, the engine streams mid-turn progress (thinking/text deltas + tool markers) through the seam's `onProgress` option; the orchestrator buffers it and `GET /sessions/:id` attaches it as transient `liveActivity`. The per-turn timeout is **inactivity-based** (progress re-arms it), so long actively-working @@ -69,9 +76,11 @@ HTTP endpoints (under `/api/plugins/fusion-plugin-compound-engineering/`): - `POST /sessions` → start a stage session - `POST /sessions/:id/answer` → answer the awaiting question (send `projectId`) - `POST /sessions/:id/resume` → resume an awaiting/interrupted session (send `projectId`) +- `POST /sessions/:id/cancel` → cancel an in-flight session; stops the agent and keeps the row as `interrupted` - `GET /sessions/:id` → current persisted session state (push + poll fallback) - `GET /sessions` → list sessions (filter by status/stage) - `GET /sessions/:id/links` → the work→board pipeline-link records for a session +- `DELETE /sessions/:id` → discard a session; stops any live handle and deletes the row ## Sync model diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 60dfbafcf0..1bf5bc522c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -21,7 +21,7 @@ At runtime, settings are merged. **Project settings override global settings** w | `PUT /api/settings` | Update project settings only. | | `GET /api/settings/global` | Get global settings only. | | `PUT /api/settings/global` | Update global settings only. | -| `GET /api/settings/scopes` | Get separated `{ global, project }` view. | +| `GET /api/settings/scopes` | Get separated `{ global, project, workflowSettings }` view. | --- @@ -58,7 +58,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. | | `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. | | `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). | -| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, supportsDeveloperRole?, models? }`; `supportsDeveloperRole` is an OpenAI-compatible opt-in that enables `developer` role emission (default/omitted is `false`, forcing safe `system` role). API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. | +| `customProviders` | `CustomProvider[]` | `[]` | <a id="customproviders"></a>User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), Anthropic-compatible, or Google Generative AI (`apiType: "google-generative-ai"`) providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, supportsDeveloperRole?, models? }`; `supportsDeveloperRole` is an OpenAI-compatible opt-in that enables `developer` role emission (default/omitted is `false`, forcing safe `system` role). API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. | | `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. | | `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. | | `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. | @@ -184,8 +184,9 @@ govern that execution belong to the workflow. **Where to set them.** The common model lanes for a project's default workflow are available directly in **Settings → Project Models → Default workflow model lanes**: Plan/Triage, Executor, and Reviewer. Those dropdown controls use the shared model -picker and still write workflow setting values for the active project's default -workflow; they do not restore the old project settings keys. +picker and are persisted by the Settings modal's primary **Save** action, which +writes workflow setting values for the active project's default workflow; they do +not restore the old project settings keys. For step execution, review/approval policy, fallbacks, title summarization, and custom workflow settings, open the **workflow editor** (the workflow node editor in @@ -235,11 +236,34 @@ These groups moved out of project settings and into workflow settings (built-in | **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | | **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) | +### Workflow-native triage policy settings + +The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path. + +| Setting | Default | Purpose | +|---|---:|---| +| `triageSizeSmallMaxHours` | `2` | Size S upper hour boundary (`S (<2h)`). | +| `triageSizeMediumMaxHours` | `4` | Size M upper hour boundary (`M (2-4h)`). | +| `triageSizeLargeMaxHours` | `8` | Size L upper hour boundary; XL starts at `8h+`. | +| `triageSubtaskStepThreshold` | `7` | Canonical “MORE THAN 7 implementation steps” split-consideration threshold. | +| `triageSubtaskLargeStepSignal` | `9` | Broad-scope signal for large tasks whose plan reaches 9+ steps. | +| `triageSubtaskAdditiveStepSignal` | `12` | Additive partitioning signal for 12+ implementation steps. | +| `triageSubtaskPackageThreshold` | `3` | Canonical package/module breadth threshold (“MORE THAN 3 different packages/modules”). | +| `triageSubtaskFileScopeThreshold` | `20` | File Scope entry count that signals broad work. | +| `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. | +| `triageNoCommitsDecisionVerbs` | all seven built-ins | Decision-only verbs: Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report. | +| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred workflow for decision-only/no-commit tasks. | +| `triageDefaultWorkflowId` | `builtin:coding` | Default workflow for standard coding tasks. | +| `leanPlanning` | `false` | Workflow-native fast-mode policy: select the lean `planning-fast` prompt variant instead of the full triage spec prompt. | +| `autoApproveSpec` | `false` | Workflow-native fast-mode policy: auto-approve generated specs and skip the independent spec reviewer. | + In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor, -and Reviewer dropdown controls for the default workflow. The workflow editor's -Settings → Values tab uses the same dropdown picker for declared provider/model -pairs, including fallbacks. Former locations for advanced workflow policy still -show a short redirect stub linking to the workflow editor (for one release). +and Reviewer dropdown controls for the default workflow. The modal's primary +**Save** action persists pending default-workflow model lane overrides; there is no +separate workflow-model save button. The workflow editor's Settings → Values tab +uses the same dropdown picker for declared provider/model pairs, including +fallbacks. Former locations for advanced workflow policy still show a short +redirect stub linking to the workflow editor (for one release). > Note: the global baseline model lanes (`executionGlobalProvider` etc.) and > integrity guarantees stay where they are — only the per-workflow process policy @@ -271,6 +295,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. | | `heartbeatPromptTemplate` | `"default" \| "compact"` | `"default"` | Heartbeat execution-prompt trim template default. Per-agent `runtimeConfig.heartbeatPromptTemplate` overrides this value. Role fallback when unset everywhere is `executor`→`default`, non-executor coordination roles→`compact`. | | `autoClaimCandidatesInPrompt` | `number` | `5` | Default no-task heartbeat candidate list length. Integer range `0-10`; `0` suppresses candidate prompt injection. | +| `engineerBacklogAutoClaim` | `boolean` | `false` | Opt engineer-role agents into no-task backlog auto-claim for implementation tasks. The default remains executor-only; per-agent `runtimeConfig.engineerBacklogAutoClaim` overrides this project default, and explicit routing/delegation is unchanged. Configure the project default in **Settings → Scheduling & Capacity → Let engineer agents auto-claim backlog tasks**; configure the per-agent override in **Agents → Agent Detail → Settings → Heartbeat Settings → Engineer Backlog Auto-Claim**. | | `defaultNodeId` | `string` | `undefined` | Optional project default execution node for task dispatch. When set, tasks without a per-task `nodeId` override resolve to this node (`routing source: project-default`). See [Task Management → Node Routing](./task-management.md#node-routing). | | `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). | | `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). | @@ -280,7 +305,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | -| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled). For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | +| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | @@ -772,7 +797,7 @@ Short-lived token bounds are enforced server-side: ## Model Selection Hierarchy -Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. +Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. ### Planning model @@ -785,26 +810,26 @@ Fusion resolves task models through workflow-backed lane values first, then glob ### Executor model -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set -2. Per-task `modelProvider` + `modelId` -3. Default workflow lane value `executionProvider` + `executionModelId` -4. Global `executionGlobalProvider` + `executionGlobalModelId` -5. Project `defaultProviderOverride` + `defaultModelIdOverride` -6. Global `defaultProvider` + `defaultModelId` +1. Per-task `modelProvider` + `modelId` +2. Default workflow lane value `executionProvider` + `executionModelId` +3. Global `executionGlobalProvider` + `executionGlobalModelId` +4. Project `defaultProviderOverride` + `defaultModelIdOverride` +5. Global `defaultProvider` + `defaultModelId` +6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured 7. Automatic provider/model resolution ### Heartbeat model (durable agents) Heartbeat sessions for durable agents use this order: -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present -2. Default workflow lane value `executionProvider` + `executionModelId` -3. Global `executionGlobalProvider` + `executionGlobalModelId` -4. Project `defaultProviderOverride` + `defaultModelIdOverride` -5. Global `defaultProvider` + `defaultModelId` +1. Default workflow lane value `executionProvider` + `executionModelId` +2. Global `executionGlobalProvider` + `executionGlobalModelId` +3. Project `defaultProviderOverride` + `defaultModelIdOverride` +4. Global `defaultProvider` + `defaultModelId` +5. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no execution/default pair is configured 6. Automatic provider/model resolution -When heartbeat has both (1) and (2-5), the runtime model is used as primary and the execution-lane model is passed as fallback. On timer-triggered runs, unrecoverable missing-provider credential/registry failures complete as `heartbeat_model_unavailable` instead of permanently setting the durable agent to `state=error`. +On timer-triggered runs, unrecoverable missing-provider credential/registry failures complete as `heartbeat_model_unavailable` instead of permanently setting the durable agent to `state=error`. ### Reviewer model @@ -815,13 +840,13 @@ When heartbeat has both (1) and (2-5), the runtime model is used as primary and 5. Global `defaultProvider` + `defaultModelId` 6. Automatic provider/model resolution -Mission validation sessions use this same validator lane, with an assigned durable agent runtime model taking precedence when the linked task has one. +Mission validation sessions use this same validator lane; assigned durable agent runtime models are only used as a fallback when no complete validator/default pair is configured. ### Merger model -1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set -2. Project `defaultProviderOverride` + `defaultModelIdOverride` -3. Global `defaultProvider` + `defaultModelId` +1. Project `defaultProviderOverride` + `defaultModelIdOverride` +2. Global `defaultProvider` + `defaultModelId` +3. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no default pair is configured 4. Automatic provider/model resolution For post-merge prompt workflow steps, explicit step-level `modelProvider` + `modelId` overrides take precedence over the merger lane above. @@ -837,6 +862,8 @@ Project-scoped model lane used for task title auto-summarization, GitHub trackin 5. Global `defaultProvider` + `defaultModelId` 6. Automatic provider/model resolution +If the configured title summarizer provider/model is stale and no longer exists in the pi model registry, title generation logs a warning with the stale id and retries once with automatic provider/model resolution. Other AI failures (auth, empty output, unavailable engine) still fail normally. + > **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes. The hierarchies above reflect current runtime behavior. --- @@ -1146,7 +1173,7 @@ Common heartbeat/runtime keys on `runtimeConfig` include: | `selfImproveIntervalMs` | `number` | Delay between self-improvement cycles (default 4h, minimum 1h) | | `lastSelfImproveAt` | `string` | Last self-improvement checkpoint timestamp (managed by heartbeat monitor) | -Configure these per agent in **Agents → Agent Detail → Settings → Heartbeat Settings** (dashboard), or by updating agent `runtimeConfig` via the Agents API/CLI config flows. +Configure these per agent in **Agents → Agent Detail → Settings → Heartbeat Settings** (dashboard), or by updating agent `runtimeConfig` via the Agents API/CLI config flows. The **Engineer Backlog Auto-Claim** checkbox in this card controls `runtimeConfig.engineerBacklogAutoClaim` for that agent and only affects no-task backlog pickup; explicit assignment and delegation behavior are unchanged. These examples show agents configured to use Paperclip, Hermes, and OpenClaw runtime hints: diff --git a/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md b/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md new file mode 100644 index 0000000000..11cea018e5 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md @@ -0,0 +1,55 @@ +--- +title: "Mobile auto-merge toggle blanks dashboard via document horizontal scroll" +date: 2026-06-11 +category: ui-bugs +module: packages/dashboard/app/components/Board +problem_type: ui_bug +component: dashboard-board +symptoms: + - "Toggling the in-review Auto-merge switch on a mobile viewport leaves the dashboard blank/white until refresh" + - "React board subtree remains mounted; no PageErrorBoundary fallback or pageerror is emitted" + - "Existing jsdom board/task-card/worktree tests pass because jsdom has no real viewport pan/paint" +root_cause: mobile_document_horizontal_scroll +resolution_type: code_fix +severity: high +related_components: + - packages/dashboard/app/components/Column + - packages/dashboard/app/hooks/useAppSettings + - packages/dashboard/app/styles.css +tags: + - mobile + - real-browser + - auto-merge + - horizontal-scroll + - blank-screen + - fn-6243 +--- + +# Mobile auto-merge toggle blanks dashboard via document horizontal scroll + +## Problem + +The recurring mobile blank-screen regression for the in-review **Auto-merge** toggle was not a React unmount or thrown exception. A real mobile browser can pan the **document** horizontally while bringing the offscreen in-review toggle into view/focus. Once `window.scrollX` is non-zero, the entire dashboard shell is shifted left and the viewport can look blank even though `main.board` and all columns remain mounted. + +## Real-browser evidence + +FN-6243 reproduced this with the existing Playwright CLI against a real dashboard process (`node packages/cli/dist/bin.js dashboard --port 0 --no-auth --dev --paused`) at a 375×812 mobile/touch viewport. + +Pre-fix evidence: + +- Before toggle: `main.board` box `{ x: 0, width: 375, height: 454.828125 }`; in-review column box `{ x: 948, width: 300, height: 430.828125 }`. +- After toggle: `main.board` still existed but box shifted to `{ x: -911, width: 375, height: 454.828125 }`; in-review column shifted to `{ x: -874, width: 300, height: 430.828125 }`. +- `pageErrors: []`. + +Post-fix evidence: + +- After toggle round-trip: `window.scrollX === 0`, `main.board` remained at `{ x: 0, width: 375, height: 454.828125 }`, in-review column was visible with non-zero size, and `pageErrors: []`. + +## Solution + +Keep the document/root horizontal scroll pinned to zero on mobile board stabilization and immediately after the auto-merge toggle fires. The board's own internal horizontal scroll remains the only horizontal scroller; do not reintroduce mandatory scroll snap. + +Regression coverage should include both: + +1. The existing jsdom integration surface for `useAppSettings.toggleAutoMerge` success and rollback paths. +2. A real-browser/manual or smoke run when the bug class involves viewport pan, paint, layout, visual viewport, or fixed mobile chrome. jsdom cannot reproduce this class. diff --git a/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md b/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md new file mode 100644 index 0000000000..ed24c8c4f3 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md @@ -0,0 +1,64 @@ +--- +title: "Mobile board iOS horizontal overscroll containment" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On iOS Safari/PWA, dragging the kanban board past the first or last column rubber-bands the column strip off screen" + - "Horizontal edge overscroll can expose empty space and chain to the document even though the board's inner column scroll is intentional" +root_cause: css_scroll_containment_gap +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/Lane.css + - packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts +tags: + - ios-safari + - mobile-board + - overscroll-behavior + - scroll-snap + - css-regression-test + - kanban +applies_when: + - "A horizontally scrollable board or lane strip uses `overflow-x: auto` with mobile momentum scrolling" + - "Edge dragging should keep native inner scrolling but must not chain or park content off screen" +--- + +# Mobile board iOS horizontal overscroll containment + +## Problem + +The mobile kanban board intentionally scrolls horizontally between columns using `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. On iOS Safari/PWA, that same momentum scroller can rubber-band past its first or last column if the scroller does not contain horizontal overscroll. The visible result is that the columns slide away from the viewport edge, exposing empty space and sometimes chaining the drag to the document. + +## Root cause + +The board had page-level mobile overscroll protection on `html, body`, but the board itself is the horizontal scroll container. The base `.board` and the mobile `@media (max-width: 768px) .board` rules declared the intended scroll and snap properties without `overscroll-behavior-x`, so iOS edge overscroll was not contained at the board boundary. Workflow and multi-lane board variants in `Lane.css` had the same independent horizontal scrollers. + +## Solution + +Add axis-specific containment to each horizontal board strip: + +```css +.board, +.board.board-workflow-columns, +.lane-columns { + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; +} +``` + +Keep `contain` rather than `none`: the board can retain its native inner scroll feel while edge overscroll stops at the board/lane container instead of chaining outward. Do not replace this with `overflow: hidden`/`clip`, and do not switch snap back to `x mandatory`; both would regress intentional mobile column navigation. + +## Regression coverage + +Use a CSS-fixture test that loads the combined dashboard CSS and asserts: + +- the mobile `.board` rule still has `overflow-x: auto` and `scroll-snap-type: x proximity`; +- the mobile `.board` rule declares `overscroll-behavior-x: contain`; +- the base `.board`, `.board.board-workflow-columns`, and `.lane-columns` horizontal scrollers also declare containment; +- no checked board path uses `scroll-snap-type: x mandatory`. + +For FN-6378 this lives in `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts`. diff --git a/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md b/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md new file mode 100644 index 0000000000..b8bd147ac1 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md @@ -0,0 +1,62 @@ +--- +title: "Mobile document horizontal pan containment" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On mobile, the entire dashboard can be horizontally panned into a shifted state" + - "Header, board, and footer slide left together while a dark empty void appears on the right" + - "The inner kanban board should scroll horizontally, but the document/page itself must not" +root_cause: mobile_viewport_containment +resolution_type: css_fix +severity: high +related_components: + - packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts + - packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts + - packages/dashboard/app/__tests__/board-tablet-overflow.test.ts +tags: + - mobile + - viewport + - overflow + - touch-action + - visual-viewport + - kanban-board +--- + +# Mobile document horizontal pan containment + +## Problem + +The mobile dashboard can enter a broken off-axis state where the whole page chrome shifts left and exposes an empty dark strip on the right. The screenshot for FN-6365 showed the header, board, and footer all shifted together, which means the document/visual viewport was panned horizontally — not just the intended `.board` column strip. + +## Root cause + +The mobile global CSS locked `overflow: hidden` on `html`, `body`, and `#root`, but every element was also assigned `touch-action: pan-x pan-y`. That allowed horizontal gestures that began on root chrome, fixed bars, modal chrome, or other non-board surfaces to be interpreted as page-level horizontal panning. The board was the intended horizontal scroller, but the document root did not explicitly enforce vertical-only touch handling, `overflow-x: hidden`, and `overscroll-behavior-x: none` as separate invariants. + +Fullscreen mobile overlays were also only constrained by `width/max-width: 100%`; adding logical inline-size constraints keeps modal/overlay chrome from widening the document when the layout viewport and visual viewport diverge. + +## Fix + +In the mobile `@media (max-width: 768px)` global block: + +- Lock `html`, `body`, and `#root` to the viewport inline axis with `width/max-width: 100%`, `overflow-x: hidden`, and `overscroll-behavior-x: none`. +- Make document-root/default touch handling vertical-only with `touch-action: pan-y`. +- Opt the known legitimate horizontal scrollers back into `touch-action: pan-x pan-y`: `.board`, `pre`, `code`, `.code-block`, and `table`. +- Keep `.board` horizontally scrollable with `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. +- Constrain mobile fullscreen overlay/modal chrome with `inline-size: 100%`, `max-inline-size: 100%`, and `min-width: 0` where appropriate. + +## Regression coverage + +`packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts` asserts the containment contract directly from CSS fixtures: + +- Mobile root has `overflow-x: hidden`, `overscroll-behavior-x: none`, and `touch-action: pan-y`. +- The mobile `.board` still has `overflow-x: auto` and `scroll-snap-type: x proximity`. +- Code/table opt-in horizontal scrollers keep `touch-action: pan-x pan-y`. +- Fullscreen overlay/modal chrome is constrained to the viewport inline size. +- The tablet `.board` overflow rule remains unchanged. + +## Pitfall + +Do not fix this class by blanket-clipping all descendants or removing `.board` horizontal scrolling. The board, code blocks, and tables are valid inner horizontal scrollers; the invariant is that the document/visual viewport itself must stay at horizontal offset zero. diff --git a/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md b/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md new file mode 100644 index 0000000000..0b744fdd94 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-ios-restore-document-scroll-drift.md @@ -0,0 +1,59 @@ +--- +title: "Mobile iOS restore document scroll drift" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/hooks/useMobileScrollLock +problem_type: ui_bug +component: frontend_mobile_layout +applies_when: "An iOS Safari/PWA dashboard tab is restored from background or bfcache after the document has stale scroll or orphaned body offset." +symptoms: + - "Returning to Fusion on iOS can leave the header/board pushed above the top of the screen" + - "A large empty gap appears at the bottom even though the soft keyboard is down" + - "The dashboard resting layout should have document scroll at the origin because body overflow is hidden" +root_cause: ios_restore_left_stale_document_scroll_or_body_offset +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/App.tsx + - packages/dashboard/app/hooks/useMobileScrollLock.ts + - packages/dashboard/app/hooks/useMobileKeyboard.ts + - FN-6362 + - FN-6364 +tags: + - ios-safari + - mobile-keyboard + - document-scroll + - visualviewport + - bfcache +--- + +# Mobile iOS restore document scroll drift + +## Problem + +On iOS Safari/PWA, switching away from Fusion and returning can leave the layout viewport visually misaligned with the dashboard. The document may retain `window.scrollY > 0`, or a stale inline body offset from an earlier lock, even though Fusion's base shell uses `body { overflow: hidden }` and the resting document scroll position should be `(0, 0)`. + +The visible symptom is the board/header appearing shifted upward with an empty gap at the bottom after foregrounding the app, including cases where no input is currently focused. + +## Solution + +Keep keyboard metrics recovery and document-scroll recovery as separate concerns: + +- FN-6362 resets `useMobileKeyboard` metrics on `visibilitychange`/`pageshow` so `--vv-offset-top` consumers stop seeing a stale keyboard-open state. +- FN-6364 adds `useMobileViewportRestoreReset` in `useMobileScrollLock.ts` and wires it once from `App.tsx` for mobile layouts. + +The restore hook only runs on iOS mobile devices. On `document.visibilitychange` it acts only when `document.visibilityState === "visible"`, and on `window.pageshow` it handles normal and bfcache restores. If no fullscreen scroll lock or keyboard viewport lock is active, it clears orphaned body fixed-position offset styles and calls `window.scrollTo(0, 0)` when stale document scroll is present. + +Do not run this reset on Android or desktop, and do not run it while `useMobileScrollLock` or `useMobileKeyboardViewportLock` is active; live locks own their own restore path. + +## Regression coverage + +Cover the invariant at the `useMobileScrollLock` hook seam: + +- iOS mobile + `visibilitychange` to visible + `scrollY > 0` calls `scrollTo(0, 0)`. +- iOS mobile + `pageshow` with `persisted: false` calls `scrollTo(0, 0)`. +- Android and desktop restore events are no-ops. +- `visibilitychange` to hidden is a no-op. +- Active fullscreen scroll locks and keyboard viewport locks prevent the restore hook from fighting the live lock. +- `scrollY === 0` is idempotent. +- Orphaned body `position: fixed` / `top` offset is cleared only when no lock is active. diff --git a/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md b/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md new file mode 100644 index 0000000000..2eb5e8bc30 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-keyboard-restore-stale-viewport.md @@ -0,0 +1,59 @@ +--- +title: "Mobile keyboard restore stale viewport reset" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/hooks/useMobileKeyboard +problem_type: ui_bug +component: frontend_mobile_layout +applies_when: "A mobile browser restores the page from hidden/pageshow after the soft keyboard collapses while the focused input remains active." +symptoms: + - "Returning to the dashboard on iOS can leave mobile layout in a keyboard-open state after the keyboard is already down" + - "Viewport height/offset metrics remain stale when an input stays focused across the hidden → visible or pageshow transition" + - "Footer/mobile-nav spacing can stay suppressed until a later resize or blur event corrects the metrics" +root_cause: stale_visualviewport_sample_held_after_restore +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/App.tsx + - packages/dashboard/app/utils/mobileBarKeyboardFlags.ts + - FN-5155 + - FN-6362 +tags: + - mobile-keyboard + - visualviewport + - ios + - pageshow + - visibilitychange + - viewport-metrics +--- + +# Mobile keyboard restore stale viewport reset + +## Problem + +`useMobileKeyboard` protects normal in-session keyboard handling from impossible iOS samples: when an input is focused, a transient sample that reports a restored full viewport but still carries stale open-keyboard metrics can be held so the dashboard does not flicker. That FN-5155 guard is useful while the page is active, but it also masked a real restore transition. + +When the app returned from `hidden`/`pageshow` with the soft keyboard collapsed and the focused input still active, the hook reused the previous open-keyboard metrics. Because focus remained on the input, the impossible-sample hold treated the collapsed restore sample as suspicious and kept `keyboardOpen`, `viewportHeight`, and `offsetTop` stale until another resize or blur arrived. + +## Solution + +Handle page restore as a distinct sampling path rather than weakening the normal in-session guard. + +- On `visibilitychange` back to `visible` and on `pageshow`, take an immediate restore sample. +- If the restore sample is a collapsed/full-height viewport, reset the baseline viewport height and bypass the impossible-sample hold for that one sample. +- Keep FN-5155's impossible-sample hold in place for regular resize/focus/tail updates. +- Continue scheduling delayed tail updates after restore so later iOS viewport corrections still land. + +This lets a collapsed restore clear `keyboardOpen`, `viewportHeight`, and `offsetTop` even when `document.activeElement` is still an input, while a genuinely open restored keyboard remains open. + +## Regression coverage + +Cover restore as a surface invariant, not only the single iOS reproduction: + +- `visibilitychange` from hidden to visible with retained focus and a collapsed viewport resets stale open-keyboard metrics. +- `pageshow` with stale positive `visualViewport.offsetTop` drift clears the keyboard state when the viewport is full height. +- A genuinely shrunken restored viewport remains keyboard-open. +- Android-style shrink metrics reset without carrying iOS offset drift. +- Existing FN-5155 in-session impossible-sample coverage remains green, proving the normal guard was not removed. + +The hook-level test seam is preferable here because callers already consume the hook-provided `keyboardOpen` and viewport values; no consumer-specific behavior needed to change. diff --git a/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md new file mode 100644 index 0000000000..ed4531f5f0 --- /dev/null +++ b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md @@ -0,0 +1,58 @@ +--- +title: "Quick Chat mobile keyboard board shift" +date: 2026-06-12 +category: ui-bugs +module: packages/dashboard/app/utils/mobileBarKeyboardFlags +problem_type: ui_bug +component: frontend_mobile_layout +applies_when: "A fullscreen mobile overlay owns its own soft-keyboard and visual-viewport handling while the dashboard board remains mounted underneath." +symptoms: + - "Opening Quick Chat on mobile focuses the composer and raises the soft keyboard" + - "The board underneath shifts upward because App-level keyboard logic removes footer/mobile-nav padding" + - "After dismissing the keyboard or closing Quick Chat, the board can remain shifted with a bottom gap" +root_cause: overlay_keyboard_state_leaked_to_board_layout +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/App.tsx + - packages/dashboard/app/components/QuickChatFAB.tsx + - packages/dashboard/app/hooks/useMobileScrollLock.ts + - FN-6329 +tags: + - quick-chat + - mobile-keyboard + - visualviewport + - overlay-layout + - footer-padding +--- + +# Quick Chat mobile keyboard board shift + +## Problem + +Quick Chat's mobile UI is a fullscreen fixed sheet that covers the board and manages its own keyboard viewport with `--vv-height` and `--vv-offset-top`. App-level mobile keyboard logic did not know that sheet was open, so the Quick Chat composer keyboard was treated like an inline board keyboard. + +On iOS, `computeMobileBarKeyboardFlags` returned `footerHidden: true` whenever `isMobile`, `keyboardOpen`, and `!anyModalOpen` were true. `App.tsx` mapped that to `mobileKeyboardOpen`, which removed `project-content--with-footer` / `project-content--with-mobile-nav` and hid the footer. Because Quick Chat is not part of `modalManager.anyModalOpen`, the board behind the sheet shifted up and could remain offset after iOS keyboard dismissal lag. + +## Solution + +Model fullscreen mobile overlays as explicit board-layout suppressors in `computeMobileBarKeyboardFlags`. + +- Keep existing modal suppression intact. +- Add an `overlayOpen` input and suppress only `footerHidden` when `anyModalOpen || overlayOpen` is true. +- Preserve `navKeyboardOpen` and `footerKeyboardOpen` semantics so mobile nav/footer keyboard classes continue to reflect keyboard state where needed. +- In `App.tsx`, pass `overlayOpen: isMobile && quickChatOpen` so only Quick Chat's mobile fullscreen sheet suppresses board layout. Desktop Quick Chat remains unaffected. + +This keeps the board's footer/mobile-nav padding classes present for the entire time Quick Chat is open. The board therefore never shifts in response to the Quick Chat keyboard, leaving nothing to snap back after the overlay closes. + +## Regression coverage + +Cover the invariant at the pure helper seam: + +- iOS + mobile + keyboard + no overlay still hides the footer for inline board keyboards. +- iOS + mobile + keyboard + modal keeps the footer visible. +- iOS + mobile + keyboard + fullscreen overlay keeps the footer visible. +- Android + mobile + keyboard + fullscreen overlay keeps `footerHidden` false while preserving nav keyboard state. +- Non-mobile remains all-false. + +Prefer this narrow helper coverage over mock-heavy `App` rendering unless a future regression needs DOM-level evidence. `computeMobileBarKeyboardFlags` has a single production caller in `App.tsx`, making the seam small and reliable. diff --git a/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md new file mode 100644 index 0000000000..e1b80a123d --- /dev/null +++ b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md @@ -0,0 +1,57 @@ +--- +title: "Tablet keyboard viewport mode flip" +date: 2026-06-10 +category: ui-bugs +module: packages/dashboard/app/hooks/useViewportMode +problem_type: ui_bug +component: frontend_responsive_layout +symptoms: + - "Opening the virtual keyboard on a tablet shrinks CSS/visual viewport height below the mobile height breakpoint" + - "Dashboard shell snaps from tablet/desktop layout into mobile layout while typing, then snaps back when the keyboard closes" + - "Downstream surfaces such as ChatView sidebars, Board stabilization, WorkflowNodeEditor, and SessionTerminal inherit the wrong mobile mode" +root_cause: responsive_breakpoint +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/Board.tsx + - packages/dashboard/app/components/WorkflowNodeEditor.tsx + - packages/dashboard/app/components/SessionTerminal.tsx + - FN-6210 +tags: + - viewport-mode + - virtual-keyboard + - responsive-layout + - tablet + - mobile-breakpoint + - visualviewport +--- + +# Tablet keyboard viewport mode flip + +## Problem + +`MOBILE_MEDIA_QUERY` intentionally includes `(max-height: 480px)` so landscape phones remain in mobile mode even when their CSS width exceeds `768px`. On tablets and desktops, however, opening a virtual keyboard can shrink the CSS viewport height (and iOS `visualViewport.height`) below `480px` without changing the physical device size. Any code that treated the height clause alone as mobile caused the dashboard shell and responsive consumers to flip into mobile layout while the user typed. + +## Solution + +Keep the exported `MOBILE_MEDIA_QUERY` string unchanged for listener compatibility, but route runtime mobile decisions through `isMobileViewport()`: + +- `(max-width: 768px)` still resolves mobile directly. +- `(max-height: 480px)` resolves mobile only when `window.screen` has a phone-class short edge (`Math.min(width, height) <= 480`). +- Missing/zero `window.screen` data fails safe to width-only detection. + +This preserves landscape-phone behavior while preventing keyboard-driven height shrink from changing tablet/desktop viewport mode. Direct breakpoint consumers (`Board`, `WorkflowNodeEditor`, and `SessionTerminal`) should subscribe to `MOBILE_MEDIA_QUERY` for reactivity but recompute state with `isMobileViewport()` rather than reading `.matches` as the final decision. + +ChatView also keeps a defense-in-depth CSS guard from FN-6210: `.chat-sidebar` has a non-mobile `max-width` matching `CHAT_SIDEBAR_MAX_WIDTH`, with the mobile media rule overriding it back to `100%`. That guard bounds the sidebar even if viewport-mode state is temporarily wrong and the inline sidebar width is removed. + +## Regression coverage + +Cover the invariant rather than the single repro: + +- Tablet-class physical screen with short viewport height stays `tablet`. +- Desktop-class physical screen with short viewport height stays `desktop`. +- Landscape phone with phone-class physical screen and short viewport stays `mobile`. +- Portrait phone width stays `mobile` regardless of height. +- Undefined/zero `window.screen` does not throw and falls back to width-only detection. +- Component-local mobile hooks such as `SessionTerminal` also use the guarded predicate. +- ChatView sidebar CSS remains bounded at the sidebar max width during simulated viewport-mode flicker while the keyboard is open. diff --git a/docs/task-management.md b/docs/task-management.md index fd81282534..849f2641c4 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -211,7 +211,7 @@ Expand the creation panel (▼) to access additional controls: - **Agent** — Assign an agent to the task - **Branch settings** (`branch` / `baseBranch`) remain available in full task forms and task detail editing (not in Quick Entry) - **Review** — Set review rigor level (None, Plan Only, Plan and Code, Full) -- **Browser Verify** — Enable browser verification workflow step +- **Optional workflow steps** — Enable workflow-declared optional steps for the selected workflow. The built-in coding workflow exposes **Browser Verification** here and keeps it opt-in by default. ### 6) CLI creation @@ -444,10 +444,13 @@ When `executionMode: "fast"`, the following automated review/validation gates ar |------|---------------|-----------| | `review_step` tool enforcement | Available to executor agent | **Not injected** | | Pre-merge workflow-step execution | Runs configured steps | **Skipped** | +| Custom graph pre-merge prompt/script/gate nodes | Run in selected custom workflows | **Skipped** | | Workflow revision loop | Enabled (feedback → fix → re-review) | **Disabled** | ### Fast Mode Mandatory Gates +The bypass applies to both the legacy workflow-step path and the workflow graph executor path (including custom non-`builtin:coding` workflows). `undefined` or `null` execution mode is treated as standard mode. + The following quality gates **remain enforced** in fast mode: | Gate | Behavior | @@ -461,7 +464,8 @@ The following quality gates **remain enforced** in fast mode: | Feature | Standard | Fast | |---------|----------|------| | Executor agent session | Full prompt + tools | Full prompt (minus review_step) | -| Pre-merge workflow steps | ✅ Run | ❌ Bypassed | +| Pre-merge workflow steps (legacy, builtin, and custom graph workflows) | ✅ Run | ❌ Bypassed | +| Custom graph prompt/script/gate validation nodes | ✅ Run | ❌ Bypassed | | `review_step` tool | ✅ Available | ❌ Not available | | Post-merge workflow steps | ✅ Run | ✅ Run | | Completion blockers (test/build/typecheck) | ✅ Enforced | ✅ Enforced | @@ -606,8 +610,9 @@ Behavior: ### Archive behavior -- `fn task archive <id>` moves done task to `archived` -- Dashboard delete confirmations for `done` tasks now include an **Archive Instead** action so users can preserve history without soft-deleting the task. This option is shown only for `done` tasks because the store-level archive contract only allows archiving from the `done` column. +- `fn task archive <id>` moves any live-board task (`triage`, `todo`, `in-progress`, `in-review`, or `done`) to `archived`; tasks already in `archived` are rejected. +- Archive records the task's `preArchiveColumn` so restore can return to the original live column instead of always assuming `done`. +- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task. - Cleanup mode can persist compact metadata and remove the task directory - Archived tasks are read-only for task log/document writes: - `logEntry()` throws `Task <id> is archived — logging is read-only` @@ -623,7 +628,7 @@ Behavior: Archive entries preserve key metadata needed for restoration, including: -- `id`, `title`, `description`, `priority`, `column` +- `id`, `title`, `description`, `priority`, `column`, `preArchiveColumn` - `dependencies`, `steps`, `currentStep` - `size`, `reviewLevel`, `prInfo` (primary mirror), `prInfos` (canonical linked PR list), `issueInfo` - `attachments` metadata @@ -639,7 +644,7 @@ Archive entries preserve key metadata needed for restoration, including: - Restores archive entry if directory is missing - Rebuilds `PROMPT.md` -- Moves task to `done` +- Moves task back to its recorded `preArchiveColumn` when available, falling back to the archived snapshot's prior `column`, then to `done` for legacy archive entries. - Logs “Task restored from archive” when recovering from compact archive entry ### Task-ID collision safety and operator recovery @@ -851,6 +856,8 @@ Users can apply presets at task creation; manual model selection can override th When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks). GitHub tracking now waits for the `createTask`-level summarizer (explicit or auto-attached from settings) to settle before filing, then uses that resulting title and falls back to deterministic description-derived title generation only when summarization is unavailable. +If a configured title summarizer model is stale after a pi upgrade, Fusion logs a warning naming that provider/model and retries once with automatic model resolution before falling back to deterministic title generation. Genuine AI-service failures are not masked by this retry. + ## Screenshots ### Board/task cards + quick entry @@ -869,7 +876,10 @@ Use `noCommitsExpected: true` for tasks where the deliverable is a decision/repo - Meaning: executor allows `fn_task_done` with zero commits for that task. - Triage auto-sets it only when the task is clearly decision-shaped (e.g. "Decide whether...", "Evaluate...", "Verify...", "Audit...") with explicitly observational acceptance criteria and explicit no-code language. +- Review Level 1 coordination/routing tasks that are board-only, explicitly say not to change source, and scope only task documents/metadata can also complete without commits even if older prompts omitted the explicit flag. This fallback is intentionally narrow and exists to recover plan-only coordination work; it does not bypass wrong-worktree or wrong-branch checks. - Ambiguous/forked tasks (e.g. "Investigate..." or "Investigate and fix if needed") leave it unset by default. +- Implementation, feature, bug-fix, source-docs, test, config, or broad investigation tasks still require commits unless they have an explicit and valid no-commit contract. +- If a legacy coordination task is stuck with `fn_task_done refused: no_commits`, prefer setting/verifying `noCommitsExpected` and re-running normal no-op finalization rather than editing `.fusion/fusion.db` directly. - You can manually set/clear it in Task Detail via **No commits expected (decision-only task)**. - Task cards show a **decision-only** badge when enabled. - Finalization still uses the existing no-op review/merge path (`mergeDetails.noOpMerge: true`, `mergeConfirmed: true`); no synthetic merge strategy values are introduced. diff --git a/docs/test-speed-audit-FN-5048.md b/docs/test-speed-audit-FN-5048.md index 24f99daaf8..714aee75da 100644 --- a/docs/test-speed-audit-FN-5048.md +++ b/docs/test-speed-audit-FN-5048.md @@ -118,6 +118,32 @@ - `ChatView.test.tsx`: **12.53s → 14.66s** (390 tests; sampled rerun modestly higher while preserving coverage) - FN-5074 preserved FN-tagged coverage and frozen-button assertions; all four files pass in isolation and full verification gates remained green in task execution. +## FN-6307 follow-up results +- Targeted isolated re-measure used the current dashboard quality projects with the dot reporter and a 2x back-to-back flakiness check for: + - `src/__tests__/routes-agents.test.ts` + - `src/__tests__/routes-git.test.ts` + - `src/__tests__/routes-planning.test.ts` + - `app/components/__tests__/FileEditor.test.tsx` + - `app/components/__tests__/NewTaskModal.test.tsx` +- Results (tests unchanged): + +| File | Tests | Baseline duration/test time | After run 1 duration/test time | After run 2 duration/test time | Outcome | +|---|---:|---:|---:|---:|---| +| `routes-agents.test.ts` | 174 | 58.43s / 46.64s | 33.42s / 28.84s | 27.06s / 24.31s | retained coverage; no source edits needed | +| `routes-git.test.ts` | 98 | 10.71s / 7.89s | 17.81s / 15.37s | 10.65s / 8.25s | retained coverage; no source edits needed | +| `routes-planning.test.ts` | 102 | 7.74s / 4.97s | 4.76s / 2.30s | 4.17s / 1.64s | replaced duplicate rate-limit HTTP loop with direct limiter seeding while preserving boundary HTTP assertions | +| `FileEditor.test.tsx` | 45 | 5.31s / 3.76s | 6.99s / 3.57s | 4.83s / 3.71s | retained CSS/layout suites unchanged | +| `NewTaskModal.test.tsx` | 50 | 3.83s / 2.16s | 3.58s / 1.95s | 3.54s / 1.97s | replaced disabled-button negative polling waits with direct assertions | + +- Combined after-run 2 duration for the five targeted files was **50.25s** versus **86.02s** in the Step 0 baseline. Test counts stayed constant; no FN-tagged regression coverage was removed. + +## FN-6308 dashboard orchestration follow-up results +- `pnpm --filter @fusion/dashboard test` now uses `packages/dashboard/scripts/run-quality-tests.mjs` to run the same 15 quality lanes with bounded process concurrency (`FUSION_DASHBOARD_TEST_CONCURRENCY`, default/hard cap `2`) while preserving the 6144 MiB per-lane heap wrapper and app/API split that avoids historical jsdom SIGKILL/OOM. +- Same-machine warm baseline before orchestration: **446.8s** for the sequential dashboard quality chain. +- Post-orchestration measurements: **202.2s** after the route-settings mock update and **193.3s** after the lint import fix, a roughly **55–57%** wall-clock reduction versus the FN-6308 baseline while preserving the file-set parity guard. +- CI-shape validation with `FUSION_TEST_TOTAL_WORKERS=6 FUSION_TEST_CONCURRENCY=2 pnpm --filter @fusion/dashboard test` passed in **192.5s**. Standard and CI-shape logs were scanned for SIGKILL/OOM/fatal heap symptoms with none found. + ## Notes - Dashboard `vitest run` baseline and post-change measurements both surfaced broad failing suites outside this task’s implementation scope; timing evidence is still captured from the same command family. +- FN-6307 verification also observed pre-existing isolated failures in `src/__tests__/routes-settings.test.ts` (`GET /settings/scopes` returning 500 for scoped settings cases) while targeted files remained green. FN-6308 updated that stale mock/response expectation after `/api/settings/scopes` added `workflowSettings`. - Standing prevention rule: see `AGENTS.md` → **Standing Rule: Do Not Add Slow Tests (FN-5048)**. diff --git a/docs/testing.md b/docs/testing.md index c158019d21..b4edb7b682 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -45,6 +45,29 @@ pnpm --filter @fusion/dashboard test:build # built client output contra Run `test:deep` when changing broad dashboard architecture, shared modal/view infrastructure, or route registration. Run `test:browser-smoke` for layout/responsive/navigation/modal/CSS changes. Run `test:build` for Vite output, lazy-loading, chunking, or client-dist changes. +`pnpm --filter @fusion/dashboard test` runs the curated app/API quality gate through +`packages/dashboard/scripts/run-quality-tests.mjs` (FN-6308). The orchestrator keeps +the historical app/API quality split and the curated/backfill lane boundaries, but +schedules independent lanes with bounded process concurrency instead of chaining every +Vitest launch sequentially. Each lane still runs through +`packages/dashboard/scripts/run-vitest-with-heap.mjs --heap=6144`; do not bypass that +wrapper or recombine the jsdom-heavy app/API projects, because the old combined run +was SIGKILLed by heap pressure under workspace worker budgeting. The top-level +`pretest` artifact bootstrap runs once before the orchestrator; lane subprocesses must +not re-run `scripts/ensure-test-artifacts.mjs`. + +Concurrency knobs: + +- `FUSION_DASHBOARD_TEST_CONCURRENCY` controls dashboard quality lane process + concurrency, defaulting to `2` and hard-capped at `2` to preserve the measured heap + budget. +- Per-lane heap is fixed at `6144` MiB by the orchestrator. Treat any code change that + makes this configurable or increases it as risky and re-measure for OOM/SIGKILL before + landing. +- `FUSION_TEST_TOTAL_WORKERS` / `FUSION_TEST_CONCURRENCY` (or targeted + `VITEST_MAX_WORKERS`) still bound Vitest thread fan-out inside each process via + `computeMaxWorkers`; do not raise them casually for dashboard/jsdom runs. + New test files under `app/**` or `src/**` are picked up automatically by the **backfill lanes** (`dashboard-app-quality-backfill` / `dashboard-api-quality-backfill`), which include the broad globs and exclude only the files an explicit curated lane @@ -137,12 +160,12 @@ commensurably. Untimed packages are named in a logged warning. - **Engine** keeps `vitest --shard X/Y` virtual slicing (its `test` is a single vitest invocation: `--project=engine-default --project=engine-reliability`); slices are now weighted by duration. -- **Dashboard** is *not* `--shard`-sliced — its `test` script is a chain of many separate - vitest invocations, so a forwarded `--shard` cannot apply coherently. Instead each leaf - lane in the chain (enumerated programmatically from `packages/dashboard/package.json` by - expanding the `pnpm run <name>` graph under `test`) is a separately-weighted schedulable - unit; a shard runs `pnpm --filter @fusion/dashboard run <lane>` for its assigned lanes. - Every lane is assigned to exactly one shard. **Lane weight** is the sum of durations of +- **Dashboard** is *not* `--shard`-sliced — its default `test` script is a bounded + concurrent lane orchestrator, so a forwarded `--shard` cannot apply coherently. Instead + each leaf quality lane (enumerated programmatically from `packages/dashboard/package.json` + and the dashboard quality orchestrator) is a separately-weighted schedulable unit; a shard + runs `pnpm --filter @fusion/dashboard run <lane>` for its assigned lanes. Every lane is + assigned to exactly one shard. **Lane weight** is the sum of durations of the files the lane's `--project`s execute, derived from the vitest config project `include`/`exclude` globs (imported via `tsx`); if the config cannot be imported the package duration is apportioned evenly across lanes (logged as `even-apportionment`). @@ -299,3 +322,15 @@ Copy this checklist into a bug-fix or UI-affordance add/remove task's `## Surfac - [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden Motivating incident: FN-6115/FN-6118/FN-6123 — a single workflow-row chevron required three tasks to fully remove because the affordance rendered across multiple components and one mobile surface kept an empty `btn-icon` button shell. + +### Symptom Verification for bug-class tasks + +Bug-class/bug-fix tasks must also include a `## Symptom Verification` section so FN-5893 acceptance proves the original user-visible failure is gone, not merely that a change landed or broad checks are green. Feature/docs/non-bug tasks are not required to carry this section. + +Use the exact heading `## Symptom Verification` and include all three required contents: + +- [ ] **Original symptom** — what the user/issue reported was broken. +- [ ] **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure. +- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test. + +Symptom-based acceptance is mandatory for bug fixes: reproduce the original failure, prove it is gone, and keep the invariant covered across the `## Surface Enumeration` checklist. Green build/tests alone are insufficient when they do not exercise the reported symptom. diff --git a/docs/workflow-policy-ownership-map.md b/docs/workflow-policy-ownership-map.md new file mode 100644 index 0000000000..ada7625224 --- /dev/null +++ b/docs/workflow-policy-ownership-map.md @@ -0,0 +1,78 @@ +# Workflow Policy Ownership Map + +## Purpose + +This map is the U1 characterization artifact for moving merge, retry, scheduling, +and recovery policy into workflow IR/runtime. It classifies current production +branches before code is deleted or moved so later cutover work can prove that no +legacy engine control path was left unowned. + +## Ownership Categories + +- `substrate`: engine/core mechanics that remain below workflow policy. +- `workflow-policy`: decisions that must be represented by workflow nodes, + workflow node state, or workflow recovery events. +- `capability`: operations invoked by workflow nodes while still using shared + guard services. +- `compat-projection`: legacy task fields or records that may remain as + derived summaries during migration. +- `delete-after-cutover`: branches that should disappear once workflow parity is + authoritative. + +## Catalog + +| Surface | Current source | Current owner | Target owner | Disposition | +|---|---|---|---|---| +| Auto-merge queue enqueue and dequeue | `packages/engine/src/project-engine.ts` | `ProjectEngine` merge queue | workflow merge work items and merge-gate nodes | `workflow-policy`, `delete-after-cutover` | +| In-review handoff delay and startup sweep | `packages/engine/src/project-engine.ts` | `task:moved` listener plus in-review scan | workflow completion handoff node creates merge work | `workflow-policy` | +| Manual `onMerge` requests | `packages/engine/src/project-engine.ts` | engine public merge queue entry point | explicit human/manual workflow event that wakes merge node | `workflow-policy`, `capability` | +| Merge request shadow contract | `packages/core/src/store.ts`, `packages/engine/src/project-engine.ts`, `packages/engine/src/merger.ts` | store record plus shadow parity branches | workflow work-item state or compatibility projection | `compat-projection` | +| Merge checkout, integration, conflict resolution, squash, finalize | `packages/engine/src/merger.ts`, `packages/engine/src/merger-ai.ts`, `packages/engine/src/merger-integration-worktree.ts` | merger lifecycle procedures | workflow merge node capabilities calling guard services | `capability` | +| Branch-group member integration and group promotion | `packages/engine/src/group-merge-coordinator.ts`, `packages/engine/src/merge-trait.ts`, `packages/engine/src/merger-integration-worktree.ts` | group coordinator and merger helpers | branch-group workflow subgraph with separate member and promotion nodes | `workflow-policy`, `capability` | +| Merge target and auto-merge eligibility guards | `packages/core/src/task-merge.ts` | shared helper used by engine paths | shared guard service called by workflow nodes | `substrate` | +| Dependency satisfaction treats `in-review` as satisfied | `packages/engine/src/scheduler.ts`, `packages/core/src/task-merge.ts` | scheduler/task helper lifecycle interpretation | workflow completion handoff state and compatibility projection | `workflow-policy`, `compat-projection` | +| Active scope leases include unmerged `in-review` worktrees | `packages/engine/src/scheduler.ts` | scheduler overlap policy | workflow work leases plus repository guard services | `workflow-policy`, `substrate` | +| PR monitor starts/stops from `in-review` transitions | `packages/engine/src/scheduler.ts` | scheduler task-move listener | workflow PR/watch nodes or workflow events | `workflow-policy` | +| Generic agent capacity, routing, claim, and lease mechanics | `packages/engine/src/scheduler.ts` | scheduler | scheduler substrate claiming runnable workflow work | `substrate` | +| Executor retry storm cap | `packages/engine/src/__tests__/executor-retry-storm.test.ts`, `packages/engine/src/project-engine.ts` | engine retry counters and execution loop | workflow node retry policy plus runtime substrate guard | `workflow-policy`, `substrate` | +| Generic backoff helpers | `packages/engine/src/retry-with-backoff.ts`, `packages/engine/src/rate-limit-retry.ts` | helper functions | reusable substrate helper called by retry nodes | `substrate` | +| Transient merge error classification | `packages/engine/src/transient-merge-error-classifier.ts` | helper used by merger/self-healing | merge-node classification input, not route owner | `substrate` | +| Task-level retry summary fields | `packages/core/src/retry-summary.ts`, `packages/core/src/manual-retry-reset.ts` | task metadata and reset patch | compatibility projection from workflow node/run retry state | `compat-projection` | +| Manual retry reset | `packages/core/src/manual-retry-reset.ts`, dashboard/API callers | task metadata patch | workflow event clearing targeted failed node retry state | `workflow-policy`, `compat-projection` | +| Recover mergeable in-review tasks | `packages/engine/src/self-healing.ts` | self-healing directly re-enqueues merge | workflow recovery event wakes merge node | `workflow-policy`, `delete-after-cutover` | +| Completion handoff limbo recovery | `packages/engine/src/self-healing.ts` | self-healing re-emits auto-merge handoff | workflow recovery event or idempotent handoff node wake | `workflow-policy` | +| Transient merge failure recovery | `packages/engine/src/self-healing.ts` | self-healing resets merge retries and re-enqueues | merge-node retry policy and retry-after work item | `workflow-policy`, `delete-after-cutover` | +| Stale merge status recovery | `packages/engine/src/self-healing.ts` | self-healing clears status and may enqueue merge | workflow recovery event plus merge work reconciliation | `workflow-policy` | +| Already-landed and no-op finalization | `packages/engine/src/self-healing.ts`, `packages/engine/src/merger.ts` | self-healing/merger lifecycle paths | workflow recovery/finalize nodes with repository guard services | `workflow-policy`, `capability` | +| Backward in-review recovery paths | `packages/engine/src/self-healing.ts`, `docs/self-healing-backward-move-audit.md` | proof-gated self-healing mutations | workflow recovery nodes; engine only emits facts | `workflow-policy`, `delete-after-cutover` | +| Workflow runtime execution facade | `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-graph-executor.ts` | runtime executes graph nodes | remains workflow runtime owner | `substrate`, `workflow-policy` | +| Built-in default workflow definitions | `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, `packages/core/src/builtin-pr-workflow-ir.ts` | partial lifecycle expression | authoritative source for default scheduling, retry, merge, and recovery regions | `workflow-policy` | +| Dashboard task-card merge/retry/stall badges | `packages/dashboard/app/components/TaskCard.tsx` | task fields and legacy classifications | workflow run/work-item projection first, legacy fields second | `compat-projection` | +| Reliability and diagnostics surfaces | `docs/diagnostics.md`, dashboard reliability views | self-healing and engine status strings | workflow-native recovery and held-work reasons | `compat-projection` | + +## Non-Bypassable Guard Services + +These remain centralized and are called by workflow node capabilities before +mutating git state: + +- File-scope and squash overlap checks. +- Branch target and branch-group target validation. +- Worktree ownership and lease checks. +- Auto-merge processing gate, including `autoMerge:false` terminal-until-human + semantics and the shared-branch member integration exception. +- Run-audit correlation for git operations and recovery facts. + +## Deletion Gates + +- No production caller may start checkout, branch integration, squash, or finalize + except a workflow merge node or an explicit human/manual API that records an + equivalent workflow event. +- `Scheduler` may claim runnable workflow work, apply capacity/routing/leases, + and monitor PR/watch substrate events; it must not infer merge eligibility, + retry routing, or task lifecycle advancement from task columns. +- `SelfHealingManager` may publish typed recovery facts and reconcile metadata; + it must not directly requeue, pause, fail, unpause, or move merge/retry tasks + except through guarded workflow primitives. +- Task-level retry and merge fields are compatibility summaries. Workflow + run/node/work-item state is the policy authority. + diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 0f2c2f75b5..11cbabeb58 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -34,13 +34,13 @@ The workflow runtime is the authoritative execution path for task lifecycle work The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows. -The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing/corrupt explicit custom selections fail closed as workflow-resolution failures instead of silently running the default. The built-in IR encodes the legacy lifecycle path as graph stages: +The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing/corrupt explicit custom selections fail closed as workflow-resolution failures instead of silently running the default. The built-in IR encodes the legacy lifecycle path as graph stages, with merge represented by workflow-native policy primitives rather than a single linear merge seam: -- `triage/planning` → `execute` → `workflow-step` → `review` → `merge` → `end` +- `triage/planning` → `execute` → `workflow-step` → `review` → `merge-gate` / branch-group integration / `merge-attempt` / retry or manual hold → `end` `builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure. -During triage/planning sessions, agents can call `fn_workflow_list` to discover available built-in and custom workflows and read their descriptions before routing work. They can call `fn_workflow_select` to select a workflow for the task being specified, or pass `workflow_id` when creating child tasks with `fn_task_create`; decision-only or investigation tasks can also set `noCommitsExpected` / `**No commits expected:** true` when no code changes are expected. +During triage/planning sessions, agents can call `fn_workflow_list` to discover available built-in and custom workflows and read their descriptions before routing work. They can call `fn_workflow_select` to select a workflow for the task being specified, or pass `workflow_id` when creating child tasks with `fn_task_create`; decision-only or investigation tasks can also set `noCommitsExpected` / `**No commits expected:** true` when no code changes are expected. The built-in triage thresholds, decision-only verb list, and default routing IDs are workflow-native typed settings resolved from the selected workflow. #### Runtime invariant criterion @@ -167,6 +167,14 @@ Notification delivery is intentionally best-effort: a missing/unconfigured notif Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. +#### Workflow-declared optional steps + +Workflows can advertise optional workflow-step templates with `optionalSteps: [{ templateId, defaultOn? }]`. This IR facet is execution-inert: the graph executor does not run or route on `optionalSteps` directly. Instead, the create and task-detail workflow UIs resolve each `templateId` against built-in and plugin-contributed `WorkflowStepTemplate` metadata, show toggleable rows, and persist the selected template ids through the existing per-task `enabledWorkflowSteps` contract. + +`defaultOn` is optional and seeds the UI toggle when a task is created or edited before the task has made its own selection. Unknown or removed template ids are skipped during resolution so stale declarations do not render blank controls or break workflow loading. + +The built-in coding workflow (`builtin:coding`) declares `browser-verification` as an optional step. It remains opt-in by default, so browser verification only runs for tasks whose `enabledWorkflowSteps` includes `browser-verification`. + ## What They Are A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks. @@ -187,7 +195,7 @@ Workflow steps run in one of two phases: - **Pre-merge** (default): runs before merge/finalization; failure blocks completion - **Post-merge**: runs after successful merge; failure is logged but non-blocking -> **Note on Fast Mode:** When a task has `executionMode: "fast"`, pre-merge workflow steps are bypassed entirely during executor completion. Post-merge workflow steps remain active and run normally (post-merge is merger-owned and unaffected by execution mode). +> **Note on Fast Mode:** When a task has `executionMode: "fast"`, pre-merge workflow steps are bypassed entirely during executor completion on both the legacy path and the workflow graph executor path. Custom graph pre-merge prompt/script/gate validation nodes are skipped as the graph equivalent of pre-merge workflow steps. Post-merge workflow steps remain active and run normally (post-merge is merger-owned and unaffected by execution mode). ## Execution Modes diff --git a/package.json b/package.json index a8203fd1b3..25abf887f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fusion-workspace", - "version": "0.41.0", + "version": "0.42.0", "private": true, "license": "MIT", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/cli-alias/CHANGELOG.md b/packages/cli-alias/CHANGELOG.md index cfbcb5dddf..da600b29c1 100644 --- a/packages/cli-alias/CHANGELOG.md +++ b/packages/cli-alias/CHANGELOG.md @@ -1,5 +1,66 @@ # runfusion.ai +## 0.42.0 + +### Patch Changes + +- Updated dependencies [8eb99ed] +- Updated dependencies [36f5ecd] +- Updated dependencies [1a716f2] +- Updated dependencies [e22afec] +- Updated dependencies [fb2c6e5] +- Updated dependencies [039d3ce] +- Updated dependencies [c0ff360] +- Updated dependencies [167f9b0] +- Updated dependencies [1c4ec5f] +- Updated dependencies [12621aa] +- Updated dependencies [30e747b] +- Updated dependencies [eb607c6] +- Updated dependencies [8c16395] +- Updated dependencies [d5b45c8] +- Updated dependencies [f0d2415] +- Updated dependencies [a83c2d8] +- Updated dependencies [cbc3157] +- Updated dependencies [cbc3157] +- Updated dependencies [e5036b1] +- Updated dependencies [535c40d] +- Updated dependencies [e35f3dd] +- Updated dependencies [3a729f5] +- Updated dependencies [c285f3f] +- Updated dependencies [f7f2cae] +- Updated dependencies [9a78814] +- Updated dependencies [2085610] +- Updated dependencies [d23c5d9] +- Updated dependencies [4fc00b6] +- Updated dependencies [65251d2] +- Updated dependencies [4e6df03] +- Updated dependencies [bffae81] +- Updated dependencies [0897b2a] +- Updated dependencies [ec4b247] +- Updated dependencies [7ffea9f] +- Updated dependencies [751d942] +- Updated dependencies [508551c] +- Updated dependencies [93237c3] +- Updated dependencies [bd87ce7] +- Updated dependencies [72661fa] +- Updated dependencies [480e55f] +- Updated dependencies [0a135c9] +- Updated dependencies [66591ec] +- Updated dependencies [a9b1139] +- Updated dependencies [f2054d0] +- Updated dependencies [34ada00] +- Updated dependencies [35554e6] +- Updated dependencies [e0ec3d1] +- Updated dependencies [f68775a] +- Updated dependencies [4ea9d66] +- Updated dependencies [44b756d] +- Updated dependencies [e6eef1a] +- Updated dependencies [07d5262] +- Updated dependencies [e305b1a] +- Updated dependencies [40cb0d3] +- Updated dependencies [f16b038] + - @runfusion/fusion@0.42.0 + ## 0.41.0 ### Patch Changes diff --git a/packages/cli-alias/package.json b/packages/cli-alias/package.json index a3543fb3d8..14d4b0536d 100644 --- a/packages/cli-alias/package.json +++ b/packages/cli-alias/package.json @@ -1,6 +1,6 @@ { "name": "runfusion.ai", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Launch Fusion with `npx runfusion.ai` — tiny alias for @runfusion/fusion.", "homepage": "https://runfusion.ai", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 5b6b1beb47..11b3627754 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,98 @@ # @runfusion/fusion +## 0.42.0 + +### Minor Changes + +- e22afec: Add workflow-native typed settings for triage/spec policy thresholds and routing defaults. The built-in defaults preserve current behavior: size bands remain S <2h, M 2-4h, L 4-8h; subtask signals use the canonical planning-prompt values of step threshold 7 and packages/modules threshold 3; file-scope/remediation thresholds remain 20 and 30. + + These triage policy settings are new workflow settings, not moved project settings, so they are excluded from the U4 `MOVED_SETTINGS_KEYS` tombstone while still resolving through workflow effective settings. + +- 039d3ce: Fast-mode triage is now expressed as workflow-declared policy: the lean prompt lives in the built-in `default-triage-fast` agent prompt and `planning-fast` seam, while `leanPlanning` and `autoApproveSpec` are workflow-native settings for prompt selection and spec-review auto-approval. + + The internal `FAST_TRIAGE_SYSTEM_PROMPT` engine constant was removed. Existing `executionMode: "fast"` tasks remain byte-equivalent through a single legacy execution-mode-to-resolved-policy bridge. + +- 167f9b0: Allow engineer-role agents to opt into no-task backlog auto-claim for implementation tasks while preserving executor-only default pickup behavior. +- 1c4ec5f: Add dashboard controls for the engineer backlog auto-claim opt-in at project scope and per-agent heartbeat settings. +- eb607c6: Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width. +- f7f2cae: Move Frontend UX criteria injection from AI self-instructions into deterministic engine-applied workflow policy, preserving the byte-equivalent checklist and idempotent insertion behavior. +- 4e6df03: Add a verified no-op/duplicate task completion path so executors can close already-satisfied tasks without fabricating commits by using an audited `fn_task_done` sentinel summary. +- 7ffea9f: Expose Google Generative AI as a selectable custom-provider API type in the dashboard settings UI and documentation. +- 508551c: Allow tasks to be archived from any live board column and restored to their pre-archive column. +- bd87ce7: Add workflow-declared optional steps and expose Browser Verification as the built-in coding workflow's opt-in optional step for task creation and editing. +- 72661fa: Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters. +- 07d5262: Sync workflow setting values across nodes in settings push, pull, receive, and status flows. + +### Patch Changes + +- 8eb99ed: Quick Entry no longer auto-focuses when the board or dashboard becomes visible. +- 36f5ecd: Skip custom workflow pre-merge prompt, script, and gate nodes when a task runs in fast execution mode. +- 1a716f2: Resolve the standard triage planning prompt from the selected workflow IR planning node instead of the removed engine-side `TRIAGE_SYSTEM_PROMPT` duplicate. The built-in `default-triage` prompt is now the canonical policy source for `builtin:coding`; where the old copies disagreed, the surviving canonical subtask-split threshold is `MORE THAN 7 implementation steps` (with the matching `MORE THAN 3 different packages/modules` guidance). Fast-mode triage continues to use `FAST_TRIAGE_SYSTEM_PROMPT` unchanged. +- fb2c6e5: Resolve the built-in reviewer base prompt from the workflow IR `review` node instead of an engine-local `REVIEWER_SYSTEM_PROMPT` duplicate. The canonical reviewer policy now lives in the `default-reviewer` agent prompt / built-in workflow seam, with reconciled superset content that preserves the FN-5928/FN-6229 surface-enumeration and symptom-verification gates, undersplit-task guidance, test-quality rules, worktree-boundary review, and the embedded port-4040 safety rule. +- c0ff360: Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control. +- 12621aa: Record explicit `builtin:coding` project-default workflow selections even when the compiled built-in has zero materialized steps, while preserving interpreter-deferred `builtin:stepwise-coding` fallback behavior. +- 30e747b: Standalone plugin scaffolds now declare the dev toolchain they generate scripts and config for: `@types/node`, `vitest`, and `typescript`. This lets projects created with `fn plugin new` install, build, test, and load through `fn plugin dev . --once` via the documented external-author path without relying on transitive or hoisted dependencies. + + Manual spot-check for release validation: + + ```sh + npx @runfusion/fusion@latest plugin new proof-point-plugin + cd proof-point-plugin + pnpm install + pnpm build + pnpm test + fn plugin dev . --once + ``` + +- 8c16395: Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run. +- d5b45c8: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. +- f0d2415: Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. + + The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty. + +- a83c2d8: Fix custom provider models not appearing in model dropdowns. The `/models` endpoint filtered results to providers configured in Fusion's auth stores, which excluded custom providers (stored in global settings). Their registry keys are now added to the allowlist so their models surface in pickers. +- cbc3157: Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll mutations were blurring the focused composer textarea: + + 1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus. + + 2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock. + + 3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op. + + 4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor. + +- cbc3157: Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected. +- e5036b1: Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap. +- 535c40d: Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again. +- e35f3dd: Classify harmless temporary merge worktree cleanup failures after `git worktree prune`/porcelain inspection while keeping still-registered worktree leaks visible in merger diagnostics. +- 3a729f5: Allow narrowly-scoped Review Level 1 coordination tasks with board-only file scope and explicit no-source intent to complete without commits while preserving the missing-commit guard for implementation tasks. +- c285f3f: Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution. +- 9a78814: Stop review entry from freezing the global auto-merge setting onto tasks. Tasks without an explicit per-task auto-merge override now continue to follow the live global setting, so toggling global auto-merge off stops newly-entered non-override in-review tasks from being auto-merge processed. +- 2085610: Move AI-merge clean-room worktrees into a repo-local cleanup-exempt root, guard cleanup sweeps by active merge ownership, and classify missing clean-room worktree failures as transient so merges can retry cleanly. +- d23c5d9: Fix task detail Pull Request and Review surfaces so they use the live project auto-merge setting instead of a stale modal-open snapshot. Create PR / manual merge affordances now appear immediately when auto-merge is toggled off, and the automatic auto-merge hint returns when it is toggled back on. +- 4fc00b6: Self-heal compound-engineering answer submission for restarted awaiting-input sessions by rehydrating the interactive session before sending the answer. +- 65251d2: Pausing or sleeping an agent no longer pauses its assigned tasks. Assigned tasks now keep their existing pause state so only explicit user actions pause ordinary task work. +- bffae81: Add `autoMergeProvenance` so Fusion can distinguish explicit per-task auto-merge overrides from legacy review-entry stamps. Startup now marks ambiguous legacy in-review `autoMerge: true` rows as `legacy-stamp` without changing behavior, and the operator-visible `reconcileLegacyAutoMergeStamps` action (dry-run by default) can clear those legacy stamps so global auto-merge OFF is respected while genuine user overrides are preserved. +- 0897b2a: Add a bounded persisted auto-retry for transient workflow-graph resume failures after engine restart or unpause, while preserving terminal failures for genuine graph errors. +- ec4b247: Re-fire durable-agent assignment wakes that were skipped because the agent was mid-heartbeat, so newly assigned tasks are worked when the active run completes instead of waiting for the next timer tick. +- 751d942: Fix workflow graph execution for the built-in coding workflow's merge-policy primitive region by collapsing any merge-region entry back to the legacy `merge` seam until the workflow interpreter owns merge policy execution. +- 93237c3: Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat. +- 480e55f: Fix non-English Active Agents next-heartbeat translations so localized strings interpolate the provided elapsed heartbeat value instead of showing a raw placeholder. +- 0a135c9: Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates. +- 66591ec: Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup. +- a9b1139: Self-healing now automatically re-dispatches an assigned in-progress task when its durable agent loses both the heartbeat run and active execution session, preventing the task from stranding until the next engine restart. +- f2054d0: Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow. +- 34ada00: Show user-sent task-detail Chat steering messages as You bubbles and keep them visible after steering requests persist. +- 35554e6: Keep the task-detail Chat composer pinned and visible while the transcript scrolls internally on mobile and desktop. +- e0ec3d1: Steering messages sent from task chat now reach active step-session and workflow runs, including parallel step sessions, and the misleading inactive-session "next session" composer copy was removed. +- f68775a: Ensure only explicit user actions unpause user-paused tasks. Engine self-healing, agent resume cascades, dashboard agent-state resume fallback, heartbeat recovery, and approval-decision resume no longer clear `userPaused` or auto-unpause tasks the user paused. +- 4ea9d66: Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults. +- 44b756d: Fix built-in branching workflow selection so interpreter-deferred coding workflows can be selected or used as project defaults without throwing during legacy step materialization. +- e6eef1a: Handle insight extraction agent responses deterministically by accepting prompt return text, falling back to session state, and surfacing a 503 error when no assistant text is produced. +- e305b1a: Respect per-task pause state during triage planning so paused tasks do not auto-advance after specification approval. +- 40cb0d3: Keep the dashboard usage dialog near the top of the viewport across desktop popover, modal, and mobile presentations. +- f16b038: Add workflow work-item storage primitives for workflow-owned merge migration. + ## 0.41.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 96d132ad9a..560b4de631 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@runfusion/fusion", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", @@ -51,6 +51,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run --silent=passed-only --reporter=dot", "test:ci-shape": "vitest run src/__tests__/ci-workflow.test.ts --silent=passed-only --reporter=dot", + "test:docs-index": "vitest run src/__tests__/docs-readme-index.test.ts --silent=passed-only --reporter=dot", "test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot", "test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot", "test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot", diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index a057ff3e75..34845be5d9 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -104,15 +104,15 @@ Request a refinement of a completed or in-review task. Creates a new follow-up t ### fn_task_archive -Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. +Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `id` | string | ✓ | Task ID to archive (e.g. FN-001). Must be in 'done' column. | +| `id` | string | ✓ | Task ID to archive from any live column (e.g. FN-001). | ### fn_task_unarchive -Unarchive an archived task (move from archived → done). Restores the task to the done column. +Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index f35f4cce5e..7430fc20b6 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -22,8 +22,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. | | `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. | | `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. | -| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. | -| `fn_task_unarchive` | Unarchive an archived task (move from archived → done). Restores the task to the done column. | +| `fn_task_archive` | Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. | +| `fn_task_unarchive` | Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. | | `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. | | `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. | | `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. | diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 0547525ce6..a5d9af16d9 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -47,6 +47,7 @@ const commandMocks = vi.hoisted(() => ({ runPrMerge: vi.fn(), runPrClose: vi.fn(), runPrAutomerge: vi.fn(), + runPrAutomergeCleanup: vi.fn(), runSettingsShow: vi.fn(), runSettingsSet: vi.fn(), @@ -194,6 +195,7 @@ vi.mock("../commands/pr.js", () => ({ runPrMerge: commandMocks.runPrMerge, runPrClose: commandMocks.runPrClose, runPrAutomerge: commandMocks.runPrAutomerge, + runPrAutomergeCleanup: commandMocks.runPrAutomergeCleanup, })); vi.mock("../commands/settings.js", () => ({ @@ -923,6 +925,14 @@ describe("bin command routing and fallbacks", () => { expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Try: fn pr create <task-id>")); }); + it("routes pr automerge-cleanup flags", async () => { + await runBin(["pr", "automerge-cleanup", "--apply", "--json", "--project", "ops"]); + expect(commandMocks.runPrAutomergeCleanup).toHaveBeenCalledWith( + { apply: true, json: true }, + "ops", + ); + }); + it("routes task delete with allow-resurrection flag", async () => { await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]); expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined); diff --git a/packages/cli/src/__tests__/docs-readme-index.test.ts b/packages/cli/src/__tests__/docs-readme-index.test.ts index a1ccdba7c3..e596db7a29 100644 --- a/packages/cli/src/__tests__/docs-readme-index.test.ts +++ b/packages/cli/src/__tests__/docs-readme-index.test.ts @@ -6,7 +6,6 @@ const workspaceRoot = resolve(import.meta.dirname, "../../../.."); const docsReadmePath = resolve(workspaceRoot, "docs", "README.md"); const requiredDocs = [ - "docs/beads-dolt-sync-evaluation.md", "docs/dev-server-modules.md", "docs/research/pi-autoresearch-analysis.md", "docs/research/research-hardening-preflight.md", diff --git a/packages/cli/src/__tests__/experiment-finalize.test.ts b/packages/cli/src/__tests__/experiment-finalize.test.ts index 836720c6af..8371b5ceac 100644 --- a/packages/cli/src/__tests__/experiment-finalize.test.ts +++ b/packages/cli/src/__tests__/experiment-finalize.test.ts @@ -3,6 +3,21 @@ import { writeFile, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const previewPlan = vi.fn(); const finalize = vi.fn(); const init = vi.fn(); @@ -18,12 +33,12 @@ const mockErrors = vi.hoisted(() => ({ })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn(() => ({ init, getExperimentSessionStore })), + TaskStore: makeConstructibleMock(() => ({ init, getExperimentSessionStore })), })); vi.mock("@fusion/engine", () => ({ defaultGitOps: vi.fn(() => ({})), - ExperimentFinalizeService: vi.fn(() => ({ previewPlan, finalize })), + ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan, finalize })), ExperimentFinalizeStateError: class extends Error { code = "state_error" as const; }, ExperimentFinalizeNoKeptRunsError: class extends Error { code = "no_kept_runs" as const; }, ExperimentFinalizePlanError: class extends Error { code = "plan_error" as const; }, diff --git a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts index 5def4116bc..6571678896 100644 --- a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts +++ b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const previewPlanMock = vi.hoisted(() => vi.fn()); const finalizeMock = vi.hoisted(() => vi.fn()); @@ -18,7 +33,7 @@ const mockErrors = vi.hoisted(() => ({ })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), getExperimentSessionStore: vi.fn(() => ({})), })), @@ -43,7 +58,7 @@ vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), fetchWebContent: vi.fn(), defaultGitOps: vi.fn(() => ({})), - ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })), + ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })), ExperimentFinalizeStateError: mockErrors.StateError, ExperimentFinalizeNoKeptRunsError: mockErrors.NoKeptError, ExperimentFinalizePlanError: mockErrors.PlanError, diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 3d34449432..8e318a504b 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -93,6 +93,26 @@ describe("CLI package.json publishing config", () => { expect(deps).toContain("ioredis"); }); + it("defines test:docs-index as a single-file docs README index lane", () => { + const script = pkg.scripts?.["test:docs-index"]; + const parts = script?.trim().split(/\s+/) ?? []; + const docsIndexPath = "src/__tests__/docs-readme-index.test.ts"; + + expect(script).toBeDefined(); + expect(script).toContain("vitest run"); + expect(parts).toEqual([ + "vitest", + "run", + docsIndexPath, + "--silent=passed-only", + "--reporter=dot", + ]); + expect(parts.filter((part) => part.endsWith(".test.ts"))).toEqual([docsIndexPath]); + expect(parts).not.toContain("--"); + expect(script).not.toMatch(/vitest\s+run\s+(?:--silent=passed-only\s+)?(?:--reporter=dot\s+)?$/); + expect(script).not.toContain("docs-readme-index "); + }); + it("prepack manifest rewrite strips workspace-only plugin/tooling devDependencies", () => { expect(prepackScript).toContain('delete devDependencies["@fusion/pi-claude-cli"]'); expect(prepackScript).toContain('delete devDependencies["@fusion/pi-llama-cpp"]'); @@ -276,17 +296,18 @@ describe("Workspace bootstrap script contract", () => { const defaultTest = dashboardPkg.scripts?.test; const defaultAppQuality = dashboardPkg.scripts?.["test:quality:app"]; const defaultApiQuality = dashboardPkg.scripts?.["test:quality:api"]; + const appSettings = dashboardPkg.scripts?.["test:quality:app:settings"]; const apiCurated = dashboardPkg.scripts?.["test:quality:api:curated"]; const deepTest = dashboardPkg.scripts?.["test:deep"]; - expect(defaultTest).toBe("pnpm run test:quality:app && pnpm run test:quality:api"); - expect(defaultAppQuality).toContain("test:quality:app:foundation-api"); - expect(defaultAppQuality).toContain("test:quality:app:settings"); - // The api lane chains curated + backfill sub-lanes; the curated sub-lane - // carries the explicit quality project, and the backfill lane is the - // curated-gate completeness net (broad glob minus curated minus skip-list). - expect(defaultApiQuality).toContain("test:quality:api:curated"); - expect(defaultApiQuality).toContain("test:quality:api:backfill"); + expect(defaultTest).toBe("node scripts/run-quality-tests.mjs"); + expect(defaultAppQuality).toBe("node scripts/run-quality-tests.mjs --group app"); + expect(defaultApiQuality).toBe("node scripts/run-quality-tests.mjs --group api"); + expect(defaultAppQuality).toContain("--group app"); + expect(appSettings).toContain("dashboard-app-quality-settings"); + // The default quality runner dispatches grouped quality lanes by script + // name; the curated API sub-lane still carries the explicit quality project. + expect(defaultApiQuality).toContain("--group api"); expect(hasProjectArg(apiCurated, "dashboard-api-quality")).toBe(true); expect(hasProjectArg(defaultTest, "dashboard-app")).toBe(false); expect(hasProjectArg(defaultTest, "dashboard-api")).toBe(false); diff --git a/packages/cli/src/__tests__/plugin-dev.test.ts b/packages/cli/src/__tests__/plugin-dev.test.ts index 4478110a17..54fafba270 100644 --- a/packages/cli/src/__tests__/plugin-dev.test.ts +++ b/packages/cli/src/__tests__/plugin-dev.test.ts @@ -3,6 +3,21 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const pluginCommandMocks = vi.hoisted(() => { const store = { registerPlugin: vi.fn(async () => ({ id: "fusion-plugin-dev-test", enabled: true })), @@ -16,8 +31,8 @@ const pluginCommandMocks = vi.hoisted(() => { return { store, loader, - createPluginStore: vi.fn(async () => store), - createPluginLoader: vi.fn(async () => ({ store, loader })), + createPluginStore: makeConstructibleMock(async () => store), + createPluginLoader: makeConstructibleMock(async () => ({ store, loader })), resolvePluginEntryFile: vi.fn(async (dir: string) => join(dir, "dist", "index.js")), loadManifestFromPath: vi.fn(async () => ({ manifest: { diff --git a/packages/cli/src/__tests__/plugin-scaffold.test.ts b/packages/cli/src/__tests__/plugin-scaffold.test.ts index e9ce66de41..3e16a3849e 100644 --- a/packages/cli/src/__tests__/plugin-scaffold.test.ts +++ b/packages/cli/src/__tests__/plugin-scaffold.test.ts @@ -8,6 +8,13 @@ import { runPluginCreate, runPluginNew } from "../commands/plugin-scaffold.js"; describe("plugin-scaffold", () => { const tmpBase = join(tmpdir(), `fn-scaffold-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const standaloneDevDependencyKeys = [ + "@runfusion/fusion", + "@types/node", + "typescript", + "vitest", + ]; + const caretRangePattern = /^\^\d+\.\d+\.\d+$/; beforeEach(() => { mkdirSync(tmpBase, { recursive: true }); @@ -64,6 +71,7 @@ describe("plugin-scaffold", () => { private?: boolean; keywords: string[]; exports: { ".": { types: string; import: string } }; + scripts: { build: string; test: string }; devDependencies: Record<string, string>; }; @@ -72,8 +80,10 @@ describe("plugin-scaffold", () => { expect(packageJson.private).toBeUndefined(); expect(packageJson.exports["."].types).toBe("./dist/index.d.ts"); expect(packageJson.exports["."].import).toBe("./dist/index.js"); - expect(Object.keys(packageJson.devDependencies)).toEqual(["@runfusion/fusion"]); - expect(packageJson.devDependencies["@runfusion/fusion"]).toMatch(/^\^\d+\.\d+\.\d+$/); + expect(Object.keys(packageJson.devDependencies)).toEqual(standaloneDevDependencyKeys); + for (const dependencyName of standaloneDevDependencyKeys) { + expect(packageJson.devDependencies[dependencyName]).toMatch(caretRangePattern); + } const packageContents = readFileSync(join(outputDir, "package.json"), "utf-8"); const indexContents = readFileSync(join(outputDir, "src/index.ts"), "utf-8"); @@ -87,8 +97,16 @@ describe("plugin-scaffold", () => { const tsconfig = JSON.parse(readFileSync(join(outputDir, "tsconfig.json"), "utf-8")) as { extends?: string; + compilerOptions: { types?: string[] }; }; expect(tsconfig.extends).toBeUndefined(); + for (const typeName of tsconfig.compilerOptions.types ?? []) { + expect(packageJson.devDependencies[`@types/${typeName}`]).toBeDefined(); + } + expect(packageJson.scripts.test.split(/\s+/)[0]).toBe("vitest"); + expect(packageJson.devDependencies.vitest).toBeDefined(); + expect(packageJson.scripts.build.split(/\s+/)[0]).toBe("tsc"); + expect(packageJson.devDependencies.typescript).toBeDefined(); }); it("supports scoped package names", async () => { @@ -96,8 +114,10 @@ describe("plugin-scaffold", () => { await runPluginNew("scoped-plugin", { output: outputDir, scope: "acme" }); const packageJson = JSON.parse(readFileSync(join(outputDir, "package.json"), "utf-8")) as { name: string; + devDependencies: Record<string, string>; }; expect(packageJson.name).toBe("@acme/fusion-plugin-scoped-plugin"); + expect(Object.keys(packageJson.devDependencies)).toEqual(standaloneDevDependencyKeys); }); it("rejects invalid plugin names", async () => { diff --git a/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts b/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts new file mode 100644 index 0000000000..e0277908f4 --- /dev/null +++ b/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../project-context.js", () => ({ + resolveProject: vi.fn(), +})); + +vi.mock("@fusion/engine", () => ({ + releaseHeldTaskByEvent: vi.fn(), +})); + +vi.mock("@fusion/dashboard", () => ({ + GitHubClient: class {}, + generatePrMetadata: vi.fn(), +})); + +vi.mock("@fusion/core/gh-cli", () => ({ + classifyGhError: vi.fn(() => ({ message: "err" })), + getGhErrorMessage: vi.fn(() => "err"), + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + isGhAuthenticated: vi.fn(() => true), + isGhAvailable: vi.fn(() => true), +})); + +const { resolveProject } = await import("../project-context.js"); +const { runPrAutomergeCleanup } = await import("../commands/pr.js"); + +function mockStore(results: Array<{ taskId: string; column: string; cleared: boolean }>) { + const reconcileLegacyAutoMergeStamps = vi.fn().mockResolvedValue(results); + vi.mocked(resolveProject).mockResolvedValue({ + store: { reconcileLegacyAutoMergeStamps } as never, + projectPath: "/tmp/project", + projectName: "proj", + } as never); + return { reconcileLegacyAutoMergeStamps }; +} + +describe("fn pr automerge-cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("dry-runs by default and lists store-provided candidates", async () => { + const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]); + + await runPrAutomergeCleanup(); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("candidate")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("FN-101")); + }); + + it("passes apply only when --apply is requested", async () => { + const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: true }]); + + await runPrAutomergeCleanup({ apply: true }); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Cleared 1 legacy auto-merge stamp")); + }); + + it("prints well-formed JSON for non-empty dry-run results", async () => { + mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]); + + await runPrAutomergeCleanup({ json: true }); + + const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as { + mode: string; + count: number; + candidates: Array<{ taskId: string; column: string; cleared: boolean }>; + }; + expect(payload).toEqual({ + mode: "dry-run", + count: 1, + candidates: [{ taskId: "FN-101", column: "in-review", cleared: false }], + }); + }); + + it("prints well-formed JSON for empty apply results", async () => { + const store = mockStore([]); + + await runPrAutomergeCleanup({ apply: true, json: true }); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as { + mode: string; + count: number; + cleared: unknown[]; + }; + expect(payload).toEqual({ mode: "apply", count: 0, cleared: [] }); + }); + + it("zero candidates is a successful no-op message", async () => { + const store = mockStore([]); + + await runPrAutomergeCleanup(); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("No legacy auto-merge stamps to clean up")); + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/project-resolver.test.ts b/packages/cli/src/__tests__/project-resolver.test.ts index e9ade98c36..4240c2e6bc 100644 --- a/packages/cli/src/__tests__/project-resolver.test.ts +++ b/packages/cli/src/__tests__/project-resolver.test.ts @@ -2,6 +2,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { existsSync, statSync } from "node:fs"; import { TaskStore } from "@fusion/core"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({ mockIsValidSqliteDatabaseFile: vi.fn(), })); @@ -41,7 +56,7 @@ vi.mock("@fusion/core", async () => { }, isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) => mockIsValidSqliteDatabaseFile(...args), - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), listTasks: vi.fn().mockResolvedValue([]), })), diff --git a/packages/cli/src/__tests__/task-plan.test.ts b/packages/cli/src/__tests__/task-plan.test.ts index a8ae3ebd3b..1a6f224bb9 100644 --- a/packages/cli/src/__tests__/task-plan.test.ts +++ b/packages/cli/src/__tests__/task-plan.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + // Mock node:readline/promises before importing vi.mock("node:readline/promises", () => ({ createInterface: vi.fn(), @@ -8,7 +23,7 @@ vi.mock("node:readline/promises", () => ({ // Mock @fusion/core before importing vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal<typeof import("@fusion/core")>()), - TaskStore: vi.fn(), + TaskStore: makeConstructibleMock(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { triage: "Triage", diff --git a/packages/cli/src/__tests__/task-steer.test.ts b/packages/cli/src/__tests__/task-steer.test.ts index bec5070b4d..4bc8becb4c 100644 --- a/packages/cli/src/__tests__/task-steer.test.ts +++ b/packages/cli/src/__tests__/task-steer.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + // Mock node:readline/promises before importing vi.mock("node:readline/promises", () => ({ createInterface: vi.fn(), @@ -8,7 +23,7 @@ vi.mock("node:readline/promises", () => ({ // Mock @fusion/core before importing vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal<typeof import("@fusion/core")>()), - TaskStore: vi.fn(), + TaskStore: makeConstructibleMock(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { triage: "Triage", diff --git a/packages/cli/src/__tests__/update-cache.test.ts b/packages/cli/src/__tests__/update-cache.test.ts index a3b3f7747c..802b38ddf3 100644 --- a/packages/cli/src/__tests__/update-cache.test.ts +++ b/packages/cli/src/__tests__/update-cache.test.ts @@ -2,6 +2,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { readFileSync } from "node:fs"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const CLI_PACKAGE_VERSION = ( JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as { version: string } ).version; @@ -16,7 +31,7 @@ const { cacheDir, mockResolveGlobalDir } = vi.hoisted(() => { vi.mock("@fusion/core", () => ({ resolveGlobalDir: mockResolveGlobalDir, - GlobalSettingsStore: vi.fn(), + GlobalSettingsStore: makeConstructibleMock(), })); const { getCachedUpdateStatus } = await import("../update-cache.js"); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a3b285e933..024bc1977a 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -120,7 +120,7 @@ async function loadCommandHandlers() { const { runDaemon } = await import("./commands/daemon.js"); const { runDesktop } = await import("./commands/desktop.js"); const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js"); - const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge } = await import("./commands/pr.js"); + const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge, runPrAutomergeCleanup } = await import("./commands/pr.js"); const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); @@ -186,6 +186,7 @@ async function loadCommandHandlers() { runPrMerge, runPrClose, runPrAutomerge, + runPrAutomergeCleanup, runSettingsShow, runSettingsSet, runSettingsExport, @@ -305,7 +306,7 @@ Usage: fn task merge <id> Merge an in-review task and close it fn task duplicate <id> Duplicate a task (creates copy in triage) fn task refine <id> [opts] Create a refinement task from done/in-review - fn task archive <id> Archive a done task + fn task archive <id> Archive a task (from any column) fn task unarchive <id> Unarchive an archived task fn task delete <id> [--force] [--allow-resurrection] Delete a task (use --force to skip confirmation; --allow-resurrection permits intentional ID recreation) @@ -331,6 +332,8 @@ PR: fn pr merge <pr-id> Force-merge the PR via its merge release fn pr close <pr-id> Close the PR terminally fn pr automerge <pr-id> [on|off] Toggle auto-merge for the PR + fn pr automerge-cleanup [--apply] [--json] + Dry-run or apply legacy auto-merge stamp cleanup fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json] Create and optionally wait for a cited-research run (search/fetch/synthesis) fn research list | ls [--status <status>] [--limit <n>] [--json] @@ -667,6 +670,7 @@ async function main() { runPrMerge, runPrClose, runPrAutomerge, + runPrAutomergeCleanup, runSettingsShow, runSettingsSet, runSettingsExport, @@ -901,9 +905,15 @@ async function main() { await runPrAutomerge(args[2], enabled, projectName); break; } + case "automerge-cleanup": + await runPrAutomergeCleanup({ + apply: args.includes("--apply"), + json: args.includes("--json"), + }, projectName); + break; default: console.error(`Unknown subcommand: pr ${subcommand || ""}`); - console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off]"); + console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off] | automerge-cleanup [--apply] [--json]"); process.exit(1); } break; diff --git a/packages/cli/src/commands/__tests__/agent.test.ts b/packages/cli/src/commands/__tests__/agent.test.ts index 735c52ef93..df2793908f 100644 --- a/packages/cli/src/commands/__tests__/agent.test.ts +++ b/packages/cli/src/commands/__tests__/agent.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + // ── Mock AgentStore ────────────────────────────────────────────────── const mockGetAgent = vi.fn(); @@ -9,7 +24,7 @@ const mockInit = vi.fn().mockResolvedValue(undefined); // AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest. // We return a plain object from the constructor which becomes the instance. vi.mock("@fusion/core", () => ({ - AgentStore: vi.fn().mockImplementation(() => ({ + AgentStore: makeConstructibleMock(() => ({ init: mockInit, getAgent: mockGetAgent, updateAgentState: mockUpdateAgentState, diff --git a/packages/cli/src/commands/__tests__/backup.test.ts b/packages/cli/src/commands/__tests__/backup.test.ts index ceca9eed1c..acfe564b3b 100644 --- a/packages/cli/src/commands/__tests__/backup.test.ts +++ b/packages/cli/src/commands/__tests__/backup.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const { mockListBackups, mockListBackupPairs, @@ -20,7 +35,7 @@ const { vi.mock("@fusion/core", () => ({ BackupManager: vi.fn(), - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), getSettings: mockGetSettings, fusionDir: "/cwd/.fusion", diff --git a/packages/cli/src/commands/__tests__/db.test.ts b/packages/cli/src/commands/__tests__/db.test.ts index b209e3f5ec..61f3368971 100644 --- a/packages/cli/src/commands/__tests__/db.test.ts +++ b/packages/cli/src/commands/__tests__/db.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + // Hoist mocks so they are evaluated before module imports const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({ mockGetDatabase: vi.fn(), @@ -8,7 +23,7 @@ const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({ })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: vi.fn(), getDatabase: mockGetDatabase, })), diff --git a/packages/cli/src/commands/__tests__/desktop.test.ts b/packages/cli/src/commands/__tests__/desktop.test.ts index f6cb0a18b0..6e662d41c9 100644 --- a/packages/cli/src/commands/__tests__/desktop.test.ts +++ b/packages/cli/src/commands/__tests__/desktop.test.ts @@ -122,7 +122,9 @@ const mocks = vi.hoisted(() => { server, app, spawn, - taskStoreCtor: vi.fn(() => store), + taskStoreCtor: vi.fn(function () { + return store; + }), createServer: vi.fn(() => app), }; }); diff --git a/packages/cli/src/commands/__tests__/init.test.ts b/packages/cli/src/commands/__tests__/init.test.ts index a6cbc68477..1313ed238a 100644 --- a/packages/cli/src/commands/__tests__/init.test.ts +++ b/packages/cli/src/commands/__tests__/init.test.ts @@ -11,6 +11,21 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { GitRepositoryInitializationError } from "@fusion/core"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const execAsync = promisify(exec); const mockCentralInit = vi.fn(); @@ -27,7 +42,7 @@ vi.mock("@fusion/core", async () => { const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core"); return { ...actual, - CentralCore: vi.fn().mockImplementation(() => ({ + CentralCore: makeConstructibleMock(() => ({ init: mockCentralInit, close: mockCentralClose, getProjectByPath: mockGetProjectByPath, diff --git a/packages/cli/src/commands/__tests__/memory-backup.test.ts b/packages/cli/src/commands/__tests__/memory-backup.test.ts index 8813a33c16..b174371c6f 100644 --- a/packages/cli/src/commands/__tests__/memory-backup.test.ts +++ b/packages/cli/src/commands/__tests__/memory-backup.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const { mockListBackups, mockRestoreBackup, @@ -15,7 +30,7 @@ const { })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), getSettings: mockGetSettings, fusionDir: "/cwd/.fusion", diff --git a/packages/cli/src/commands/__tests__/message.test.ts b/packages/cli/src/commands/__tests__/message.test.ts index 1ebc147915..74bb36914c 100644 --- a/packages/cli/src/commands/__tests__/message.test.ts +++ b/packages/cli/src/commands/__tests__/message.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + // ── Mock MessageStore ──────────────────────────────────────────────── const mockGetInbox = vi.fn(); @@ -17,7 +32,7 @@ vi.mock("@fusion/core", () => { }; return { createDatabase: vi.fn().mockReturnValue(mockDb), - MessageStore: vi.fn().mockImplementation(() => ({ + MessageStore: makeConstructibleMock(() => ({ getInbox: mockGetInbox, getOutbox: mockGetOutbox, getMailbox: mockGetMailbox, diff --git a/packages/cli/src/commands/__tests__/node.test.ts b/packages/cli/src/commands/__tests__/node.test.ts index 9fc22510d8..2b4800b9db 100644 --- a/packages/cli/src/commands/__tests__/node.test.ts +++ b/packages/cli/src/commands/__tests__/node.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mockInit = vi.fn().mockResolvedValue(undefined); const mockClose = vi.fn().mockResolvedValue(undefined); const mockListNodes = vi.fn(); @@ -13,7 +28,7 @@ const mockQuestion = vi.fn(); const mockRlClose = vi.fn(); vi.mock("@fusion/core", () => ({ - CentralCore: vi.fn().mockImplementation(() => ({ + CentralCore: makeConstructibleMock(() => ({ init: mockInit, close: mockClose, listNodes: mockListNodes, diff --git a/packages/cli/src/commands/__tests__/plugin.test.ts b/packages/cli/src/commands/__tests__/plugin.test.ts index 3a0b8e771f..d70109d9f1 100644 --- a/packages/cli/src/commands/__tests__/plugin.test.ts +++ b/packages/cli/src/commands/__tests__/plugin.test.ts @@ -3,6 +3,21 @@ import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mocks = vi.hoisted(() => { const pluginStoreInstances: Array<{ init: ReturnType<typeof vi.fn>; @@ -15,9 +30,9 @@ const mocks = vi.hoisted(() => { let loaderTaskStore: { getRootDir?: () => string } | undefined; let loaderRootDir: string | undefined; - const PluginStore = vi.fn(); + const PluginStore = makeConstructibleMock(); - const PluginLoader = vi.fn(); + const PluginLoader = makeConstructibleMock(); const setupDefaults = () => { PluginStore.mockImplementation(() => { diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 5fc892ef39..7d813a7d10 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -3,6 +3,21 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mockListProjects = vi.fn(); const mockRegisterProject = vi.fn(); const mockEnsureProjectForPath = vi.fn(async (...args: unknown[]) => ({ @@ -29,7 +44,7 @@ const mockEnsureMemoryFileWithBackend = vi.fn(); // Mock @fusion/core vi.mock("@fusion/core", () => ({ - CentralCore: vi.fn().mockImplementation(() => ({ + CentralCore: makeConstructibleMock(() => ({ init: mockInit.mockResolvedValue(undefined), close: mockClose.mockResolvedValue(undefined), listProjects: mockListProjects, @@ -41,11 +56,11 @@ vi.mock("@fusion/core", () => ({ getProjectByPath: mockGetProjectByPath, getProjectHealth: mockGetProjectHealth, })), - GlobalSettingsStore: vi.fn().mockImplementation(() => ({ + GlobalSettingsStore: makeConstructibleMock(() => ({ init: mockGlobalInit.mockResolvedValue(undefined), getSettings: mockGetSettings, })), - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: mockTaskStoreInit, listTasks: mockTaskStoreListTasks, })), diff --git a/packages/cli/src/commands/__tests__/provider-settings.test.ts b/packages/cli/src/commands/__tests__/provider-settings.test.ts index e8c2c0e54c..1b9c17d109 100644 --- a/packages/cli/src/commands/__tests__/provider-settings.test.ts +++ b/packages/cli/src/commands/__tests__/provider-settings.test.ts @@ -46,6 +46,22 @@ describe("createReadOnlyProviderSettingsView", () => { shared: "fusion", }); expect(view.getNpmCommand()).toEqual(["pnpm"]); + expect(view.isProjectTrusted()).toBe(true); + }); + + it("exposes project trust for pi package-manager discovery consumers", async () => { + const root = tempWorkspace("fusion-provider-settings-"); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + mkdirSync(agentDir, { recursive: true }); + + const view = createReadOnlyProviderSettingsView(cwd, agentDir); + const discoveryConsumer = { + resolve: vi.fn(async () => view.isProjectTrusted()), + }; + + await expect(discoveryConsumer.resolve()).resolves.toBe(true); + expect(typeof view.isProjectTrusted()).toBe("boolean"); }); it("returns empty project settings when .fusion/settings.json does not exist", () => { diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 994c732186..d1c7a9bf7d 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -4,6 +4,21 @@ import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({ mockSyncStartupModels: vi.fn().mockResolvedValue(undefined), mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), @@ -590,7 +605,7 @@ vi.mock("@fusion/core", async (importOriginal) => { storeToken: vi.fn().mockResolvedValue(undefined), }; }), - GlobalSettingsStore: vi.fn().mockImplementation(function () { + GlobalSettingsStore: makeConstructibleMock(function () { return {}; }), resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"), diff --git a/packages/cli/src/commands/__tests__/settings-export.test.ts b/packages/cli/src/commands/__tests__/settings-export.test.ts index 626131f94e..56ec19ed0f 100644 --- a/packages/cli/src/commands/__tests__/settings-export.test.ts +++ b/packages/cli/src/commands/__tests__/settings-export.test.ts @@ -4,6 +4,21 @@ import { join, resolve } from "node:path"; import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core"; import { resolveProject } from "../../project-context.js"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mockStoreInit = vi.fn().mockResolvedValue(undefined); vi.mock("node:fs/promises", () => ({ @@ -11,7 +26,7 @@ vi.mock("node:fs/promises", () => ({ })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: mockStoreInit, })), exportSettings: vi.fn(), diff --git a/packages/cli/src/commands/__tests__/settings-import.test.ts b/packages/cli/src/commands/__tests__/settings-import.test.ts index 1dc563b4d3..5b33dff5be 100644 --- a/packages/cli/src/commands/__tests__/settings-import.test.ts +++ b/packages/cli/src/commands/__tests__/settings-import.test.ts @@ -3,6 +3,21 @@ import { existsSync } from "node:fs"; import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core"; import { resolveProject } from "../../project-context.js"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mockStoreInit = vi.fn().mockResolvedValue(undefined); vi.mock("node:fs", () => ({ @@ -10,7 +25,7 @@ vi.mock("node:fs", () => ({ })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => ({ init: mockStoreInit, })), importSettings: vi.fn(), diff --git a/packages/cli/src/commands/__tests__/settings.test.ts b/packages/cli/src/commands/__tests__/settings.test.ts index de9772e093..364a10aa58 100644 --- a/packages/cli/src/commands/__tests__/settings.test.ts +++ b/packages/cli/src/commands/__tests__/settings.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + vi.mock("@fusion/core", () => { const DEFAULT_SETTINGS = { maxConcurrent: 2, @@ -23,7 +38,7 @@ vi.mock("@fusion/core", () => { }; return { - GlobalSettingsStore: vi.fn(), + GlobalSettingsStore: makeConstructibleMock(), DEFAULT_SETTINGS, SUPPORTED_LOCALES: ["en", "zh-CN", "zh-TW", "fr", "es", "ko"], resolveWorktrunkSettings: (globalValue: any, projectValue: any) => ({ diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index 0363711814..6b4e569ef7 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -2460,6 +2460,7 @@ describe("runTaskRetry", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, completionHandoffLimboRecoveryCount: 0, + graphResumeRetryCount: 0, mergeAuditBounceCount: 0, mergeRetries: 0, resumeLimboCount: 0, @@ -2534,6 +2535,7 @@ describe("runTaskRetry", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, completionHandoffLimboRecoveryCount: 0, + graphResumeRetryCount: 0, mergeAuditBounceCount: 0, mergeRetries: 0, resumeLimboCount: 0, diff --git a/packages/cli/src/commands/plugin-scaffold.ts b/packages/cli/src/commands/plugin-scaffold.ts index bbd2210fdd..3d102f9c84 100644 --- a/packages/cli/src/commands/plugin-scaffold.ts +++ b/packages/cli/src/commands/plugin-scaffold.ts @@ -12,6 +12,9 @@ import { fileURLToPath } from "node:url"; // Valid plugin name pattern: kebab-case const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const DEFAULT_RUNFUSION_VERSION = "0.39.0"; +const SCAFFOLD_TYPES_NODE_VERSION = "^22.0.0"; +const SCAFFOLD_VITEST_VERSION = "^4.1.0"; +const SCAFFOLD_TYPESCRIPT_VERSION = "^5.7.0"; /** * Convert a kebab-case string to Title Case @@ -167,6 +170,9 @@ function generateStandalonePackageJson(name: string, scope?: string): string { }, devDependencies: { "@runfusion/fusion": resolveFusionCaretVersion(), + "@types/node": SCAFFOLD_TYPES_NODE_VERSION, + typescript: SCAFFOLD_TYPESCRIPT_VERSION, + vitest: SCAFFOLD_VITEST_VERSION, }, }, null, diff --git a/packages/cli/src/commands/pr.ts b/packages/cli/src/commands/pr.ts index f7c8aefa1f..e15be8cf37 100644 --- a/packages/cli/src/commands/pr.ts +++ b/packages/cli/src/commands/pr.ts @@ -374,3 +374,44 @@ export async function runPrAutomerge(id: string, enabled: boolean | undefined, p const updated = store.updatePrEntity(id, { autoMerge: next }); console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`); } + +export interface PrAutomergeCleanupOptions { + apply?: boolean; + json?: boolean; +} + +export async function runPrAutomergeCleanup(options: PrAutomergeCleanupOptions = {}, projectName?: string) { + const { store } = await getPrContext(projectName); + const results = options.apply + ? await store.reconcileLegacyAutoMergeStamps({ apply: true }) + : await store.reconcileLegacyAutoMergeStamps(); + + if (options.json) { + console.log(JSON.stringify({ + mode: options.apply ? "apply" : "dry-run", + count: results.length, + candidates: options.apply ? undefined : results, + cleared: options.apply ? results : undefined, + }, null, 2)); + return; + } + + if (results.length === 0) { + console.log("\n ✓ No legacy auto-merge stamps to clean up.\n"); + return; + } + + if (options.apply) { + console.log(`\n ✓ Cleared ${results.length} legacy auto-merge stamp${results.length === 1 ? "" : "s"}:`); + } else { + console.log(`\n Legacy auto-merge stamp candidate${results.length === 1 ? "" : "s"} (${results.length}):`); + } + for (const result of results) { + console.log(` - ${result.taskId} (${result.column})`); + } + if (!options.apply) { + console.log("\n Re-run with --apply to clear these legacy non-override stamps. Genuine per-task overrides are preserved.\n"); + } else { + console.log(""); + } +} diff --git a/packages/cli/src/commands/provider-settings.ts b/packages/cli/src/commands/provider-settings.ts index ab8e10b1c8..b3650a2ecd 100644 --- a/packages/cli/src/commands/provider-settings.ts +++ b/packages/cli/src/commands/provider-settings.ts @@ -5,6 +5,7 @@ export interface PackageManagerSettingsView { getGlobalSettings(): Record<string, unknown>; getProjectSettings(): Record<string, unknown>; getNpmCommand(): string[] | undefined; + isProjectTrusted(): boolean; } function siblingAgentDir(agentDir: string, siblingRoot: ".fusion" | ".pi"): string | undefined { @@ -47,6 +48,10 @@ export function createReadOnlyProviderSettingsView(cwd: string, agentDir: string getNpmCommand: () => Array.isArray(mergedSettings.npmCommand) ? [...mergedSettings.npmCommand] : undefined, + // Pi's SettingsManager defaults projects to trusted. Fusion workspaces are + // user-owned, so preserve pre-upgrade behavior and keep project-scoped + // .fusion resources loadable through the read-only settings view. + isProjectTrusted: () => true, }; } diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 8d1039819c..1dc927202e 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1207,16 +1207,16 @@ export default function kbExtension(pi: ExtensionAPI) { name: "fn_task_archive", label: "fn: Archive Task", description: - "Archive a done task (move from done → archived). " + + "Archive a task from any live column (move to archived). " + "Archived tasks are preserved for historical reference but moved out of the main board view.", - promptSnippet: "Archive a done Fusion task (moves to archived column)", + promptSnippet: "Archive a Fusion task from any live column (moves to archived column)", promptGuidelines: [ - "Use to clean up old completed tasks from the done column", - "Only tasks in the 'done' column can be archived", + "Use to clean up tasks from any live board column when you want them hidden from active views", + "Already archived tasks cannot be archived again", "Archived tasks can be unarchived later if needed", ], parameters: Type.Object({ - id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }), + id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -1236,11 +1236,11 @@ export default function kbExtension(pi: ExtensionAPI) { name: "fn_task_unarchive", label: "fn: Unarchive Task", description: - "Unarchive an archived task (move from archived → done). " + - "Restores the task to the done column.", - promptSnippet: "Unarchive a Fusion task (restores to done column)", + "Unarchive an archived task (move from archived → its restore column). " + + "Restores to the pre-archive column when available, with active execution columns downgraded to todo.", + promptSnippet: "Unarchive a Fusion task (restores to its pre-archive column)", promptGuidelines: [ - "Use to restore an archived task back to the done column", + "Use to restore an archived task back to its pre-archive column when available", "Only tasks in the 'archived' column can be unarchived", ], parameters: Type.Object({ diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 84812dd41d..5011826c91 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/core +## 0.42.0 + ## 0.41.0 ## 0.40.1 diff --git a/packages/core/package.json b/packages/core/package.json index f681b4ff96..b7ec778846 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/core", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/core/src/__test-utils__/vitest-setup.ts b/packages/core/src/__test-utils__/vitest-setup.ts index 0ad97a4494..6aa4bb6c29 100644 --- a/packages/core/src/__test-utils__/vitest-setup.ts +++ b/packages/core/src/__test-utils__/vitest-setup.ts @@ -15,7 +15,7 @@ import { afterEach, expect } from "vitest"; import { createRequire, syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { isMainThread } from "node:worker_threads"; import { assertOutsideRealFusionPath } from "../test-safety.js"; @@ -40,7 +40,17 @@ const requireFromHere = createRequire(import.meta.url); const fs = requireFromHere("node:fs") as FsModule; const fsPromises = requireFromHere("node:fs/promises") as FsPromisesModule; const childProcess = requireFromHere("node:child_process") as ChildProcessModule; -const { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } = fs; +const { + appendFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + realpathSync, + existsSync, + writeFileSync, +} = fs; type EmitWarningArgs = Parameters<typeof process.emitWarning>; type EmitWarningRestArgs = EmitWarningArgs extends [string | Error, ...infer Rest] ? Rest : never; @@ -154,11 +164,165 @@ if (!process.env.FUSION_MASTER_KEY_DISABLE_KEYCHAIN) { process.env.FUSION_MASTER_KEY_DISABLE_KEYCHAIN = "1"; } -// Shared parent directory for all worker temp dirs in this run. -// globalTeardown wipes this at the end of the suite. -const WORKER_ROOT = join(tmpdir(), "fusion-test-workers"); -try { mkdirSync(WORKER_ROOT, { recursive: true }); } catch { /* ignore */ } -process.env.FUSION_TEST_WORKER_ROOT = WORKER_ROOT; +// Shared parent directory for all worker temp dirs in this Vitest invocation. +// Keep this per-run (globalSetup seeds FUSION_TEST_WORKER_ROOT) instead of a +// single long-lived tmpdir/fusion-test-workers directory: redirect setup does a +// bounded one-level sweep of WORKER_ROOT, and a static root can accumulate enough +// stale worker/home dirs after interrupted runs to make every mkdtempSync call +// take seconds. +const WORKER_ROOT = (() => { + const fromEnv = process.env.FUSION_TEST_WORKER_ROOT; + const root = fromEnv && fromEnv.trim().length > 0 + ? resolve(fromEnv) + : realpathSync(mkdtempSync(join(tmpdir(), "fusion-test-workers-"))); + try { mkdirSync(root, { recursive: true }); } catch { /* ignore */ } + process.env.FUSION_TEST_WORKER_ROOT = root; + return root; +})(); + +const REAL_TMPDIR = (() => { + try { + return realpathSync(tmpdir()); + } catch { + return resolve(tmpdir()); + } +})(); + +const TMPDIR_REDIRECT_REGISTRY = join(WORKER_ROOT, ".redir-pids"); +let tmpdirRedirectSink: string | null = null; +let tmpdirRedirectExitCleanupInstalled = false; +let tmpdirRedirectSweepComplete = false; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "EPERM"; + } +} + +function removeTmpdirRedirectSinkForPid(ownerPid: number): void { + if (ownerPid === process.pid || isProcessAlive(ownerPid)) return; + + try { + rmSync(join(WORKER_ROOT, `redir-${ownerPid}`), { recursive: true, force: true }); + } catch { + // Ignore stale-sink cleanup failures; global teardown still owns WORKER_ROOT. + } +} + +function sweepDeadTmpdirRedirectSinks(): void { + if (tmpdirRedirectSweepComplete) return; + tmpdirRedirectSweepComplete = true; + + // Registry-backed cleanup avoids scanning the OS temp root while still + // reclaiming redirect sinks from fork-pool workers that were hard-killed. + let ownerPids: number[] = []; + try { + ownerPids = Array.from(new Set( + readFileSync(TMPDIR_REDIRECT_REGISTRY, "utf8") + .split(/\r?\n/) + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid) && pid > 0), + )); + } catch { + // The registry may not exist yet. The bounded WORKER_ROOT sweep below still + // catches legacy redirect dirs created before the registry was introduced. + } + + const liveOwnerPids: number[] = []; + for (const ownerPid of ownerPids) { + if (ownerPid === process.pid || isProcessAlive(ownerPid)) { + liveOwnerPids.push(ownerPid); + continue; + } + + removeTmpdirRedirectSinkForPid(ownerPid); + } + + // Preserve the local self-healing behavior for redirect dirs that predate the + // registry or whose registry append was skipped. This is a single-level scan + // of WORKER_ROOT (not the OS temp root) and only touches dead pid-owned dirs. + try { + for (const entry of readdirSync(WORKER_ROOT)) { + const match = /^redir-(\d+)$/.exec(entry); + if (!match) continue; + const ownerPid = Number.parseInt(match[1], 10); + if (ownerPid === process.pid || liveOwnerPids.includes(ownerPid) || isProcessAlive(ownerPid)) { + continue; + } + removeTmpdirRedirectSinkForPid(ownerPid); + } + } catch { + // Best-effort only; stale entries are harmless and swept by future workers. + } + + try { + writeFileSync(TMPDIR_REDIRECT_REGISTRY, liveOwnerPids.length > 0 ? `${liveOwnerPids.join("\n")}\n` : ""); + } catch { + // Best-effort only; stale entries are harmless and swept by future workers. + } +} + +export const __fusionTmpdirRedirectTestHooks = { + workerRoot: WORKER_ROOT, + registryPath: TMPDIR_REDIRECT_REGISTRY, + sinkForPid(pid: number): string { + return join(WORKER_ROOT, `redir-${pid}`); + }, + resetSweepForTest(): void { + tmpdirRedirectSweepComplete = false; + }, + sweepDeadTmpdirRedirectSinks, +}; + +function ensureTmpdirRedirectSink(): string { + if (tmpdirRedirectSink) { + // FN-6310: recovery-timeout cleanup can remove a live worker's cached + // redirect sink; recreate it on demand so later mkdtemp calls don't ENOENT. + mkdirSync(tmpdirRedirectSink, { recursive: true }); + return tmpdirRedirectSink; + } + + sweepDeadTmpdirRedirectSinks(); + const sink = join(WORKER_ROOT, `redir-${process.pid}`); + mkdirSync(sink, { recursive: true }); + try { + appendFileSync(TMPDIR_REDIRECT_REGISTRY, `${process.pid}\n`); + } catch { + // Best-effort only; the process exit hook and global teardown still clean up. + } + tmpdirRedirectSink = sink; + + if (!tmpdirRedirectExitCleanupInstalled) { + tmpdirRedirectExitCleanupInstalled = true; + process.once("exit", () => { + try { + rmSync(sink, { recursive: true, force: true }); + } catch { + // Best-effort only. vitest globalTeardown also sweeps WORKER_ROOT. + } + }); + } + + return sink; +} + +/** + * If a mkdtemp prefix points straight at the OS temp root, rewrite it into a + * swept per-process sink under WORKER_ROOT. Prefixes already nested under a + * subdirectory pass through unchanged, as do non-string prefixes (Buffer/URL). + */ +function redirectTmpdirPrefix<T>(prefix: T): T { + if (typeof prefix !== "string") return prefix; + + const parent = dirname(prefix); + if (parent !== tmpdir() && parent !== REAL_TMPDIR) return prefix; + + return join(ensureTmpdirRedirectSink(), basename(prefix)) as T; +} function ensureIsolatedHome(): void { const existingHome = process.env.HOME ?? process.env.USERPROFILE; @@ -290,8 +454,9 @@ function installFsGuards(): void { return originalFs.cpSync(src, dest, options as Parameters<typeof fs.cpSync>[2]); }) as typeof fs.cpSync; mutableFs.mkdtempSync = ((prefix, options) => { - guardOne(prefix, "fs.mkdtempSync"); - return originalFs.mkdtempSync(prefix, options as Parameters<typeof fs.mkdtempSync>[1]); + const redirectedPrefix = redirectTmpdirPrefix(prefix); + guardOne(redirectedPrefix, "fs.mkdtempSync"); + return originalFs.mkdtempSync(redirectedPrefix, options as Parameters<typeof fs.mkdtempSync>[1]); }) as typeof fs.mkdtempSync; mutableFs.openSync = ((path, flags, mode) => { guardOne(path, "fs.openSync"); @@ -408,8 +573,9 @@ function installFsGuards(): void { return originalFsPromises.open(...args); }) as typeof fsPromises.open; mutableFsPromises.mkdtemp = (async (...args: Parameters<typeof fsPromises.mkdtemp>) => { - guardOne(args[0], "fs.promises.mkdtemp"); - return originalFsPromises.mkdtemp(...args); + const redirectedPrefix = redirectTmpdirPrefix(args[0]); + guardOne(redirectedPrefix, "fs.promises.mkdtemp"); + return originalFsPromises.mkdtemp(redirectedPrefix, args[1]); }) as typeof fsPromises.mkdtemp; mutableFsPromises.truncate = (async (...args: Parameters<typeof fsPromises.truncate>) => { guardOne(args[0], "fs.promises.truncate"); diff --git a/packages/core/src/__test-utils__/vitest-teardown.ts b/packages/core/src/__test-utils__/vitest-teardown.ts index f09bb12cab..20f52e3482 100644 --- a/packages/core/src/__test-utils__/vitest-teardown.ts +++ b/packages/core/src/__test-utils__/vitest-teardown.ts @@ -1,28 +1,78 @@ /** * Vitest globalSetup hook. * - * We only publish the shared worker-root env var here. Teardown is intentionally - * a no-op because deleting shared temp roots during teardown can race with - * still-running suites in some Vitest pool modes and trigger uv_cwd failures. - * Worker dirs are cleaned by vitest-setup.ts on process exit. + * We publish a per-invocation worker-root env var. Teardown removes that private + * root after the project finishes so workspace isolation checks do not report + * the run-local worker/home directories as leaks. */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; -const WORKER_ROOT = join(tmpdir(), "fusion-test-workers"); +export const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner"; + +let workerRootRmSync = rmSync; +let workerRootSleepMsSync = sleepMsSync; + +export function __setWorkerRootRmSyncForTests(nextRmSync: typeof rmSync): void { + workerRootRmSync = typeof nextRmSync === "function" ? nextRmSync : rmSync; +} + +export function __setWorkerRootSleepMsSyncForTests(nextSleep: (ms: number) => void): void { + workerRootSleepMsSync = typeof nextSleep === "function" ? nextSleep : sleepMsSync; +} + +function sleepMsSync(ms: number): void { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function isEnoent(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); +} + +export function removeWorkerRootWithRetry(workerRoot: string, retries = 3, delayMs = 75): void { + let lastError: unknown = null; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + workerRootRmSync(workerRoot, { recursive: true, force: true }); + return; + } catch (error) { + if (isEnoent(error)) return; + lastError = error; + if (attempt < retries) { + workerRootSleepMsSync(delayMs); + } + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + console.warn(`[vitest-teardown] failed to remove worker root ${workerRoot} after ${retries} attempts: ${message}`); +} export default function setup(): () => Promise<void> { - // Set the env var here too so vitest-setup.ts workers pick it up even if - // their own mkdir runs after globalSetup. - process.env.FUSION_TEST_WORKER_ROOT = WORKER_ROOT; + // Use a fresh root for each Vitest invocation. A static shared root makes the + // setup-time redirect sweep proportional to stale directories left by every + // prior interrupted run. + const workerRoot = resolve(mkdtempSync(join(tmpdir(), "fusion-test-workers-"))); + try { + writeFileSync(join(workerRoot, WORKER_ROOT_OWNER_FILE), `${process.pid}\n`); + } catch { + // Best effort only. The marker protects active roots from external orphan + // pruning; teardown still owns this root by absolute path. + } + process.env.FUSION_TEST_WORKER_ROOT = workerRoot; return async function teardown() { - // Intentionally no-op. - // - // Worker temp dirs are cleaned by vitest-setup.ts using process.on("exit") - // after first chdir-ing out of the worker dir. Deleting shared temp roots - // from global teardown is unsafe under some Vitest pool modes because it - // can run while other suites are still active, causing ENOENT uv_cwd. + try { + process.chdir(tmpdir()); + } catch { + // Ignore — cleanup below is best-effort and uses an absolute path. + } + // FN-6360: macOS can report transient EBUSY/ENOTEMPTY while SQLite WALs or + // redirected temp dirs are still closing. Retry boundedly so a brief busy-fd + // race does not leak the per-invocation fusion-test-workers-* root. + removeWorkerRootWithRetry(workerRoot); }; } diff --git a/packages/core/src/__tests__/agent-prompts.test.ts b/packages/core/src/__tests__/agent-prompts.test.ts index 47829f8fba..93464d08fa 100644 --- a/packages/core/src/__tests__/agent-prompts.test.ts +++ b/packages/core/src/__tests__/agent-prompts.test.ts @@ -8,7 +8,12 @@ import { getAvailableTemplates, getTemplatesForRole, } from "../agent-prompts.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../builtin-workflow-prompts.js"; +import { renderTriagePolicyPlaceholders } from "../builtin-workflow-settings.js"; +import { resolvePlanningPromptFromIr, resolveSeamPromptFromIr } from "../workflow-ir-resolver.js"; import type { AgentPromptsConfig, AgentPromptTemplate } from "../types.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; // --------------------------------------------------------------------------- // resolveAgentPrompt @@ -257,36 +262,65 @@ describe("resolveAgentPrompt", () => { expect(result).toContain("task_document_write"); }); - it("triage prompt broad-scope decomposition block is present and identical in core and engine templates", () => { + it("fast triage prompt is sourced from built-in workflow seam data", () => { + const fastTemplate = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.id === "default-triage-fast"); + + expect(fastTemplate).toBeDefined(); + expect(fastTemplate?.role).toBe("triage"); + expect(BUILTIN_SEAM_PROMPTS["planning-fast"]).toBe(fastTemplate?.prompt); + expect(builtinSeamPrompt("planning-fast")).toBe(fastTemplate?.prompt); + expect(builtinSeamPrompt("planning-fast")).toContain("This task is running in **fast mode**"); + expect(builtinSeamPrompt("planning-fast")).not.toContain("## Review Level"); + }); + + it("triage planning prompt is sourced from workflow IR without an engine duplicate", () => { const corePrompt = resolveAgentPrompt("triage"); + const planningPrompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR); const triageSource = readFileSync( resolve(fileURLToPath(new URL("..", import.meta.url)), "..", "..", "engine", "src", "triage.ts"), "utf8", ); - const enginePromptMatch = triageSource.match(/export const TRIAGE_SYSTEM_PROMPT = `([\s\S]*?)`;/); - expect(enginePromptMatch?.[1]).toBeTruthy(); - const enginePrompt = enginePromptMatch![1].replaceAll("\\`", "`"); - for (const prompt of [corePrompt, enginePrompt]) { - expect(prompt).toContain("**Broad-scope decomposition signals:**"); - expect(prompt).toContain("step count would reach 9 or more"); - expect(prompt).toContain("would reach 12 or more"); - expect(prompt).toContain("20 or more entries"); - expect(prompt).toContain("at or above 30 items"); - } + expect(triageSource).not.toContain(["FAST", "TRIAGE", "SYSTEM", "PROMPT"].join("_")); + expect(triageSource).not.toMatch(/export const [A-Z_]*TRIAGE[A-Z_]*SYSTEM_PROMPT\s*=/); + expect(planningPrompt).toBe(corePrompt); + expect(corePrompt).toContain("**Broad-scope decomposition signals:**"); + expect(corePrompt).toContain("step count would reach {{triageSubtaskLargeStepSignal}} or more"); + expect(corePrompt).toContain("would reach {{triageSubtaskAdditiveStepSignal}} or more"); + expect(corePrompt).toContain("{{triageSubtaskFileScopeThreshold}} or more entries"); + expect(corePrompt).toContain("at or above {{triageSubtaskRemediationBatchThreshold}} items"); - const marker = "**Broad-scope decomposition signals:**"; - const blockRegex = /\*\*Broad-scope decomposition signals:\*\*[\s\S]*?(?=\n\n(?:##|\*\*))/; - const coreStart = corePrompt.indexOf(marker); - const engineStart = enginePrompt.indexOf(marker); - expect(coreStart).toBeGreaterThanOrEqual(0); - expect(engineStart).toBeGreaterThanOrEqual(0); + const renderedPrompt = renderTriagePolicyPlaceholders(corePrompt, {}); + expect(renderedPrompt).toContain("step count would reach 9 or more"); + expect(renderedPrompt).toContain("would reach 12 or more"); + expect(renderedPrompt).toContain("20 or more entries"); + expect(renderedPrompt).toContain("at or above 30 items"); + expect(renderedPrompt).not.toContain("{{"); + }); - const coreBlock = corePrompt.slice(coreStart).match(blockRegex)?.[0]; - const engineBlock = enginePrompt.slice(engineStart).match(blockRegex)?.[0]; - expect(coreBlock).toBeTruthy(); - expect(engineBlock).toBeTruthy(); - expect(coreBlock).toBe(engineBlock); + it("resolves custom seam prompts and ignores IRs without matching prompts", () => { + const customIr: WorkflowIr = { + version: "v1", + name: "custom", + nodes: [ + { id: "start", kind: "start" }, + { id: "planning", kind: "prompt", config: { seam: "planning", prompt: "custom planning prompt" } }, + { id: "review", kind: "prompt", config: { seam: "review", prompt: "custom review prompt" } }, + ], + edges: [], + }; + const noPlanningIr: WorkflowIr = { + version: "v1", + name: "no-planning", + nodes: [{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "executor" } }], + edges: [], + }; + + expect(resolvePlanningPromptFromIr(customIr)).toBe("custom planning prompt"); + expect(resolveSeamPromptFromIr(customIr, "review")).toBe("custom review prompt"); + expect(resolveSeamPromptFromIr(BUILTIN_CODING_WORKFLOW_IR, "review")).toBe(resolveAgentPrompt("reviewer")); + expect(resolvePlanningPromptFromIr(noPlanningIr)).toBeUndefined(); + expect(resolveSeamPromptFromIr(noPlanningIr, "review")).toBeUndefined(); }); it("built-in triage prompt requires surface enumeration for bug-fix specs", () => { diff --git a/packages/core/src/__tests__/agent-role-policy.test.ts b/packages/core/src/__tests__/agent-role-policy.test.ts index 5282aba59e..2dea943f95 100644 --- a/packages/core/src/__tests__/agent-role-policy.test.ts +++ b/packages/core/src/__tests__/agent-role-policy.test.ts @@ -31,11 +31,14 @@ describe("agent-role-policy", () => { canAgentTakeImplementationTaskForBacklogPickup({ role: "executor" }, { column: "todo" }), ).toBe(true); expect( - canAgentTakeImplementationTask({ role: "executor" }, { column: "todo" }), + canAgentTakeImplementationTaskForBacklogPickup({ role: "executor" }, { column: "todo" }, { allowEngineer: true }), + ).toBe(true); + expect( + canAgentTakeImplementationTask({ role: "executor" }, { column: "todo" }, { allowEngineer: true }), ).toBe(true); }); - it("allows durable engineer only for explicit routing", () => { + it("allows durable engineer for explicit routing and opt-in backlog pickup only", () => { expect(isEngineerRoleAgent({ role: "engineer" })).toBe(true); expect( canAgentTakeImplementationTaskForExplicitRouting({ role: "engineer" }, { column: "todo" }), @@ -43,9 +46,18 @@ describe("agent-role-policy", () => { expect( canAgentTakeImplementationTaskForBacklogPickup({ role: "engineer" }, { column: "todo" }), ).toBe(false); + expect( + canAgentTakeImplementationTask({ role: "engineer" }, { column: "todo" }), + ).toBe(false); + expect( + canAgentTakeImplementationTaskForBacklogPickup({ role: "engineer" }, { column: "todo" }, { allowEngineer: true }), + ).toBe(true); + expect( + canAgentTakeImplementationTask({ role: "engineer" }, { column: "todo" }, { allowEngineer: true }), + ).toBe(true); }); - it("keeps reviewer blocked by default", () => { + it("keeps reviewer and custom roles blocked from backlog pickup even when engineers opt in", () => { expect(isExecutorRoleAgent({ role: "reviewer" })).toBe(false); expect( canAgentTakeImplementationTaskForExplicitRouting({ role: "reviewer" }, { column: "todo" }), @@ -53,6 +65,21 @@ describe("agent-role-policy", () => { expect( canAgentTakeImplementationTaskForBacklogPickup({ role: "reviewer" }, { column: "todo" }), ).toBe(false); + expect( + canAgentTakeImplementationTaskForBacklogPickup({ role: "reviewer" }, { column: "todo" }, { allowEngineer: true }), + ).toBe(false); + expect( + canAgentTakeImplementationTaskForBacklogPickup({ role: "custom" }, { column: "todo" }, { allowEngineer: true }), + ).toBe(false); + }); + + it("does not gate non-implementation columns by role", () => { + expect( + canAgentTakeImplementationTaskForBacklogPickup({ role: "reviewer" }, { column: "done" }), + ).toBe(true); + expect( + canAgentTakeImplementationTaskForBacklogPickup({ role: "custom" }, { column: "archived" }, { allowEngineer: true }), + ).toBe(true); }); it("formats mismatch reason with agent/task details", () => { diff --git a/packages/core/src/__tests__/agent-store.test.ts b/packages/core/src/__tests__/agent-store.test.ts index 518b94f69c..16905f744c 100644 --- a/packages/core/src/__tests__/agent-store.test.ts +++ b/packages/core/src/__tests__/agent-store.test.ts @@ -1846,7 +1846,11 @@ describe("AgentStore", () => { taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); await taskStore.init(); - store = new AgentStore({ rootDir, taskStore }); + // Mirror the top-level AgentStore setup: checkout-leasing assertions need + // the disk-backed TaskStore for task persistence, but not a disk-backed + // AgentStore SQLite database in a shared hook. + store.close(); + store = new AgentStore({ rootDir, inMemoryDb: true, taskStore }); await store.init(); const holder = await store.createAgent({ name: "Checkout Holder", role: "executor" }); @@ -1859,7 +1863,6 @@ describe("AgentStore", () => { }); afterEach(() => { - store.close(); taskStore.close(); }); diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index d29c4437b6..a977332d4d 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -22,6 +22,7 @@ import { MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT, MAX_DESCRIPTION_LENGTH, + MAX_TITLE_SUMMARIZE_INPUT_LENGTH, MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, @@ -53,6 +54,7 @@ describe("ai-summarize", () => { it("should have correct length limits", () => { expect(MIN_DESCRIPTION_LENGTH).toBe(201); expect(MAX_DESCRIPTION_LENGTH).toBe(2000); + expect(MAX_TITLE_SUMMARIZE_INPUT_LENGTH).toBe(4000); expect(MAX_TITLE_LENGTH).toBe(60); }); @@ -89,21 +91,21 @@ describe("ai-summarize", () => { expect(() => validateDescription(desc)).toThrow("at least 201 characters"); }); - it("should throw for description too long", () => { - const desc = "a".repeat(2001); - expect(() => validateDescription(desc)).toThrow(ValidationError); - expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters"); - }); - it("should accept description at minimum boundary", () => { const desc = "a".repeat(201); expect(validateDescription(desc)).toBe(desc); }); - it("should accept description at maximum boundary", () => { + it("should accept description at historical maximum boundary", () => { const desc = "a".repeat(2000); expect(validateDescription(desc)).toBe(desc); }); + + it("should accept descriptions longer than the historical maximum", () => { + const desc = "a".repeat(5000); + expect(() => validateDescription(desc)).not.toThrow(); + expect(validateDescription(desc)).toBe(desc); + }); }); // ── Rate Limiting ────────────────────────────────────────────────────────── @@ -206,6 +208,33 @@ describe("ai-summarize", () => { expect(prompt.mock.calls[0][0]).toContain("Do not call any tools"); }); + it("summarizes long descriptions with bounded prompt input", async () => { + const prompt = vi.fn().mockResolvedValue(undefined); + getFnAgentMock.mockResolvedValue(() => + Promise.resolve({ + session: { + prompt, + dispose: vi.fn(), + state: { + messages: [ + { role: "assistant", content: "Summarize long description" }, + ], + }, + }, + }) + ); + const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250); + + const title = await summarizeTitle(description, "/tmp"); + + expect(title).toBe("Summarize long description"); + expect(prompt).toHaveBeenCalledTimes(1); + const promptText = prompt.mock.calls[0][0] as string; + expect(promptText).toContain("…(truncated)"); + expect(promptText).not.toContain("tail"); + expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250); + }); + it("strips chatty preamble + markdown from AI response (FN-3057 regression)", async () => { // Reproduces the FN-3057 incident: model wrote a chat-style reply // ("Created **FN-3058** with the full spec…") that was sliced mid-word @@ -255,6 +284,108 @@ describe("ai-summarize", () => { const title = await summarizeTitle("a".repeat(201), "/tmp"); expect(title).toBe("Refactor merger title fallback"); }); + + it("retries stale configured model ids with automatic resolution and logs the stale id", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const createFnAgent = vi + .fn() + .mockRejectedValueOnce(new Error( + "Configured model fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo (primary selection) " + + "was not found in the pi model registry.", + )) + .mockResolvedValueOnce({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Fix pi upgrade regressions" }], + }, + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); + + const title = await summarizeTitle( + "a".repeat(201), + "/tmp", + "fireworksai", + "accounts/fireworks/routers/kimi-k2p5-turbo", + ); + + expect(title).toBe("Fix pi upgrade regressions"); + expect(createFnAgent).toHaveBeenNthCalledWith(1, expect.objectContaining({ + defaultProvider: "fireworksai", + defaultModelId: "accounts/fireworks/routers/kimi-k2p5-turbo", + })); + expect(createFnAgent).toHaveBeenNthCalledWith(2, expect.not.objectContaining({ + defaultProvider: expect.any(String), + defaultModelId: expect.any(String), + })); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo")); + warnSpy.mockRestore(); + }); + + it("keeps valid configured model ids on the primary summarizer path", async () => { + const createFnAgent = vi.fn().mockResolvedValue({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Keep configured model" }], + }, + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); + + const title = await summarizeTitle("a".repeat(201), "/tmp", "anthropic", "claude-sonnet-4-5"); + + expect(title).toBe("Keep configured model"); + expect(createFnAgent).toHaveBeenCalledTimes(1); + expect(createFnAgent).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + })); + }); + + it("returns null when stale-model automatic resolution also fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const createFnAgent = vi + .fn() + .mockRejectedValueOnce(new Error( + "Configured model fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo (primary selection) " + + "was not found in the pi model registry.", + )) + .mockRejectedValueOnce(new Error("No model selected")); + getFnAgentMock.mockResolvedValue(createFnAgent); + + await expect(summarizeTitle( + "a".repeat(201), + "/tmp", + "fireworksai", + "accounts/fireworks/routers/kimi-k2p5-turbo", + )).resolves.toBeNull(); + expect(createFnAgent).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("retrying with automatic model resolution")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Automatic title summarizer fallback")); + warnSpy.mockRestore(); + }); + + it("does not mask genuine AI service errors", async () => { + getFnAgentMock.mockResolvedValue(() => + Promise.resolve({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + error: "authentication failed", + messages: [], + }, + }, + }) + ); + + await expect(summarizeTitle("a".repeat(201), "/tmp", "anthropic", "claude-sonnet-4-5")) + .rejects.toThrow("AI session error: authentication failed"); + }); }); describe("sanitizeTitle", () => { diff --git a/packages/core/src/__tests__/archive-db-fts-maintenance.test.ts b/packages/core/src/__tests__/archive-db-fts-maintenance.test.ts index cf9dd5d3b3..9800b23d24 100644 --- a/packages/core/src/__tests__/archive-db-fts-maintenance.test.ts +++ b/packages/core/src/__tests__/archive-db-fts-maintenance.test.ts @@ -60,8 +60,8 @@ describe("ArchiveDatabase FTS maintenance", () => { return; } - const payload = "alpha ".repeat(1200); - for (let i = 0; i < 180; i++) { + const payload = "alpha ".repeat(400); + for (let i = 0; i < 72; i++) { archive.upsert(makeEntry("FN-ARCHIVE-1", { archivedAt: new Date(1717372800000 + i * 1000).toISOString(), updatedAt: new Date(1717372800000 + i * 1000).toISOString(), @@ -81,7 +81,7 @@ describe("ArchiveDatabase FTS maintenance", () => { expect(rebuiltBytes).not.toBeNull(); expect(rebuiltBytes!).toBeLessThan(grownBytes!); expect(rebuiltBytes!).toBeLessThan(1 * 1024 * 1024); - expect(archive.search("release-note-179", 10).map((entry) => entry.id)).toContain("FN-ARCHIVE-1"); + expect(archive.search("release-note-71", 10).map((entry) => entry.id)).toContain("FN-ARCHIVE-1"); } finally { archive.close(); await rm(dir, { recursive: true, force: true }); diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 8e7ff67cdc..985260d595 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { BUILTIN_CODING_WORKFLOW_IR, + BUILTIN_PR_WORKFLOW_IR, + BUILTIN_STEPWISE_CODING_WORKFLOW_IR, DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr, serializeWorkflowIr, @@ -36,7 +38,8 @@ describe("builtin coding workflow ir", () => { const seams = BUILTIN_CODING_WORKFLOW_IR.nodes .map((node) => String(node.config?.seam ?? "")) .filter((seam) => seam.length > 0); - expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review", "merge"])); + expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review"])); + expect(seams).not.toContain("merge"); expect(seams).not.toContain("triage"); }); @@ -72,7 +75,8 @@ describe("builtin coding workflow ir", () => { expect(byId.get("execute")?.column).toBe("in-progress"); expect(byId.get("workflow-step")?.column).toBe("in-progress"); expect(byId.get("review")?.column).toBe("in-review"); - expect(byId.get("merge")?.column).toBe("in-review"); + expect(byId.get("merge-gate")?.column).toBe("in-review"); + expect(byId.get("merge-attempt")?.column).toBe("in-review"); }); it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => { @@ -80,7 +84,6 @@ describe("builtin coding workflow ir", () => { expect(byId.get("execute")?.config?.name).toBe("Execute"); expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("merge")?.config?.name).toBe("Merge boundary"); }); it("declares a bounded retry budget only on the execute seam", () => { @@ -93,10 +96,9 @@ describe("builtin coding workflow ir", () => { const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("merge")?.config?.name).toBe("Merge boundary"); expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined(); expect(byId.get("review")?.config?.maxRetries).toBeUndefined(); - expect(byId.get("merge")?.config?.maxRetries).toBeUndefined(); + expect(byId.get("merge-attempt")?.config?.maxReworkCycles).toBe(3); }); it("preserves the execute retry declaration through parse/serialize round-trip", () => { @@ -104,4 +106,43 @@ describe("builtin coding workflow ir", () => { const config = executeNodeConfig(reparsed); expect(config.maxRetries).toBe(EXECUTE_NODE_MAX_RETRIES); }); + + it("expresses default merge retry recovery and branch-group policy as built-in nodes", () => { + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((node) => [node.id, node])); + expect(byId.get("merge-gate")?.kind).toBe("merge-gate"); + expect(byId.get("merge-retry")?.kind).toBe("retry-backoff"); + expect(byId.get("merge-manual-hold")?.kind).toBe("manual-merge-hold"); + expect(byId.get("branch-group-member-integration")?.kind).toBe("branch-group-member-integration"); + expect(byId.get("branch-group-promotion")?.kind).toBe("branch-group-promotion"); + expect(byId.get("merge-attempt")?.kind).toBe("merge-attempt"); + expect(byId.get("recovery-router")?.kind).toBe("recovery-router"); + expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }), + expect.objectContaining({ from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }), + expect.objectContaining({ from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }), + ]), + ); + }); + + it("expresses merge policy regions in stepwise and PR built-ins", () => { + expect(BUILTIN_STEPWISE_CODING_WORKFLOW_IR.nodes.map((node) => node.kind)).toEqual( + expect.arrayContaining([ + "merge-gate", + "retry-backoff", + "manual-merge-hold", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "recovery-router", + ]), + ); + expect(BUILTIN_PR_WORKFLOW_IR.nodes.map((node) => node.kind)).toEqual(expect.arrayContaining(["manual-merge-hold", "pr-merge"])); + expect(BUILTIN_PR_WORKFLOW_IR.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ from: "gate", to: "manual-merge-hold", condition: "outcome:auto-off" }), + expect.objectContaining({ from: "manual-merge-hold", to: "pr-merge", condition: "success" }), + ]), + ); + }); }); diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts new file mode 100644 index 0000000000..3a45df98e7 --- /dev/null +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + BUILTIN_MOVED_WORKFLOW_SETTINGS, + BUILTIN_TRIAGE_POLICY_SETTINGS, + BUILTIN_WORKFLOW_SETTINGS, + renderTriagePolicyPlaceholders, +} from "../builtin-workflow-settings.js"; +import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; + +const expectedDefaults: Record<string, { type: string; default: unknown }> = { + triageSizeSmallMaxHours: { type: "number", default: 2 }, + triageSizeMediumMaxHours: { type: "number", default: 4 }, + triageSizeLargeMaxHours: { type: "number", default: 8 }, + triageSubtaskStepThreshold: { type: "number", default: 7 }, + triageSubtaskLargeStepSignal: { type: "number", default: 9 }, + triageSubtaskAdditiveStepSignal: { type: "number", default: 12 }, + triageSubtaskPackageThreshold: { type: "number", default: 3 }, + triageSubtaskFileScopeThreshold: { type: "number", default: 20 }, + triageSubtaskRemediationBatchThreshold: { type: "number", default: 30 }, + triageNoCommitsDecisionVerbs: { + type: "multi-enum", + default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"], + }, + triageDecisionOnlyWorkflowId: { type: "enum", default: "builtin:quick-fix" }, + triageDefaultWorkflowId: { type: "enum", default: "builtin:coding" }, + leanPlanning: { type: "boolean", default: false }, + autoApproveSpec: { type: "boolean", default: false }, +}; + +describe("workflow-native triage policy settings", () => { + it("declares behavior-equivalent typed defaults outside the moved-key catalog", () => { + const triageById = new Map(BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting])); + const fullIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((setting) => setting.id)); + const movedIds = new Set(BUILTIN_MOVED_WORKFLOW_SETTINGS.map((setting) => setting.id)); + const movedKeyIds = new Set(MOVED_SETTINGS_KEYS); + + expect(BUILTIN_TRIAGE_POLICY_SETTINGS).toHaveLength(Object.keys(expectedDefaults).length); + for (const [id, expected] of Object.entries(expectedDefaults)) { + const setting = triageById.get(id); + expect(setting, `${id} should be declared`).toBeDefined(); + expect(setting?.type).toBe(expected.type); + expect(setting?.default).toStrictEqual(expected.default); + expect(fullIds.has(id), `${id} should be in the full built-in catalog`).toBe(true); + expect(movedIds.has(id), `${id} should not be in the moved-key catalog`).toBe(false); + expect(movedKeyIds.has(id), `${id} should not be in MOVED_SETTINGS_KEYS`).toBe(false); + } + }); + + it("renders placeholders from resolved settings and rejects dangling tokens", () => { + const prompt = [ + "Size S (<{{triageSizeSmallMaxHours}}h)", + "MORE THAN {{triageSubtaskStepThreshold}} implementation steps", + "verbs: {{triageNoCommitsDecisionVerbs}}", + ].join("\n"); + + const rendered = renderTriagePolicyPlaceholders(prompt, { + triageSizeSmallMaxHours: 1, + triageSubtaskStepThreshold: 5, + triageNoCommitsDecisionVerbs: ["Audit", "Confirm"], + } as never); + + expect(rendered).toContain("Size S (<1h)"); + expect(rendered).toContain("MORE THAN 5 implementation steps"); + expect(rendered).toContain("verbs: Audit, Confirm"); + expect(rendered).not.toContain("{{"); + expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/); + }); +}); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 39a5026f3a..2a7ab7dd57 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -20,7 +20,7 @@ const EXECUTE_NODE_MAX_RETRIES = 2; describe("built-in workflows", () => { // Non-compiler built-ins model graph-only node kinds or reusable fragments the // linear compiler cannot lower to a step list. They still must parse as valid IR. - const NON_COMPILABLE_BUILTIN_IDS = new Set(["builtin:stepwise-coding", "builtin:pr-workflow"]); + const NON_COMPILABLE_BUILTIN_IDS = new Set(["builtin:coding", "builtin:stepwise-coding", "builtin:pr-workflow"]); it("every built-in has a valid IR; linear built-ins compile without error", () => { expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4); @@ -138,7 +138,15 @@ describe("built-in workflows", () => { expect(byId.get("execute")?.column).toBe("in-progress"); expect(byId.get("workflow-step")?.column).toBe("in-progress"); expect(byId.get("review")?.column).toBe("in-review"); - expect(byId.get("merge")?.column).toBe("in-review"); + // Merge is the native primitive region (FN-6035), placed in in-review. + expect(byId.get("merge")).toBeUndefined(); + expect(byId.get("merge-gate")?.column).toBe("in-review"); + expect(byId.get("merge-retry")?.column).toBe("in-review"); + expect(byId.get("merge-manual-hold")?.column).toBe("in-review"); + expect(byId.get("branch-group-member-integration")?.column).toBe("in-review"); + expect(byId.get("branch-group-promotion")?.column).toBe("in-review"); + expect(byId.get("merge-attempt")?.column).toBe("in-review"); + expect(byId.get("recovery-router")?.column).toBe("in-review"); expect(ir.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS); }); @@ -195,10 +203,18 @@ describe("built-in workflows", () => { const byId = new Map(candidate.nodes.map((node) => [node.id, node])); expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps"); expect(byId.get("review")?.config?.name).toBe("Review"); - expect(byId.get("merge")?.config?.name).toBe("Merge boundary"); expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined(); expect(byId.get("review")?.config?.maxRetries).toBeUndefined(); - expect(byId.get("merge")?.config?.maxRetries).toBeUndefined(); + // The merge lifecycle is no longer a single `merge` seam node (FN-6035): it + // is expressed as the merge-gate/merge-attempt/branch-group primitive region. + expect(byId.get("merge")).toBeUndefined(); + expect(byId.get("merge-gate")?.kind).toBe("merge-gate"); + expect(byId.get("merge-retry")?.kind).toBe("retry-backoff"); + expect(byId.get("merge-manual-hold")?.kind).toBe("manual-merge-hold"); + expect(byId.get("branch-group-member-integration")?.kind).toBe("branch-group-member-integration"); + expect(byId.get("branch-group-promotion")?.kind).toBe("branch-group-promotion"); + expect(byId.get("merge-attempt")?.kind).toBe("merge-attempt"); + expect(byId.get("recovery-router")?.kind).toBe("recovery-router"); } }); @@ -320,11 +336,11 @@ describe("built-in workflows", () => { const coding = getBuiltinWorkflow("builtin:coding"); const execute = coding?.ir.nodes.find((node) => node.id === "execute"); const review = coding?.ir.nodes.find((node) => node.id === "review"); - const merge = coding?.ir.nodes.find((node) => node.id === "merge"); expect((execute?.config as { prompt?: string } | undefined)?.prompt).toContain("You are a task execution agent"); expect((review?.config as { prompt?: string } | undefined)?.prompt).toContain("You are an independent code and plan reviewer"); - expect((merge?.config as { prompt?: string } | undefined)?.prompt).toContain("You are a merge agent"); + // No `merge` seam node post-FN-6035 — merge runs as native primitives. + expect(coding?.ir.nodes.find((node) => node.id === "merge")).toBeUndefined(); }); it("rejects editing or deleting a built-in", async () => { @@ -334,10 +350,54 @@ describe("built-in workflows", () => { await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow(/cannot be deleted/i); }); - it("a task can select a built-in workflow", async () => { - const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] }); - await store.selectTaskWorkflow(task.id, "builtin:coding"); - expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:coding"); + it("branching built-ins can be selected without throwing", async () => { + for (const workflowId of ["builtin:coding", "builtin:stepwise-coding"]) { + const task = await store.createTask({ description: `select ${workflowId}`, enabledWorkflowSteps: [] }); + + await expect(store.selectTaskWorkflow(task.id, workflowId)).resolves.toEqual([]); + + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(task.id)).toEqual({ workflowId, stepIds: [] }); + } + }); + + it("create-time branching built-in workflowId records selection without throwing", async () => { + const task = await store.createTask({ description: "explicit builtin coding", workflowId: "builtin:coding" }); + + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(task.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + }); + + it("branching built-in project defaults do not throw", async () => { + await expect(store.createTask({ description: "implicit builtin default" })).resolves.toMatchObject({ + description: "implicit builtin default", + }); + + await store.setDefaultWorkflowId("builtin:coding"); + const codingTask = await store.createTask({ description: "default builtin coding" }); + expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + + const reservedCodingTask = await store.createTaskWithReservedId( + { description: "reserved default builtin coding" }, + { taskId: "reserved-default-builtin-coding" }, + ); + expect((await store.getTask(reservedCodingTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(reservedCodingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + + await store.setDefaultWorkflowId("builtin:stepwise-coding"); + const stepwiseTask = await store.createTask({ description: "default builtin stepwise" }); + expect((await store.getTask(stepwiseTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(stepwiseTask.id)).toBeUndefined(); + + const reservedStepwiseTask = await store.createTaskWithReservedId( + { description: "reserved default builtin stepwise" }, + { taskId: "reserved-default-builtin-stepwise" }, + ); + expect((await store.getTask(reservedStepwiseTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect(store.getTaskWorkflowSelection(reservedStepwiseTask.id)).toBeUndefined(); }); it("rejects selecting the PR lifecycle fragment for a task", async () => { diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index a4f89a3caa..aa49d59aa6 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,8 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -394,8 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1465,8 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1491,16 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); db.close(); }); @@ -1535,8 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1577,8 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1650,8 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1891,8 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1966,8 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1991,8 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2096,8 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2316,8 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(115); - expect(localDb.getSchemaVersion()).toBe(115); + expect(localDb.getSchemaVersion()).toBe(118); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2628,8 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(115); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2783,8 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(115); - expect(migrated.getSchemaVersion()).toBe(115); + expect(migrated.getSchemaVersion()).toBe(118); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2815,8 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(115); - expect(fresh.getSchemaVersion()).toBe(115); + expect(fresh.getSchemaVersion()).toBe(118); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2844,8 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(115); - expect(migrated.getSchemaVersion()).toBe(115); + expect(migrated.getSchemaVersion()).toBe(118); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2871,8 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(115); - expect(fresh.getSchemaVersion()).toBe(115); + expect(fresh.getSchemaVersion()).toBe(118); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2906,8 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(115); - expect(migrated.getSchemaVersion()).toBe(115); + expect(migrated.getSchemaVersion()).toBe(118); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2948,8 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(115); - expect(migrated.getSchemaVersion()).toBe(115); + expect(migrated.getSchemaVersion()).toBe(118); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2976,8 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(115); - expect(fresh.getSchemaVersion()).toBe(115); + expect(fresh.getSchemaVersion()).toBe(118); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -3236,13 +3216,16 @@ describe("Database.recoverIfCorrupt startup guard", () => { const dbPath = join(fusionDir, "fusion.db"); const db = new Database(fusionDir); db.init(); - // Span many pages so mid-file corruption lands on a B-tree page. - for (let i = 0; i < 3000; i++) { - db.prepare("INSERT INTO activityLog (id, timestamp, type, details) VALUES (?, ?, 'test', '{}')").run( - `row-${i}`, - new Date().toISOString(), - ); - } + // Span enough pages so mid-file corruption lands on a B-tree page + // without overfeeding sqlite3 .recover. + db.transaction(() => { + for (let i = 0; i < 100; i++) { + db.prepare("INSERT INTO activityLog (id, timestamp, type, details) VALUES (?, ?, 'test', '{}')").run( + `row-${i}`, + new Date().toISOString(), + ); + } + }); db.walCheckpoint("TRUNCATE"); db.close(); diff --git a/packages/core/src/__tests__/frontend-ux-policy.test.ts b/packages/core/src/__tests__/frontend-ux-policy.test.ts new file mode 100644 index 0000000000..298bc96b00 --- /dev/null +++ b/packages/core/src/__tests__/frontend-ux-policy.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + FRONTEND_UX_CRITERIA_SECTION, + applyFrontendUxCriteria, + matchesFrontendUxPath, +} from "../frontend-ux-policy.js"; +import { WORKFLOW_STEP_TEMPLATES } from "../types.js"; + +const EXACT_FRONTEND_UX_CRITERIA = `## 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 +`; + +function promptWithFileScope(paths: string[]): string { + return `# Task: FN-0000 - Example + +## Mission + +Implement the requested change without disturbing surrounding behavior. + +## File Scope + +${paths.map((path) => `- \`${path}\``).join("\n")} + +## Acceptance Criteria + +- Works as expected +`; +} + +function extractInsertedCriteria(prompt: string): string { + const start = prompt.indexOf("## Frontend UX Criteria"); + expect(start).toBeGreaterThanOrEqual(0); + const rest = prompt.slice(start); + expect(rest.slice(FRONTEND_UX_CRITERIA_SECTION.length)).toMatch(/^\n## File Scope/); + return rest.slice(0, FRONTEND_UX_CRITERIA_SECTION.length); +} + +describe("frontend UX policy", () => { + it("preserves the byte-exact criteria section fixture", () => { + expect(FRONTEND_UX_CRITERIA_SECTION).toBe(EXACT_FRONTEND_UX_CRITERIA); + expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n")).toBe(true); + expect(FRONTEND_UX_CRITERIA_SECTION.endsWith("\n\n")).toBe(false); + }); + + it.each([ + ["dashboard package", "packages/dashboard/src/server.ts"], + ["app components", "packages/plugin/app/components/Button.tsx"], + ["app hooks", "packages/plugin/app/hooks/useThing.ts"], + ["app css", "packages/plugin/app/layout.css"], + ["app tsx", "packages/plugin/app/routes.tsx"], + ])("injects exactly once after Mission for %s scope", (_label, path) => { + const original = promptWithFileScope([path]); + const injected = applyFrontendUxCriteria(original); + + expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION); + expect(extractInsertedCriteria(injected)).toBe(FRONTEND_UX_CRITERIA_SECTION); + expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1); + expect(injected).toMatch(/## Mission\n\nImplement the requested change without disturbing surrounding behavior\.\n\n## Frontend UX Criteria\n\n- \[ \] \*\*Design tokens only\*\*/); + expect(applyFrontendUxCriteria(injected)).toBe(injected); + }); + + it.each([ + ["backend", "packages/engine/src/triage.ts"], + ["config json", "package.json"], + ["eslint config", "eslint.config.mjs"], + ["docs", "docs/dashboard-guide.md"], + ["dashboard src css excluded from rule 4 but matched by dashboard package", "packages/dashboard/src/styles.css", true], + ["component css covered by component rule, not rule 4", "packages/plugin/app/components/Button.css", true], + ])("matches the expected frontend classification for %s", (_label, path, expected = false) => { + expect(matchesFrontendUxPath(path)).toBe(expected); + }); + + it("does not inject for backend-only, config-only, or docs-only file scopes", () => { + for (const path of ["packages/engine/src/triage.ts", "package.json", "eslint.config.mjs", "docs/testing.md"]) { + const original = promptWithFileScope([path]); + expect(applyFrontendUxCriteria(original)).toBe(original); + } + }); + + it("uses caller-provided file scope paths without reparsing prompt markdown", () => { + const promptWithoutFileScope = `# Task: FN-0000 - Example + +## Mission + +Implement dashboard UI. + +## Acceptance Criteria + +- Works as expected +`; + + const injected = applyFrontendUxCriteria(promptWithoutFileScope, ["packages/dashboard/app/routes.tsx"]); + + expect(injected).toContain(FRONTEND_UX_CRITERIA_SECTION); + expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1); + }); + + it("keeps checklist tokens aligned with the frontend UX design persona", () => { + const persona = WORKFLOW_STEP_TEMPLATES.find((template) => template.id === "frontend-ux-design"); + expect(persona?.name).toBe("Frontend UX Design"); + expect(persona?.prompt).toContain("design tokens"); + expect(persona?.prompt).toContain("Component Reuse"); + expect(persona?.prompt).toContain("Responsive Behavior"); + expect(persona?.prompt).toContain("Visual Hierarchy"); + + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Design tokens only"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Component reuse"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Responsive scaffolding"); + expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Visual hierarchy preserved"); + }); +}); diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 5ad25567d1..75cf8c431e 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index cd834d81a9..84f57c78a3 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(115); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(115); + expect(db3.getSchemaVersion()).toBe(118); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(115); + expect(db1.getSchemaVersion()).toBe(118); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(115); + expect(db2.getSchemaVersion()).toBe(118); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(115); + expect(db1.getSchemaVersion()).toBe(118); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts b/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts new file mode 100644 index 0000000000..49db519d67 --- /dev/null +++ b/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TaskStore } from "../store.js"; +import { allowsAutoMergeProcessing } from "../task-merge.js"; +import type { Task } from "../types.js"; +import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; + +async function moveToReview(store: TaskStore, description: string): Promise<Task> { + const task = await store.createTask({ description }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + return store.moveTask(task.id, "in-review"); +} + +async function seedLegacyStamp(store: TaskStore, rootDir: string, description = "legacy stamp"): Promise<Task> { + const task = await moveToReview(store, description); + (store as any).db.prepare("UPDATE tasks SET autoMerge = 1, autoMergeProvenance = NULL WHERE id = ?").run(task.id); + const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json"); + const diskTask = JSON.parse(await readFile(taskJsonPath, "utf-8")) as Task; + diskTask.autoMerge = true; + delete diskTask.autoMergeProvenance; + await writeFile(taskJsonPath, JSON.stringify(diskTask, null, 2)); + return (await store.getTask(task.id))!; +} + +async function resetLegacyMarker(store: TaskStore): Promise<void> { + (store as any).db.prepare("DELETE FROM __meta WHERE key = 'legacyAutoMergeStampMarkedVersion'").run(); +} + +describe("legacy auto-merge stamp reconciliation", () => { + const harness = createTaskStoreTestHarness(); + let rootDir: string; + let store: TaskStore; + + afterEach(async () => { + await harness.afterEach(); + }); + + async function setupHarness(): Promise<void> { + await harness.beforeEach(); + rootDir = harness.rootDir(); + store = harness.store(); + } + + it("marks ambiguous legacy in-review stamps once without changing autoMerge", async () => { + await setupHarness(); + const legacy = await seedLegacyStamp(store, rootDir); + const user = await moveToReview(store, "user override"); + await store.updateTask(user.id, { autoMerge: true }); + await resetLegacyMarker(store); + + await (store as any).markLegacyAutoMergeStampsOnce(); + + const marked = await store.getTask(legacy.id); + const preserved = await store.getTask(user.id); + expect(marked?.autoMerge).toBe(true); + expect(marked?.autoMergeProvenance).toBe("legacy-stamp"); + expect(preserved?.autoMerge).toBe(true); + expect(preserved?.autoMergeProvenance).toBe("user"); + + const firstAuditCount = store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" }).length; + await (store as any).markLegacyAutoMergeStampsOnce(); + expect(store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" })).toHaveLength(firstAuditCount); + }); + + it("no-ops on empty and zero-candidate databases while setting the once marker", async () => { + await setupHarness(); + await resetLegacyMarker(store); + + await (store as any).markLegacyAutoMergeStampsOnce(); + + expect(store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" })).toHaveLength(0); + const regular = await moveToReview(store, "no override"); + expect(regular.autoMerge).toBeUndefined(); + await (store as any).markLegacyAutoMergeStampsOnce(); + expect((await store.getTask(regular.id))?.autoMergeProvenance).toBeUndefined(); + }); + + it("dry-runs candidates without mutating and apply clears only legacy stamps", async () => { + await setupHarness(); + const legacy = await seedLegacyStamp(store, rootDir); + await resetLegacyMarker(store); + await (store as any).markLegacyAutoMergeStampsOnce(); + + const user = await moveToReview(store, "genuine user true"); + await store.updateTask(user.id, { autoMerge: true }); + + const dryRun = await store.reconcileLegacyAutoMergeStamps(); + expect(dryRun).toEqual([{ taskId: legacy.id, column: "in-review", cleared: false }]); + expect((await store.getTask(legacy.id))?.autoMerge).toBe(true); + expect((await store.getTask(legacy.id))?.autoMergeProvenance).toBe("legacy-stamp"); + + // Original symptom: with global autoMerge off, the legacy value still passes the gate. + expect(allowsAutoMergeProcessing((await store.getTask(legacy.id))!, { autoMerge: false })).toBe(true); + + const applied = await store.reconcileLegacyAutoMergeStamps({ apply: true }); + expect(applied).toEqual([{ taskId: legacy.id, column: "in-review", cleared: true }]); + + const cleared = (await store.getTask(legacy.id))!; + expect(cleared.autoMerge).toBeUndefined(); + expect(cleared.autoMergeProvenance).toBeUndefined(); + expect(allowsAutoMergeProcessing(cleared, { autoMerge: false })).toBe(false); + + const preserved = (await store.getTask(user.id))!; + expect(preserved.autoMerge).toBe(true); + expect(preserved.autoMergeProvenance).toBe("user"); + expect(allowsAutoMergeProcessing(preserved, { autoMerge: false })).toBe(true); + + const clearAudits = store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-cleared" }); + expect(clearAudits).toHaveLength(1); + expect(clearAudits[0]?.target).toBe(legacy.id); + }); + + it("round-trips provenance through SQLite and task.json, including absent provenance", async () => { + const diskRoot = makeTmpDir(); + const globalDir = makeTmpDir(); + let diskStore = new TaskStore(diskRoot, globalDir); + await diskStore.init(); + try { + const inherited = await moveToReview(diskStore, "absent provenance"); + const explicit = await moveToReview(diskStore, "explicit provenance"); + await diskStore.updateTask(explicit.id, { autoMerge: true }); + + const explicitJson = JSON.parse(await readFile(join(diskRoot, ".fusion", "tasks", explicit.id, "task.json"), "utf-8")) as Task; + const inheritedJson = JSON.parse(await readFile(join(diskRoot, ".fusion", "tasks", inherited.id, "task.json"), "utf-8")) as Task; + expect(explicitJson.autoMergeProvenance).toBe("user"); + expect(inheritedJson.autoMergeProvenance).toBeUndefined(); + + diskStore.close(); + diskStore = new TaskStore(diskRoot, globalDir); + await diskStore.init(); + + expect((await diskStore.getTask(explicit.id))?.autoMergeProvenance).toBe("user"); + expect((await diskStore.getTask(explicit.id, { activityLogLimit: 50 }))?.autoMergeProvenance).toBe("user"); + expect((await diskStore.getTask(inherited.id))?.autoMergeProvenance).toBeUndefined(); + expect((await diskStore.getTask(inherited.id, { activityLogLimit: 50 }))?.autoMergeProvenance).toBeUndefined(); + } finally { + diskStore.close(); + await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/packages/core/src/__tests__/manual-retry-reset.test.ts b/packages/core/src/__tests__/manual-retry-reset.test.ts index 5b6a249fb1..3594e32b10 100644 --- a/packages/core/src/__tests__/manual-retry-reset.test.ts +++ b/packages/core/src/__tests__/manual-retry-reset.test.ts @@ -53,6 +53,7 @@ describe("buildManualRetryResetPatch", () => { for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) { expect(patch[key]).toBe(0); } + expect(patch.graphResumeRetryCount).toBe(0); }); it("includes all retry-summary counters in the reset key list", () => { diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 018b79c15e..b3e4291825 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); }); it("upserts merge request records", async () => { @@ -69,6 +69,87 @@ describe("TaskStore merge request record + completion handoff marker", () => { expect(store.transitionMergeRequestState(taskId, "succeeded", { now: "2026-05-30T00:00:05.000Z" }).state).toBe("succeeded"); }); + it("projects merge request states onto workflow work items", async () => { + const cases = [ + { mergeState: "queued", workState: "runnable", kind: "merge" }, + { mergeState: "running", workState: "running", kind: "merge" }, + { mergeState: "retrying", workState: "retrying", kind: "merge" }, + { mergeState: "manual-required", workState: "manual-required", kind: "manual-hold" }, + { mergeState: "succeeded", workState: "succeeded", kind: "merge" }, + { mergeState: "exhausted", workState: "exhausted", kind: "merge" }, + { mergeState: "cancelled", workState: "cancelled", kind: "merge" }, + ] as const; + + for (const { mergeState, workState, kind } of cases) { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: mergeState, + attemptCount: 3, + lastError: mergeState === "manual-required" ? "needs human" : "last failure", + now: "2026-05-30T00:00:00.000Z", + }); + + const item = store.projectMergeRequestToWorkflowWorkItem(taskId, { + now: "2026-05-30T00:00:01.000Z", + }); + + expect(item).toMatchObject({ + runId: `merge-request:${taskId}`, + taskId, + nodeId: "builtin.merge.request", + kind, + state: workState, + attempt: 3, + }); + } + }); + + it("projects merge requests idempotently across restart-style replays", async () => { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: "retrying", + attemptCount: 2, + lastError: "network reset", + now: "2026-05-30T00:00:00.000Z", + }); + + const first = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" }); + const second = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:02.000Z" }); + + expect(second?.id).toBe(first?.id); + expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toHaveLength(1); + expect(second).toMatchObject({ state: "retrying", attempt: 2, lastError: "network reset" }); + }); + + it("cancels stale manual-hold projection when the same merge request succeeds", async () => { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: "manual-required", + attemptCount: 1, + lastError: "needs human", + now: "2026-05-30T00:00:00.000Z", + }); + + const hold = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" }); + store.upsertMergeRequestRecord(taskId, { + state: "succeeded", + attemptCount: 1, + lastError: null, + now: "2026-05-30T00:00:02.000Z", + }); + const merge = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:03.000Z" }); + + expect(merge).toMatchObject({ kind: "merge", state: "succeeded" }); + expect(store.getWorkflowWorkItem(hold?.id ?? "")).toMatchObject({ + kind: "manual-hold", + state: "cancelled", + lastError: "superseded-by-merge-request-projection", + }); + expect(store.listWorkflowWorkItemsForTask(taskId).filter((item) => item.state !== "cancelled")).toEqual([ + expect.objectContaining({ id: merge?.id, kind: "merge", state: "succeeded" }), + ]); + }); + it("rejects invalid merge-request transitions", async () => { const taskId = await createTask(); store.upsertMergeRequestRecord(taskId, { state: "queued" }); @@ -112,4 +193,33 @@ describe("TaskStore merge request record + completion handoff marker", () => { expect(store.getMergeRequestRecord(taskId)?.state).toBe("cancelled"); expect(store.getCompletionHandoffAcceptedMarker(taskId)).toBeNull(); }); + + it("cancels active workflow merge work on user hard-cancel from in-review to todo", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-test" }, + }); + store.setCompletionHandoffAcceptedMarker(taskId, { source: "executor:fn_task_done" }); + const mergeWork = store.upsertWorkflowWorkItem({ + runId: "run-merge", + taskId, + nodeId: "builtin.merge.request", + kind: "merge", + state: "running", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-05-30T00:05:00.000Z", + }); + + await store.moveTask(taskId, "todo", { moveSource: "user" }); + + expect(store.getWorkflowWorkItem(mergeWork.id)).toMatchObject({ + state: "cancelled", + leaseOwner: null, + leaseExpiresAt: null, + lastError: "cancelled-by-user-hard-cancel", + }); + }); }); diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index e26bbb7a60..c48ef1e1e5 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -82,6 +82,104 @@ describe("model-resolution", () => { ).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); }); + it("uses project lane overrides for every pure settings lane before global and default fallbacks", () => { + expect(resolveExecutionSettingsModel({ + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-exec-provider", modelId: "project-exec-model" }); + + expect(resolvePlanningSettingsModel({ + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + planningGlobalProvider: "global-plan-provider", + planningGlobalModelId: "global-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-plan-provider", modelId: "project-plan-model" }); + + expect(resolveValidatorSettingsModel({ + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + validatorGlobalProvider: "global-validator-provider", + validatorGlobalModelId: "global-validator-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-validator-provider", modelId: "project-validator-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerProvider: "project-title-provider", + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-title-provider", modelId: "project-title-model" }); + }); + + it("does not mix partial project lane pairs with lower precedence model fields", () => { + expect(resolveExecutionSettingsModel({ + executionProvider: "project-exec-provider", + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + })).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + + expect(resolvePlanningSettingsModel({ + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + + expect(resolveValidatorSettingsModel({ + validatorProvider: "project-validator-provider", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + })).toEqual({ provider: "global-title-provider", modelId: "global-title-model" }); + }); + + it("keeps global lane and default fallback order intact when project lanes are unset", () => { + expect(resolveExecutionSettingsModel({ + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + + expect(resolvePlanningSettingsModel({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + + expect(resolveValidatorSettingsModel({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + + expect(resolveTitleSummarizerSettingsModel({ + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + })).toEqual({ provider: "global-title-provider", modelId: "global-title-model" }); + }); + it("uses task overrides before settings fallbacks", () => { expect( resolveTaskExecutionModel( @@ -119,6 +217,46 @@ describe("model-resolution", () => { ).toEqual({ provider: "openai", modelId: "gpt-4.1" }); }); + it("ignores partial pairs at every precedence tier", () => { + expect( + resolveProjectDefaultModel({ + defaultProviderOverride: "openai", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + ).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); + + expect( + resolveTaskExecutionModel( + { modelProvider: "task-provider" }, + { + executionProvider: "openai", + executionModelId: "gpt-4.1", + }, + ), + ).toEqual({ provider: "openai", modelId: "gpt-4.1" }); + + expect( + resolveTaskPlanningModel( + { planningModelId: "task-planning-model" }, + { + planningGlobalProvider: "anthropic", + planningGlobalModelId: "claude-sonnet-4-5", + }, + ), + ).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); + + expect( + resolveTaskValidatorModel( + { validatorModelProvider: "validator-task-provider" }, + { + defaultProviderOverride: "google", + defaultModelIdOverride: "gemini-2.5-pro", + }, + ), + ).toEqual({ provider: "google", modelId: "gemini-2.5-pro" }); + }); + it("forces every lane to mock when testMode is true", () => { const settings = { testMode: true, diff --git a/packages/core/src/__tests__/move-task-characterization.test.ts b/packages/core/src/__tests__/move-task-characterization.test.ts index 2507c4a755..a5c3323945 100644 --- a/packages/core/src/__tests__/move-task-characterization.test.ts +++ b/packages/core/src/__tests__/move-task-characterization.test.ts @@ -9,7 +9,7 @@ // - merge-blocker on in-review → done (user source) // - userPaused set only for user-source in-progress → todo // - reopen field/step resets on in-review/done → todo|triage -// - autoMerge stamping on → in-review +// - autoMerge live-global inheritance on → in-review // - timing fields (cumulativeActiveMs / executionStartedAt) on in-progress // // It runs GREEN against the unmodified store first, then runs forever against @@ -17,11 +17,13 @@ // Any divergence between the two flag states is a U4 parity FAILURE. import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { allowsAutoMergeProcessing, resolveEffectiveAutoMerge } from "../task-merge.js"; import { VALID_TRANSITIONS } from "../types.js"; import type { Column, Task } from "../types.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; +const MOVE_SOURCES = ["user", "engine", "scheduler"] as const; // Flag states the characterization runs against. OFF is the legacy path; ON is // the workflow-resolved path. The default workflow MUST reproduce identical @@ -83,7 +85,7 @@ for (const flag of flagStates) { describe("transition allow/reject matrix (every from×to×moveSource)", () => { for (const from of ALL_COLUMNS) { for (const to of ALL_COLUMNS) { - for (const moveSource of ["user", "engine"] as const) { + for (const moveSource of MOVE_SOURCES) { const allowed = from === to || VALID_TRANSITIONS[from].includes(to); const label = `${from} → ${to} [${moveSource}] should ${allowed ? "ALLOW" : "REJECT"}`; it(label, async () => { @@ -170,15 +172,45 @@ for (const flag of flagStates) { }); }); - describe("autoMerge stamping (→ in-review)", () => { - it("stamps autoMerge from settings when undefined", async () => { - await store.updateSettings({ autoMerge: true }); - const task = await seedInColumn("in-progress"); - const result = await store.moveTask(task.id, "in-review", { - moveSource: "user", + describe("autoMerge live-global inheritance (→ in-review)", () => { + for (const moveSource of MOVE_SOURCES) { + it(`leaves undefined autoMerge to follow live settings for ${moveSource}-source moves`, async () => { + await store.updateSettings({ autoMerge: true }); + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "in-review", { + moveSource, + allowDirectInReviewMove: true, + }); + + expect(result.autoMerge).toBeUndefined(); + expect(allowsAutoMergeProcessing(result, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing(result, { autoMerge: true })).toBe(true); + expect(resolveEffectiveAutoMerge(result, { autoMerge: false })).toBe(false); + expect(resolveEffectiveAutoMerge(result, { autoMerge: true })).toBe(true); + }); + } + + it("preserves explicit task autoMerge overrides", async () => { + await store.updateSettings({ autoMerge: false }); + const explicitTrue = await seedInColumn("in-progress"); + await store.updateTask(explicitTrue.id, { autoMerge: true }); + const trueResult = await store.moveTask(explicitTrue.id, "in-review", { + moveSource: "engine", allowDirectInReviewMove: true, }); - expect(result.autoMerge).toBe(true); + expect(trueResult.autoMerge).toBe(true); + expect(allowsAutoMergeProcessing(trueResult, { autoMerge: false })).toBe(true); + + await store.updateSettings({ autoMerge: true }); + const explicitFalse = await seedInColumn("in-progress"); + await store.updateTask(explicitFalse.id, { autoMerge: false }); + const falseResult = await store.moveTask(explicitFalse.id, "in-review", { + moveSource: "scheduler", + allowDirectInReviewMove: true, + }); + expect(falseResult.autoMerge).toBe(false); + expect(resolveEffectiveAutoMerge(falseResult, { autoMerge: true })).toBe(false); + expect(resolveEffectiveAutoMerge(falseResult, { autoMerge: false })).toBe(false); }); }); diff --git a/packages/core/src/__tests__/no-op-completion-marker.test.ts b/packages/core/src/__tests__/no-op-completion-marker.test.ts new file mode 100644 index 0000000000..61f61d60e4 --- /dev/null +++ b/packages/core/src/__tests__/no-op-completion-marker.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseNoOpCompletionMarker } from "../no-op-completion-marker.js"; + +describe("parseNoOpCompletionMarker", () => { + it.each([ + ["PREMISE STALE: already implemented on HEAD", "premise-stale"], + ["NO-OP: existing behavior already satisfies the request", "no-op"], + ["NOOP: no code changes are needed", "no-op"], + ["DUPLICATE: FN-6239 covers the same requested behavior", "duplicate"], + ["REDUNDANT: FN-6239 already landed this", "redundant"], + ] as const)("recognizes leading prefix %s", (summary, kind) => { + const marker = parseNoOpCompletionMarker(summary); + + expect(marker).toMatchObject({ kind }); + expect(marker?.reason.length).toBeGreaterThan(0); + }); + + it("matches prefixes case-insensitively", () => { + expect(parseNoOpCompletionMarker("no-op: verified unchanged")?.kind).toBe("no-op"); + expect(parseNoOpCompletionMarker("duplicate: fn-6239 already covers it")).toMatchObject({ + kind: "duplicate", + canonicalId: "FN-6239", + }); + }); + + it("requires the marker at the start of the summary", () => { + expect(parseNoOpCompletionMarker("Verified existing behavior; NO-OP: no changes needed")).toBeNull(); + expect(parseNoOpCompletionMarker("The task is DUPLICATE: FN-6239")).toBeNull(); + }); + + it("returns null for empty, undefined, and ordinary prose", () => { + expect(parseNoOpCompletionMarker(undefined)).toBeNull(); + expect(parseNoOpCompletionMarker("")).toBeNull(); + expect(parseNoOpCompletionMarker("Implemented the requested behavior and verified tests.")).toBeNull(); + }); + + it("captures duplicate and redundant canonical task ids", () => { + expect(parseNoOpCompletionMarker("DUPLICATE: FN-6239 existing QuickChatFAB tests cover this")).toMatchObject({ + kind: "duplicate", + canonicalId: "FN-6239", + reason: "FN-6239 existing QuickChatFAB tests cover this", + }); + expect(parseNoOpCompletionMarker("REDUNDANT: covered by fn-42 after rebase")).toMatchObject({ + kind: "redundant", + canonicalId: "FN-42", + }); + }); + + it("does not require a canonical id for duplicate and redundant summaries", () => { + expect(parseNoOpCompletionMarker("DUPLICATE: same request already exists on HEAD")).toEqual({ + kind: "duplicate", + reason: "same request already exists on HEAD", + }); + }); +}); diff --git a/packages/core/src/__tests__/plugin-store.test.ts b/packages/core/src/__tests__/plugin-store.test.ts index 79548815e3..ec9ba82582 100644 --- a/packages/core/src/__tests__/plugin-store.test.ts +++ b/packages/core/src/__tests__/plugin-store.test.ts @@ -37,33 +37,37 @@ function seedLegacyPluginRow( }, ): void { const db = new Database(join(projectRoot, ".fusion")); - db.init(); - const now = row.updatedAt ?? new Date().toISOString(); - db.prepare(` - INSERT INTO plugins ( - id, name, version, description, author, homepage, path, - enabled, state, settings, settingsSchema, error, dependencies, - aiScanOnLoad, lastSecurityScan, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - row.id, - row.name, - row.version, - null, - null, - null, - row.path, - row.enabled ?? 1, - row.state ?? "installed", - toJson(row.settings ?? {}), - null, - row.error ?? null, - toJson([]), - 0, - null, - now, - now, - ); + try { + db.init(); + const now = row.updatedAt ?? new Date().toISOString(); + db.prepare(` + INSERT INTO plugins ( + id, name, version, description, author, homepage, path, + enabled, state, settings, settingsSchema, error, dependencies, + aiScanOnLoad, lastSecurityScan, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + row.id, + row.name, + row.version, + null, + null, + null, + row.path, + row.enabled ?? 1, + row.state ?? "installed", + toJson(row.settings ?? {}), + null, + row.error ?? null, + toJson([]), + 0, + null, + now, + now, + ); + } finally { + db.close(); + } } describe("PluginStore", () => { @@ -165,18 +169,26 @@ describe("PluginStore", () => { expect(plugins.filter((plugin) => plugin.id === "legacy-idempotent")).toHaveLength(1); const centralDb = new CentralDatabase(migrationCentral); - centralDb.init(); - const installCount = centralDb - .prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?") - .get("legacy-idempotent") as { count: number }; - expect(installCount.count).toBe(1); + try { + centralDb.init(); + const installCount = centralDb + .prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?") + .get("legacy-idempotent") as { count: number }; + expect(installCount.count).toBe(1); + } finally { + centralDb.close(); + } const localDb = new Database(join(migrationProject, ".fusion")); - localDb.init(); - const marker = localDb - .prepare("SELECT value FROM __meta WHERE key = 'pluginCentralMigrationV1'") - .get() as { value: string } | undefined; - expect(marker?.value).toBe("done"); + try { + localDb.init(); + const marker = localDb + .prepare("SELECT value FROM __meta WHERE key = 'pluginCentralMigrationV1'") + .get() as { value: string } | undefined; + expect(marker?.value).toBe("done"); + } finally { + localDb.close(); + } } finally { await rm(migrationProject, { recursive: true, force: true }); await rm(migrationCentral, { recursive: true, force: true }); diff --git a/packages/core/src/__tests__/process-supervisor.test.ts b/packages/core/src/__tests__/process-supervisor.test.ts index 5ac917391f..af980e7677 100644 --- a/packages/core/src/__tests__/process-supervisor.test.ts +++ b/packages/core/src/__tests__/process-supervisor.test.ts @@ -73,7 +73,11 @@ describe("process-supervisor", () => { const child = superviseSpawn(process.execPath, [fixturePath, "spawn-child", parentPidFile, grandchildPidFile], { stdio: "ignore", killGraceMs: 100, - maxLifetimeMs: 5_000, + // This case is specifically asserting explicit cascade teardown. Do not + // arm a lifetime timer here: matching the old 5s lifetime with the 5s + // waitFor windows made the explicit teardown race maxLifetime cleanup + // under broad-suite load. + maxLifetimeMs: Number.POSITIVE_INFINITY, }); await waitFor(() => Number.parseInt(readFileSync(grandchildPidFile, "utf8"), 10) > 0); @@ -81,8 +85,10 @@ describe("process-supervisor", () => { expect(isAlive(grandchildPid)).toBe(true); await __terminateSupervisedChildrenForTests("cascade"); - await child.waitExit(); + await expect(child.waitExit()).resolves.toEqual({ code: null, signal: "SIGTERM" }); + expect(__getProcessSupervisorStateForTests().registrySize).toBe(0); await waitFor(() => !isAlive(grandchildPid)); + expect(isAlive(grandchildPid)).toBe(false); }); it("escalates to SIGKILL after the grace period", async () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 087f71d8fc..68faeb4cdb 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(115); + it("schema version is bumped to 118", () => { + expect(db.getSchemaVersion()).toBe(118); }); }); }); diff --git a/packages/core/src/__tests__/settings-consistency.test.ts b/packages/core/src/__tests__/settings-consistency.test.ts index 7c32d2196b..dc829a494f 100644 --- a/packages/core/src/__tests__/settings-consistency.test.ts +++ b/packages/core/src/__tests__/settings-consistency.test.ts @@ -10,7 +10,10 @@ */ import { describe, it, expect } from "vitest"; import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; -import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { + BUILTIN_TRIAGE_POLICY_SETTINGS, + BUILTIN_WORKFLOW_SETTINGS, +} from "../builtin-workflow-settings.js"; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, @@ -39,18 +42,29 @@ describe("settings consistency (U5)", () => { } }); - it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => { + it("(b) every built-in declaration is either moved or workflow-native triage policy", () => { const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id)); const moved = new Set(movedKeys); + const native = new Set(BUILTIN_TRIAGE_POLICY_SETTINGS.map((s) => s.id)); // Every moved key has a declaration. for (const key of moved) { expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true); } - // Every declaration is a moved key. + // Every declaration is either a moved key or an explicitly workflow-native triage setting. for (const id of declIds) { - expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true); + expect( + moved.has(id) || native.has(id), + `declaration '${id}' must be in MOVED_SETTINGS_KEYS or BUILTIN_TRIAGE_POLICY_SETTINGS`, + ).toBe(true); } - expect(moved.size).toBe(declIds.size); + for (const id of native) { + expect(moved.has(id), `native triage setting '${id}' must not be in MOVED_SETTINGS_KEYS`).toBe(false); + expect(PROJECT_SETTINGS_KEYS as readonly string[], `native triage setting '${id}' must not be project schema key`).not.toContain(id); + expect(GLOBAL_SETTINGS_KEYS as readonly string[], `native triage setting '${id}' must not be global schema key`).not.toContain(id); + expect(Object.keys(DEFAULT_PROJECT_SETTINGS), `native triage setting '${id}' must not be project default`).not.toContain(id); + expect(Object.keys(DEFAULT_GLOBAL_SETTINGS), `native triage setting '${id}' must not be global default`).not.toContain(id); + } + expect(declIds.size).toBe(moved.size + native.size); }); it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => { diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts index 24735ea5f7..6f4e465f9c 100644 --- a/packages/core/src/__tests__/settings-migration.test.ts +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -22,6 +22,7 @@ import { SETTINGS_MIGRATION_VERSION, SETTINGS_MIGRATION_MARKER_KEY, } from "../moved-settings.js"; +import { BUILTIN_TRIAGE_POLICY_SETTINGS } from "../builtin-workflow-settings.js"; import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js"; import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; @@ -155,7 +156,16 @@ describe("settings hard-move migration (U4)", () => { expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerModelId", undefined); expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackProvider", undefined); expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackModelId", undefined); - // 26 keys after removing buildTimeoutMs plus the summarizer lane from the catalog. + // 26 keys after removing buildTimeoutMs plus the summarizer lane from the moved catalog. + expect(MOVED_SETTINGS_KEYS.length).toBe(26); + }); + + it("workflow-native triage policy settings are excluded from moved/project schemas", () => { + for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) { + expect(MOVED_SETTINGS_KEYS, `${setting.id} is workflow-native, not a moved key`).not.toContain(setting.id); + expect(PROJECT_SETTINGS_KEYS, `${setting.id} must not be a project schema key`).not.toContain(setting.id); + expect(DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).not.toHaveProperty(setting.id); + } expect(MOVED_SETTINGS_KEYS.length).toBe(26); }); diff --git a/packages/core/src/__tests__/store-archive-search.test.ts b/packages/core/src/__tests__/store-archive-search.test.ts index 0ee83b39bb..4e58664133 100644 --- a/packages/core/src/__tests__/store-archive-search.test.ts +++ b/packages/core/src/__tests__/store-archive-search.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest"; import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { AgentStore } from "../agent-store.js"; @@ -24,16 +25,47 @@ describe("TaskStore Archive and Search", () => { afterAll(harness.afterAll); describe("archiveTask", () => { - it("archives a done task (moves done → archived)", async () => { - const task = await store.createTask({ description: "Test task" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.moveTask(task.id, "done"); + it("archives tasks from every live column and emits the real source column", async () => { + const liveColumns = ["triage", "todo", "in-progress", "in-review", "done"] as const; - const archived = await store.archiveTask(task.id); + for (const column of liveColumns) { + const task = await store.createTask({ description: `Archive from ${column}` }); + if (column === "todo") { + await store.moveTask(task.id, "todo"); + } else if (column === "in-progress") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + } else if (column === "in-review") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + } else if (column === "done") { + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + } + + const events: any[] = []; + store.on("task:moved", (data) => events.push(data)); + const archived = await store.archiveTask(task.id, false); + + expect(archived.column).toBe("archived"); + expect(archived.preArchiveColumn).toBe(column); + expect(events).toHaveLength(1); + expect(events[0].from).toBe(column); + expect(events[0].to).toBe("archived"); + } + }); + + it("archives a non-done task with cleanup enabled", async () => { + const task = await store.createTask({ description: "Cleanup archive from todo" }); + await store.moveTask(task.id, "todo"); + + const archived = await store.archiveTask(task.id, true); expect(archived.column).toBe("archived"); + expect(archived.preArchiveColumn).toBe("todo"); }); it("adds log entry 'Task archived'", async () => { @@ -78,11 +110,11 @@ describe("TaskStore Archive and Search", () => { expect(fetched.column).toBe("archived"); }); - it("throws error when task is not in 'done' column", async () => { + it("throws error when task is already archived", async () => { const task = await store.createTask({ description: "Test task" }); - // Task starts in triage, not done + await store.archiveTask(task.id, false); - await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'"); + await expect(store.archiveTask(task.id)).rejects.toThrow("already archived"); }); it("updates columnMovedAt timestamp", async () => { @@ -141,13 +173,27 @@ describe("TaskStore Archive and Search", () => { }); describe("unarchiveTask", () => { - it("unarchives an archived task (moves archived → done)", async () => { - const task = await store.createTask({ description: "Test task" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); - await store.moveTask(task.id, "in-review"); - await store.moveTask(task.id, "done"); + it("unarchives to the pre-archive column, downgrading active execution columns to todo", async () => { + const todoTask = await store.createTask({ description: "Todo round trip" }); + await store.moveTask(todoTask.id, "todo"); + await store.archiveTask(todoTask.id, false); + await expect(store.unarchiveTask(todoTask.id)).resolves.toMatchObject({ column: "todo" }); + + const inProgressTask = await store.createTask({ description: "In progress round trip" }); + await store.moveTask(inProgressTask.id, "todo"); + await store.moveTask(inProgressTask.id, "in-progress"); + await store.archiveTask(inProgressTask.id, false); + await expect(store.unarchiveTask(inProgressTask.id)).resolves.toMatchObject({ column: "todo" }); + }); + + it("falls back to done for legacy archives without a pre-archive column", async () => { + const task = await store.createTask({ description: "Legacy archive" }); await store.archiveTask(task.id, false); + const dir = join(harness.rootDir(), ".fusion", "tasks", task.id); + const raw = await readFile(join(dir, "task.json"), "utf-8"); + const parsed = JSON.parse(raw); + delete parsed.preArchiveColumn; + await writeFile(join(dir, "task.json"), JSON.stringify(parsed)); const unarchived = await store.unarchiveTask(task.id); diff --git a/packages/core/src/__tests__/store-create-summarize-deferred-hook.test.ts b/packages/core/src/__tests__/store-create-summarize-deferred-hook.test.ts new file mode 100644 index 0000000000..d813088448 --- /dev/null +++ b/packages/core/src/__tests__/store-create-summarize-deferred-hook.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { setCreateFnAgent } from "../ai-engine-loader.js"; +import { TaskStore } from "../store.js"; +import { setTaskCreatedHook } from "../task-creation-hooks.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("TaskStore createTask title summarization deferred hook", () => { + const harness = createTaskStoreTestHarness(); + let store: TaskStore; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + + afterEach(async () => { + setTaskCreatedHook(undefined); + setCreateFnAgent(undefined); + await harness.afterEach(); + }); + + it("defers the task-created hook until store-managed summarize completes", async () => { + const longDescription = "a".repeat(201); + let releasePrompt!: () => void; + const promptStarted = vi.fn(); + const promptDone = new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + setCreateFnAgent(vi.fn(async () => ({ + session: { + prompt: vi.fn(async () => { + promptStarted(); + await promptDone; + }), + state: { messages: [{ role: "assistant", content: "Deferred Hook Title" }] }, + }, + }))); + const hookSpy = vi.fn(); + setTaskCreatedHook(hookSpy); + + const task = await store.createTask( + { description: longDescription, summarize: true }, + { + settings: { + autoSummarizeTitles: false, + titleSummarizerProvider: "mock", + titleSummarizerModelId: "title-model", + }, + }, + ); + + await vi.waitFor(() => expect(promptStarted).toHaveBeenCalled()); + expect(hookSpy).not.toHaveBeenCalled(); + + releasePrompt(); + await vi.waitFor(() => { + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: task.id, + title: "Deferred Hook Title", + }), + store, + ); + }); + }); +}); diff --git a/packages/core/src/__tests__/store-create.test.ts b/packages/core/src/__tests__/store-create.test.ts index f89cf5848c..3cedda47bc 100644 --- a/packages/core/src/__tests__/store-create.test.ts +++ b/packages/core/src/__tests__/store-create.test.ts @@ -501,50 +501,6 @@ describe("TaskStore", () => { expect(promptSpy).toHaveBeenCalledWith(expect.stringContaining(longDescription)); }); - it("defers the task-created hook until store-managed summarize completes", async () => { - const longDescription = "a".repeat(201); - let releasePrompt!: () => void; - const promptStarted = vi.fn(); - const promptDone = new Promise<void>((resolve) => { - releasePrompt = resolve; - }); - setCreateFnAgent(vi.fn(async () => ({ - session: { - prompt: vi.fn(async () => { - promptStarted(); - await promptDone; - }), - state: { messages: [{ role: "assistant", content: "Deferred Hook Title" }] }, - }, - }))); - const hookSpy = vi.fn(); - setTaskCreatedHook(hookSpy); - - const task = await store.createTask( - { description: longDescription, summarize: true }, - { - settings: { - autoSummarizeTitles: false, - titleSummarizerProvider: "mock", - titleSummarizerModelId: "title-model", - }, - }, - ); - - await vi.waitFor(() => expect(promptStarted).toHaveBeenCalled()); - expect(hookSpy).not.toHaveBeenCalled(); - - releasePrompt(); - await vi.waitFor(() => { - expect(hookSpy).toHaveBeenCalledWith( - expect.objectContaining({ - id: task.id, - title: "Deferred Hook Title", - }), - store, - ); - }); - }); it("should ignore malformed confirmation-prose generated titles", async () => { const mockOnSummarize = vi diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 2db85dc1d2..f4311184d8 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -18,7 +18,7 @@ describe("TaskStore merge queue", () => { beforeEach(async () => { rootDir = makeTmpDir(); globalDir = join(rootDir, ".fusion-global"); - store = new TaskStore(rootDir, globalDir); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); await store.init(); }); @@ -60,10 +60,14 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(115); + expect(store.getDatabase().getSchemaVersion()).toBe(118); }); it("migrates a legacy v88 database and preserves task rows", async () => { + store.close(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + const existingTask = await store.createTask({ description: "legacy row survives", priority: "high" }); const db = store.getDatabase(); db.exec("DROP INDEX IF EXISTS idx_mergeQueue_lease_ready"); @@ -404,10 +408,11 @@ describe("TaskStore merge queue", () => { }); it("allows exactly one worker to lease a single queued task across competing stores", async () => { - const storeA = new TaskStore(rootDir, globalDir); + store.close(); + store = new TaskStore(rootDir, globalDir); const storeB = new TaskStore(rootDir, globalDir); - extraStores.push(storeA, storeB); - await storeA.init(); + extraStores.push(storeB); + await store.init(); await storeB.init(); const taskId = await createInReviewTask(); @@ -415,7 +420,7 @@ describe("TaskStore merge queue", () => { for (let index = 0; index < 20; index += 1) { store.enqueueMergeQueue(taskId, { now: `2026-05-19T00:00:${String(index).padStart(2, "0")}.000Z` }); const [leaseA, leaseB] = await Promise.all([ - Promise.resolve().then(() => storeA.acquireMergeQueueLease("worker-a", { leaseDurationMs: 60_000, now: `2026-05-19T00:10:${String(index).padStart(2, "0")}.000Z` })), + Promise.resolve().then(() => store.acquireMergeQueueLease("worker-a", { leaseDurationMs: 60_000, now: `2026-05-19T00:10:${String(index).padStart(2, "0")}.000Z` })), Promise.resolve().then(() => storeB.acquireMergeQueueLease("worker-b", { leaseDurationMs: 60_000, now: `2026-05-19T00:10:${String(index).padStart(2, "0")}.000Z` })), ]); diff --git a/packages/core/src/__tests__/store-movement.test.ts b/packages/core/src/__tests__/store-movement.test.ts index b8885f49fd..6d2fb238a6 100644 --- a/packages/core/src/__tests__/store-movement.test.ts +++ b/packages/core/src/__tests__/store-movement.test.ts @@ -7,6 +7,7 @@ import * as projectMemory from "../project-memory.js"; import { AgentStore } from "../agent-store.js"; import { CentralDatabase } from "../central-db.js"; import { TaskStore, TaskHasDependentsError } from "../store.js"; +import { allowsAutoMergeProcessing, resolveEffectiveAutoMerge } from "../task-merge.js"; import { buildResearchDocumentKey, type Task } from "../types.js"; import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; @@ -60,36 +61,72 @@ describe("TaskStore", () => { }); - describe("moveTask — autoMerge snapshot on in-review", () => { - it("snapshots global autoMerge=true when task override is undefined", async () => { + describe("moveTask — autoMerge follows live settings on in-review", () => { + async function createInProgressTask(description: string): Promise<Task> { + const task = await store.createTask({ description }); + await store.moveTask(task.id, "todo"); + return store.moveTask(task.id, "in-progress"); + } + + it("does not snapshot global autoMerge=true when task override is undefined", async () => { await store.updateSettings({ autoMerge: true }); - const task = await store.createTask({ description: "snapshot true" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); + const task = await createInProgressTask("no snapshot true"); const moved = await store.moveTask(task.id, "in-review"); - expect(moved.autoMerge).toBe(true); + + expect(moved.autoMerge).toBeUndefined(); + expect(moved.autoMergeProvenance).toBeUndefined(); + expect(allowsAutoMergeProcessing(moved, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing(moved, { autoMerge: false })).toBe(false); }); - it("snapshots global autoMerge=false when task override is undefined", async () => { + it("does not snapshot global autoMerge=false when task override is undefined", async () => { await store.updateSettings({ autoMerge: false }); - const task = await store.createTask({ description: "snapshot false" }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); + const task = await createInProgressTask("no snapshot false"); const moved = await store.moveTask(task.id, "in-review"); - expect(moved.autoMerge).toBe(false); + + expect(moved.autoMerge).toBeUndefined(); + expect(moved.autoMergeProvenance).toBeUndefined(); + expect(resolveEffectiveAutoMerge(moved, { autoMerge: false })).toBe(false); + expect(resolveEffectiveAutoMerge(moved, { autoMerge: true })).toBe(true); }); - it("preserves explicit task autoMerge override when entering in-review", async () => { - await store.updateSettings({ autoMerge: false }); - const task = await store.createTask({ description: "explicit override" }); - await store.updateTask(task.id, { autoMerge: true }); - await store.moveTask(task.id, "todo"); - await store.moveTask(task.id, "in-progress"); + it("tracks live global toggles for undefined while preserving explicit overrides", async () => { + await store.updateSettings({ autoMerge: true }); + const inherited = await createInProgressTask("inherits live global"); + const inheritedMoved = await store.moveTask(inherited.id, "in-review"); - const moved = await store.moveTask(task.id, "in-review"); - expect(moved.autoMerge).toBe(true); + expect(inheritedMoved.autoMerge).toBeUndefined(); + expect(inheritedMoved.autoMergeProvenance).toBeUndefined(); + expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: true })).toBe(true); + + const explicitTrue = await createInProgressTask("explicit true override"); + await store.updateTask(explicitTrue.id, { autoMerge: true }); + const explicitTrueWithProvenance = await store.getTask(explicitTrue.id); + expect(explicitTrueWithProvenance?.autoMergeProvenance).toBe("user"); + const explicitTrueMoved = await store.moveTask(explicitTrue.id, "in-review"); + expect(explicitTrueMoved.autoMerge).toBe(true); + expect(explicitTrueMoved.autoMergeProvenance).toBe("user"); + expect(allowsAutoMergeProcessing(explicitTrueMoved, { autoMerge: false })).toBe(true); + expect(resolveEffectiveAutoMerge(explicitTrueMoved, { autoMerge: false })).toBe(true); + + const explicitFalse = await createInProgressTask("explicit false override"); + await store.updateTask(explicitFalse.id, { autoMerge: false }); + const explicitFalseWithProvenance = await store.getTask(explicitFalse.id); + expect(explicitFalseWithProvenance?.autoMergeProvenance).toBe("user"); + const explicitFalseMoved = await store.moveTask(explicitFalse.id, "in-review"); + expect(explicitFalseMoved.autoMerge).toBe(false); + expect(explicitFalseMoved.autoMergeProvenance).toBe("user"); + expect(allowsAutoMergeProcessing(explicitFalseMoved, { autoMerge: false })).toBe(false); + expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: false })).toBe(false); + expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: true })).toBe(false); + + await store.updateTask(explicitFalse.id, { autoMerge: null }); + const cleared = await store.getTask(explicitFalse.id); + expect(cleared?.autoMerge).toBeUndefined(); + expect(cleared?.autoMergeProvenance).toBeUndefined(); }); }); diff --git a/packages/core/src/__tests__/store-persistence.test.ts b/packages/core/src/__tests__/store-persistence.test.ts index 41c5fa579c..ef62ce6f3c 100644 --- a/packages/core/src/__tests__/store-persistence.test.ts +++ b/packages/core/src/__tests__/store-persistence.test.ts @@ -59,6 +59,28 @@ describe("TaskStore", () => { }); }); + describe("graphResumeRetryCount persistence", () => { + it("defaults to zero and round-trips updateTask values", async () => { + const task = await harness.store().createTask({ description: "Graph retry counter task" }); + + expect((await harness.store().getTask(task.id)).graphResumeRetryCount).toBe(0); + + const updated = await harness.store().updateTask(task.id, { graphResumeRetryCount: 2 }); + expect(updated.graphResumeRetryCount).toBe(2); + expect((await harness.store().getTask(task.id)).graphResumeRetryCount).toBe(2); + }); + + it("clears graphResumeRetryCount with null", async () => { + const task = await harness.store().createTask({ description: "Graph retry clear task" }); + await harness.store().updateTask(task.id, { graphResumeRetryCount: 1 }); + + const cleared = await harness.store().updateTask(task.id, { graphResumeRetryCount: null }); + + expect(cleared.graphResumeRetryCount).toBeNull(); + expect((await harness.store().getTask(task.id)).graphResumeRetryCount).toBeUndefined(); + }); + }); + describe("agent taskId sync on reassignment", () => { it("reassignment clears the old agent taskId and sets the new agent taskId", async () => { harness.store().close(); diff --git a/packages/core/src/__tests__/store-workflow-runtime.test.ts b/packages/core/src/__tests__/store-workflow-runtime.test.ts new file mode 100644 index 0000000000..0849cd6955 --- /dev/null +++ b/packages/core/src/__tests__/store-workflow-runtime.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { SCHEMA_VERSION } from "../db.js"; +import { TaskStore } from "../store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-workflow-runtime-test-")); +} + +describe("TaskStore workflow work items", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + globalDir = join(rootDir, ".fusion-global"); + store = new TaskStore(rootDir, globalDir); + await store.init(); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + async function createTaskId(): Promise<string> { + const task = await store.createTask({ description: "workflow work item test" }); + return task.id; + } + + it("creates workflow work-item tables on fresh schema", () => { + const db = store.getDatabase(); + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workflow_work_items'") + .get() as { name: string } | undefined; + const indexes = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'workflow_work_items' ORDER BY name") + .all() as Array<{ name: string }>; + + expect(table).toEqual({ name: "workflow_work_items" }); + expect(indexes.map((row) => row.name)).toEqual( + expect.arrayContaining([ + "idx_workflow_work_items_due", + "idx_workflow_work_items_leaseExpiresAt", + "idx_workflow_work_items_task_run", + ]), + ); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); + + it("upserts by run, task, node, and kind without duplicating work", async () => { + const taskId = await createTaskId(); + + const created = store.upsertWorkflowWorkItem({ + runId: "run-1", + taskId, + nodeId: "merge.node", + kind: "merge", + now: "2026-06-09T00:00:00.000Z", + }); + const updated = store.upsertWorkflowWorkItem({ + runId: "run-1", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "held", + blockedReason: "shared branch is assembling", + now: "2026-06-09T00:00:01.000Z", + }); + + expect(updated).toMatchObject({ + id: created.id, + runId: "run-1", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "held", + attempt: 0, + blockedReason: "shared branch is assembling", + }); + + const rows = store + .getDatabase() + .prepare("SELECT COUNT(*) AS count FROM workflow_work_items WHERE runId = ? AND taskId = ?") + .get("run-1", taskId) as { count: number }; + expect(rows.count).toBe(1); + }); + + it("lists due runnable and retrying work independently of task column", async () => { + const taskId = await createTaskId(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + await store.moveTask(taskId, "in-review"); + const now = "2026-06-09T00:00:00.000Z"; + + const runnable = store.upsertWorkflowWorkItem({ + runId: "run-1", + taskId, + nodeId: "plan.node", + kind: "task", + state: "runnable", + now, + }); + const futureRetry = store.upsertWorkflowWorkItem({ + runId: "run-1", + taskId, + nodeId: "retry.node", + kind: "retry", + state: "retrying", + retryAfter: "2026-06-09T00:05:00.000Z", + now, + }); + store.upsertWorkflowWorkItem({ + runId: "run-1", + taskId, + nodeId: "hold.node", + kind: "manual-hold", + state: "held", + now, + }); + + expect(store.listDueWorkflowWorkItems({ now }).map((item) => item.id)).toEqual([runnable.id]); + expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:05:00.000Z" }).map((item) => item.id)).toEqual([ + runnable.id, + futureRetry.id, + ]); + }); + + it("acquires due leases and exposes expired running leases for reclaim", async () => { + const taskId = await createTaskId(); + const item = store.upsertWorkflowWorkItem({ + runId: "run-lease", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "runnable", + now: "2026-06-09T00:00:00.000Z", + }); + + const leased = store.acquireWorkflowWorkItemLease(item.id, "worker-a", { + now: "2026-06-09T00:00:00.000Z", + leaseDurationMs: 60_000, + }); + expect(leased).toMatchObject({ + id: item.id, + state: "running", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-06-09T00:01:00.000Z", + }); + + expect( + store.acquireWorkflowWorkItemLease(item.id, "worker-b", { + now: "2026-06-09T00:00:30.000Z", + leaseDurationMs: 60_000, + }), + ).toBeNull(); + expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:00:30.000Z" })).toEqual([]); + + expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z" }).map((due) => due.id)).toEqual([item.id]); + const reclaimed = store.acquireWorkflowWorkItemLease(item.id, "worker-b", { + now: "2026-06-09T00:01:00.000Z", + leaseDurationMs: 60_000, + }); + expect(reclaimed).toMatchObject({ + id: item.id, + state: "running", + leaseOwner: "worker-b", + leaseExpiresAt: "2026-06-09T00:02:00.000Z", + }); + }); + + it("honors due-list state filters and validates lease duration", async () => { + const taskId = await createTaskId(); + const item = store.upsertWorkflowWorkItem({ + runId: "run-filter", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "runnable", + now: "2026-06-09T00:00:00.000Z", + }); + store.acquireWorkflowWorkItemLease(item.id, "worker-a", { + now: "2026-06-09T00:00:00.000Z", + leaseDurationMs: 60_000, + }); + + expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z", states: ["runnable"] })).toEqual([]); + expect(store.listDueWorkflowWorkItems({ now: "2026-06-09T00:01:00.000Z", states: ["running"] }).map((due) => due.id)).toEqual([ + item.id, + ]); + expect(() => + store.acquireWorkflowWorkItemLease(item.id, "worker-b", { + now: "2026-06-09T00:01:00.000Z", + leaseDurationMs: 0, + }), + ).toThrow("workflow work item leaseDurationMs must be > 0 (received 0)"); + }); + + it("preserves lease and retry metadata on idempotent duplicate upserts", async () => { + const taskId = await createTaskId(); + const item = store.upsertWorkflowWorkItem({ + runId: "run-idempotent", + taskId, + nodeId: "retry.node", + kind: "retry", + state: "retrying", + retryAfter: "2026-06-09T00:05:00.000Z", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-06-09T00:06:00.000Z", + lastError: "temporary failure", + now: "2026-06-09T00:00:00.000Z", + }); + + const duplicate = store.upsertWorkflowWorkItem({ + runId: "run-idempotent", + taskId, + nodeId: "retry.node", + kind: "retry", + now: "2026-06-09T00:01:00.000Z", + }); + + expect(duplicate).toMatchObject({ + id: item.id, + state: "retrying", + retryAfter: "2026-06-09T00:05:00.000Z", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-06-09T00:06:00.000Z", + lastError: "temporary failure", + updatedAt: "2026-06-09T00:01:00.000Z", + }); + }); + + it("does not requeue terminal work", async () => { + const taskId = await createTaskId(); + const item = store.upsertWorkflowWorkItem({ + runId: "run-terminal", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "runnable", + }); + + store.transitionWorkflowWorkItem(item.id, "succeeded", { now: "2026-06-09T00:00:01.000Z" }); + + expect(() => + store.upsertWorkflowWorkItem({ + runId: "run-terminal", + taskId, + nodeId: "merge.node", + kind: "merge", + state: "runnable", + }), + ).toThrow(/terminal \(succeeded\) and cannot be requeued as runnable/); + }); +}); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index af7c770510..ec92575074 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); const index = db .prepare( diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index c7e26e981f..b77c3ca2e9 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -45,16 +45,36 @@ describe("resolveEffectiveAutoMerge", () => { it("falls back to global false when task value is undefined", () => { expect(resolveEffectiveAutoMerge({ autoMerge: undefined }, { autoMerge: false })).toBe(false); }); + + it("tracks live global toggles while task value remains undefined", () => { + const task = { autoMerge: undefined }; + expect(resolveEffectiveAutoMerge(task, { autoMerge: true })).toBe(true); + expect(resolveEffectiveAutoMerge(task, { autoMerge: false })).toBe(false); + expect(resolveEffectiveAutoMerge(task, { autoMerge: true })).toBe(true); + }); + + it("treats provenance as metadata and resolves solely from the value", () => { + expect(resolveEffectiveAutoMerge({ autoMerge: true, autoMergeProvenance: "legacy-stamp" }, { autoMerge: false })).toBe(true); + expect(resolveEffectiveAutoMerge({ autoMerge: false, autoMergeProvenance: "user" }, { autoMerge: true })).toBe(false); + expect(resolveEffectiveAutoMerge({ autoMerge: undefined, autoMergeProvenance: undefined }, { autoMerge: true })).toBe(true); + }); }); describe("allowsAutoMergeProcessing", () => { - it("lets explicit per-task true through when the global setting is off (FN per-task override)", () => { - expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true); + it("lets explicit per-task true with user provenance through when the global setting is off", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true, autoMergeProvenance: "user" }, { autoMerge: false })).toBe(true); + }); + + it("still lets legacy-stamp true through at the gate so reconcile, not the gate, owns cleanup", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true, autoMergeProvenance: "legacy-stamp" }, { autoMerge: false })).toBe(true); }); it("blocks tasks without an explicit override when the global setting is off", () => { - expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false); - expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false); + const task = { autoMerge: undefined }; + expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing(task, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: false, autoMergeProvenance: "user" }, { autoMerge: false })).toBe(false); }); it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { diff --git a/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts new file mode 100644 index 0000000000..4eaa544fbf --- /dev/null +++ b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts @@ -0,0 +1,132 @@ +import { existsSync, mkdtempSync, mkdirSync, rmSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { __fusionTmpdirRedirectTestHooks } from "../__test-utils__/vitest-setup"; + +const createdPaths: string[] = []; + +function remember(path: string): string { + createdPaths.push(path); + return path; +} + +function expectUnderWorkerRoot(path: string): void { + const workerRoot = process.env.FUSION_TEST_WORKER_ROOT; + expect(workerRoot).toBeTruthy(); + expect(path.startsWith(`${workerRoot}${sep}`)).toBe(true); + expect(dirname(path)).toBe(join(workerRoot!, `redir-${process.pid}`)); +} + +function rememberDir(path: string): string { + mkdirSync(path, { recursive: true }); + return remember(path); +} + +afterEach(() => { + for (const path of createdPaths.splice(0).reverse()) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("vitest setup tmpdir mkdtemp redirect", () => { + it("redirects sync mkdtemp prefixes rooted directly at the OS temp dir", () => { + const path = remember(mkdtempSync(join(tmpdir(), "fn-redirect-sync-"))); + + expectUnderWorkerRoot(path); + }); + + it("redirects async mkdtemp prefixes rooted directly at the OS temp dir", async () => { + const path = remember(await mkdtemp(join(tmpdir(), "fn-redirect-async-"))); + + expectUnderWorkerRoot(path); + }); + + it("redirects the realpath spelling of the OS temp dir when it differs", () => { + const realTmpdir = realpathSync(tmpdir()); + if (realTmpdir === tmpdir()) { + expect(realTmpdir).toBe(tmpdir()); + return; + } + + const path = remember(mkdtempSync(join(realTmpdir, "fn-redirect-realpath-"))); + + expectUnderWorkerRoot(path); + }); + + it("leaves nested temp-root prefixes unchanged", () => { + const parent = remember(join(tmpdir(), `fn-redirect-parent-${process.pid}-${Date.now()}`)); + mkdirSync(parent, { recursive: true }); + + const path = remember(mkdtempSync(join(parent, "nested-"))); + + expect(path.startsWith(`${parent}${sep}`)).toBe(true); + }); + + it("leaves non-string prefixes untouched", () => { + const prefix = Buffer.from(join(tmpdir(), "fn-redirect-buffer-")); + + const path = remember(mkdtempSync(prefix)); + + expect(path.startsWith(`${tmpdir()}${sep}`)).toBe(true); + }); + + it("recreates the cached redirect sink after deletion for sync and async mkdtemp", async () => { + const sink = __fusionTmpdirRedirectTestHooks.sinkForPid(process.pid); + const first = remember(mkdtempSync(join(tmpdir(), "fn-redirect-prime-"))); + expectUnderWorkerRoot(first); + expect(existsSync(sink)).toBe(true); + + rmSync(sink, { recursive: true, force: true }); + expect(existsSync(sink)).toBe(false); + + const syncPath = remember(mkdtempSync(join(tmpdir(), "fn-redirect-recreated-sync-"))); + expect(existsSync(syncPath)).toBe(true); + expect(dirname(syncPath)).toBe(sink); + expect(existsSync(sink)).toBe(true); + + rmSync(sink, { recursive: true, force: true }); + expect(existsSync(sink)).toBe(false); + + const asyncPath = remember(await mkdtemp(join(tmpdir(), "fn-redirect-recreated-async-"))); + expect(existsSync(asyncPath)).toBe(true); + expect(dirname(asyncPath)).toBe(sink); + expect(existsSync(sink)).toBe(true); + }); + + it("sweeps only dead redirect sinks and preserves current or alive pids", () => { + const { registryPath, resetSweepForTest, sinkForPid, sweepDeadTmpdirRedirectSinks } = __fusionTmpdirRedirectTestHooks; + const currentSink = rememberDir(sinkForPid(process.pid)); + const liveForeignPid = process.ppid; + const liveForeignSink = rememberDir(sinkForPid(liveForeignPid)); + const deadPid = 99_999_999; + const deadSink = rememberDir(sinkForPid(deadPid)); + + writeFileSync(registryPath, `${process.pid}\n${process.pid}\n${liveForeignPid}\n${deadPid}\n`); + resetSweepForTest(); + sweepDeadTmpdirRedirectSinks(); + + expect(existsSync(currentSink)).toBe(true); + expect(existsSync(liveForeignSink)).toBe(true); + expect(existsSync(deadSink)).toBe(false); + + const fallbackDeadSink = rememberDir(sinkForPid(deadPid - 1)); + rmSync(registryPath, { force: true }); + resetSweepForTest(); + sweepDeadTmpdirRedirectSinks(); + + expect(existsSync(currentSink)).toBe(true); + expect(existsSync(liveForeignSink)).toBe(true); + expect(existsSync(fallbackDeadSink)).toBe(false); + + const emptyRegistryDeadSink = rememberDir(sinkForPid(deadPid - 2)); + writeFileSync(registryPath, ""); + resetSweepForTest(); + sweepDeadTmpdirRedirectSinks(); + + expect(existsSync(currentSink)).toBe(true); + expect(existsSync(liveForeignSink)).toBe(true); + expect(existsSync(emptyRegistryDeadSink)).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts b/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts new file mode 100644 index 0000000000..d03fd35a12 --- /dev/null +++ b/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import setup, { + __setWorkerRootRmSyncForTests, + __setWorkerRootSleepMsSyncForTests, +} from "../__test-utils__/vitest-teardown"; + +const createdPaths: string[] = []; +const originalWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT; + +function remember(path: string): string { + createdPaths.push(path); + return path; +} + +function makeWorkerChild(root: string, label: string): void { + const workerDir = join(root, `w-${process.pid}-${label}`); + mkdirSync(workerDir, { recursive: true }); + writeFileSync(join(workerDir, "file.txt"), "worker temp payload"); +} + +function restoreWorkerRootEnv(): void { + if (originalWorkerRoot === undefined) { + delete process.env.FUSION_TEST_WORKER_ROOT; + } else { + process.env.FUSION_TEST_WORKER_ROOT = originalWorkerRoot; + } +} + +afterEach(() => { + __setWorkerRootRmSyncForTests(rmSync); + __setWorkerRootSleepMsSyncForTests(() => {}); + restoreWorkerRootEnv(); + for (const path of createdPaths.splice(0).reverse()) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("vitest global teardown worker-root cleanup", () => { + it("removes the per-invocation worker root on the clean path", async () => { + const teardown = setup(); + const workerRoot = remember(process.env.FUSION_TEST_WORKER_ROOT!); + makeWorkerChild(workerRoot, "clean"); + + await teardown(); + + expect(existsSync(workerRoot)).toBe(false); + }); + + it("retries an EBUSY worker-root removal and removes the root", async () => { + const teardown = setup(); + const workerRoot = remember(process.env.FUSION_TEST_WORKER_ROOT!); + makeWorkerChild(workerRoot, "busy"); + let attempts = 0; + const sleeps: number[] = []; + + __setWorkerRootRmSyncForTests((path, options) => { + attempts++; + if (attempts === 1) { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + rmSync(path, options); + }); + __setWorkerRootSleepMsSyncForTests((ms) => { + sleeps.push(ms); + }); + + await teardown(); + + expect(attempts).toBe(2); + expect(sleeps).toEqual([75]); + expect(existsSync(workerRoot)).toBe(false); + }); + + it("tolerates ENOENT when the worker root is already gone", async () => { + const teardown = setup(); + const workerRoot = remember(process.env.FUSION_TEST_WORKER_ROOT!); + makeWorkerChild(workerRoot, "enoent"); + rmSync(workerRoot, { recursive: true, force: true }); + + await teardown(); + + expect(existsSync(workerRoot)).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/workflow-compiler.test.ts b/packages/core/src/__tests__/workflow-compiler.test.ts index accf4e3bd8..21086ebfcb 100644 --- a/packages/core/src/__tests__/workflow-compiler.test.ts +++ b/packages/core/src/__tests__/workflow-compiler.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect } from "vitest"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js"; import { compileWorkflowToSteps, + MERGE_REGION_NODE_KINDS, validateLinearity, WorkflowCompileError, + WORKFLOW_INTERPRETER_DEFERRED_SUFFIX, } from "../workflow-compiler.js"; import { serializeWorkflowIr, parseWorkflowIr } from "../workflow-ir.js"; import type { WorkflowIr } from "../workflow-ir-types.js"; @@ -116,10 +120,76 @@ describe("compileWorkflowToSteps (U2)", () => { { from: "b", to: "end", condition: "success" }, ], }; + const err = validateLinearity(ir); + expect(err).toBeInstanceOf(WorkflowCompileError); + expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); expect(() => compileWorkflowToSteps(ir)).toThrow(WorkflowCompileError); expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i); }); + it("validates builtin workflow linearity while preserving stepwise interpreter deferral", () => { + expect(validateLinearity(BUILTIN_CODING_WORKFLOW_IR)).toBeNull(); + + const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR); + expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError); + expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); + }); + + it("compiles the builtin coding workflow without merge-region steps", () => { + const steps = compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR); + const mergeRegionNodeIds = BUILTIN_CODING_WORKFLOW_IR.nodes + .filter((node) => MERGE_REGION_NODE_KINDS.has(node.kind)) + .map((node) => node.id); + + expect(steps.map((step) => step.name)).toEqual([]); + expect(mergeRegionNodeIds).toEqual( + expect.arrayContaining([ + "merge-gate", + "merge-retry", + "merge-manual-hold", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "recovery-router", + ]), + ); + expect(steps.some((step) => mergeRegionNodeIds.includes(step.name))).toBe(false); + }); + + it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => { + // Mirrors the builtin:coding shape: review → merge-gate fans out into the + // engine-owned merge/branch-group/retry subgraph. These primitive kinds are a + // terminal boundary, so the graph still compiles to its pre-merge step list + // instead of failing as interpreter-only. + const ir: WorkflowIr = { + version: "v1", + name: "merge-region", + nodes: [ + { id: "start", kind: "start" }, + { id: "spec", kind: "prompt", config: { name: "Spec", prompt: "spec" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "merge-gate", kind: "merge-gate", config: { gate: "auto-merge" } }, + { id: "merge-attempt", kind: "merge-attempt" }, + { id: "merge-hold", kind: "manual-merge-hold" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "spec", condition: "success" }, + { from: "spec", to: "review", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, + { from: "review", to: "end", condition: "failure" }, + { from: "merge-gate", to: "merge-attempt", condition: "outcome:auto-on" }, + { from: "merge-gate", to: "merge-hold", condition: "outcome:auto-off" }, + { from: "merge-attempt", to: "end", condition: "success" }, + { from: "merge-hold", to: "merge-attempt", condition: "success" }, + ], + }; + expect(validateLinearity(parseWorkflowIr(ir))).toBeNull(); + const steps = compileWorkflowToSteps(ir); + // Only the pre-merge user node lowers; the merge primitives emit no steps. + expect(steps.map((s) => s.name)).toEqual(["Spec"]); + }); + it("rejects a graph missing the start/end nodes via parse", () => { const ir = { version: "v1", name: "x", nodes: [{ id: "p", kind: "prompt" }], edges: [] } as WorkflowIr; expect(() => compileWorkflowToSteps(ir)).toThrow(); @@ -143,6 +213,8 @@ describe("compileWorkflowToSteps (U2)", () => { }; const err = validateLinearity(ir); expect(err).toBeInstanceOf(WorkflowCompileError); + expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); + expect(err?.message).toMatch(/disconnected nodes/); }); it("rejects seams that are out of the planning -> execute -> workflow-step -> review -> merge order", () => { diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts index a12300681c..722cf6d1fd 100644 --- a/packages/core/src/__tests__/workflow-ir-settings.test.ts +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -7,7 +7,10 @@ import { } from "../workflow-ir.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { getBuiltinWorkflow } from "../builtin-workflows.js"; -import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { + BUILTIN_MOVED_WORKFLOW_SETTINGS, + BUILTIN_WORKFLOW_SETTINGS, +} from "../builtin-workflow-settings.js"; import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; import type { WorkflowIrV2, @@ -208,7 +211,7 @@ describe("parseWorkflowIr — workflow settings declarations (U1)", () => { }); describe("built-in workflow settings parity anchor (U1, R4)", () => { - it("the built-in coding workflow declares the full moved-key catalog", () => { + it("the built-in coding workflow declares the full workflow settings catalog", () => { const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id)); for (const setting of BUILTIN_WORKFLOW_SETTINGS) { @@ -224,7 +227,7 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => { // Post-U4 hard-move: every catalog key has been REMOVED from // DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but // drops the default literal), so the legacy object no longer carries them. - for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + for (const setting of BUILTIN_MOVED_WORKFLOW_SETTINGS) { expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false); } // The declaration defaults are now the single source of truth; pin the legacy @@ -249,7 +252,7 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => { reflectionEnabled: false, // Per-phase model lanes have undefined legacy defaults → declaration omits default. }; - for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + for (const setting of BUILTIN_MOVED_WORKFLOW_SETTINGS) { if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) { expect(setting.default).toStrictEqual(expectedDefaults[setting.id]); } else { diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 599b06c111..81e4c39dba 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -75,6 +75,51 @@ describe("parseWorkflowIr — v2 columns & placement", () => { }); }); +describe("parseWorkflowIr — optionalSteps", () => { + const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })); + const base = (): WorkflowIrV2 => v2( + columns, + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + + it("parses and serializes optionalSteps deterministically", () => { + const ir: WorkflowIrV2 = { + ...base(), + optionalSteps: [ + { templateId: "browser-verification" }, + { templateId: "plugin:example:step", defaultOn: true }, + ], + }; + + const parsed = parseWorkflowIr(ir); + expect(parsed).toEqual(ir); + expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir); + }); + + it("rejects malformed optionalSteps", () => { + expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); + expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/); + expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/); + }); + + it("upgrades v1 graphs without optionalSteps", () => { + const parsed = parseWorkflowIr({ + version: "v1", + name: "legacy", + nodes: startEnd, + edges: [{ from: "start", to: "end" }], + }); + expect(parsed.version).toBe("v2"); + if (parsed.version !== "v2") throw new Error("expected v2"); + expect(parsed.optionalSteps).toBeUndefined(); + }); +}); + describe("parseWorkflowIr — v1 upgrade", () => { const v1: WorkflowIrV1 = { version: "v1", diff --git a/packages/core/src/__tests__/workflow-optional-steps.test.ts b/packages/core/src/__tests__/workflow-optional-steps.test.ts new file mode 100644 index 0000000000..93bb6661bf --- /dev/null +++ b/packages/core/src/__tests__/workflow-optional-steps.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { resolveWorkflowOptionalSteps } from "../workflow-optional-steps.js"; +import type { WorkflowIr, WorkflowIrV2 } from "../workflow-ir-types.js"; + +const v1: WorkflowIr = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], +}; + +function v2(optionalSteps?: WorkflowIrV2["optionalSteps"]): WorkflowIrV2 { + return { + version: "v2", + name: "optional", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + optionalSteps, + }; +} + +describe("resolveWorkflowOptionalSteps", () => { + it("resolves the builtin coding browser verification optional step", () => { + expect(resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)).toEqual([ + { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge", + defaultOn: false, + }, + ]); + }); + + it("skips unknown template ids", () => { + expect( + resolveWorkflowOptionalSteps(v2([ + { templateId: "missing" }, + { templateId: "browser-verification" }, + ])), + ).toHaveLength(1); + }); + + it("returns an empty array for v1 and v2 workflows without optional steps", () => { + expect(resolveWorkflowOptionalSteps(v1)).toEqual([]); + expect(resolveWorkflowOptionalSteps(v2())).toEqual([]); + }); + + it("preserves declaration order and resolves plugin templates", () => { + const result = resolveWorkflowOptionalSteps( + v2([ + { templateId: "plugin:demo:first", defaultOn: true }, + { templateId: "browser-verification" }, + ]), + [ + { + id: "plugin:demo:first", + name: "Plugin First", + description: "Plugin optional verification", + prompt: "Run plugin verification", + category: "Quality", + icon: "plug", + phase: "post-merge", + }, + ], + ); + + expect(result.map((step) => step.templateId)).toEqual([ + "plugin:demo:first", + "browser-verification", + ]); + expect(result[0]).toMatchObject({ + name: "Plugin First", + icon: "plug", + phase: "post-merge", + defaultOn: true, + }); + }); +}); diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 739895c07b..65bb97aed4 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -7,11 +7,10 @@ * - Additional role variants (senior-engineer, strict-reviewer, concise-triage) * - A resolver function that merges custom templates from project settings with built-ins * - * NOTE: The built-in prompt texts are derived from the engine's hardcoded prompts - * (EXECUTOR_SYSTEM_PROMPT, TRIAGE_SYSTEM_PROMPT, REVIEWER_SYSTEM_PROMPT, and the - * merger prompt). They should be kept in sync when the engine prompts change. - * Since @fusion/core cannot import @fusion/engine (circular dependency), these - * are maintained as inline strings. + * NOTE: Built-in prompt texts that feed workflow seams live here as the canonical + * source for @fusion/core and @fusion/engine. Engine code should resolve triage + * and reviewer built-ins through workflow IR seam prompts instead of carrying + * duplicate policy constants. * * @module agent-prompts */ @@ -19,7 +18,7 @@ import type { AgentCapability, AgentPromptTemplate, AgentPromptsConfig } from "./types.js"; // --------------------------------------------------------------------------- -// Built-in prompt text (derived from engine constants — keep in sync) +// Built-in prompt text (canonical source for workflow seam prompts) // --------------------------------------------------------------------------- const EXECUTOR_PROMPT_TEXT = `You are a task execution agent for "fn", an AI-orchestrated task board. @@ -205,9 +204,229 @@ The tool prevents your session from being killed by the inactivity watchdog duri - If you need to run \`pnpm install\` (e.g. you added a new package), use \`fn_run_verification\` with \`scope: "workspace"\` and \`timeoutSec: 600\`. - If a verification command times out, do NOT blindly retry — investigate. Check for hung subprocesses, infinite test loops, or tests waiting on missing dependencies. Use \`node_modules/.modules.yaml\` presence to confirm bootstrap.`; +const FAST_TRIAGE_PROMPT_TEXT = `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} + +## Surface Enumeration + +{Required for bug-fix tasks and UI-affordance add/remove tasks (adding, removing, or restructuring icons, buttons, chevrons/arrows, toggles, badges, menu entries, click targets): a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. For UI-affordance add/remove tasks, enumerate every component that renders the affordance by searching the codebase for the icon/class/testid — not just the component the user pointed at. Explicitly check for leftover shells after removal (empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels) across both desktop and mobile breakpoints. Use the canonical checklist in docs/testing.md as the starting point.} + +## Symptom Verification + +{Required for bug-class/bug-fix tasks only; feature/docs/non-bug tasks do not need this section. Use the exact heading \`## Symptom Verification\` and include: (1) **Original symptom** — what the user/issue reported was broken; (2) **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure; (3) **Assertion it is gone** — the executor's final verification must reproduce that original failure condition and assert it no longer occurs via a real automated test. Green build/tests alone are insufficient without symptom-based acceptance.} + +## 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 + +> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed +> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps +> that are genuinely independent of their immediate predecessor; an unannotated step is +> assumed to depend on the one before it (fully sequential). Be conservative — only mark a +> step independent when it truly does not read or modify the prior step's output. + +### 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, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states) + +For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: +- [ ] Providers / bridges / execution paths touched by the invariant +- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior +- [ ] Empty / undefined / duplicate / populated data states +- [ ] Shared hooks / components / modules / helpers reusing the logic +- [ ] Every component that renders the affordance (search the codebase for the icon/class/testid, not just the one the user pointed at) +- [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden + +For bug-class/bug-fix tasks, add and fill in the exact \`## Symptom Verification\` section: +- [ ] **Original symptom** — what the user/issue reported was broken +- [ ] **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure +- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test; green build/tests alone are insufficient + +**Artifacts:** +- \`path/to/file\` (new | modified) + +### Step {N-1}: Testing & Verification + +> ZERO failures allowed for checks required by this task's quality gates. Run impacted/package-scoped verification first; run workspace-wide suites only when the task or workflow explicitly requires them, or during final integration after impacted checks pass. +> 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 impacted tests +- [ ] 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 — <short summary>\` (the \`<short summary>\` is required — use a concrete 5–10 word description) +- **Bug fixes:** \`fix({ID}): description\` (short, concrete summary required) +- **Tests:** \`test({ID}): description\` (short, concrete summary required) + +Good examples: +- \`feat(FN-1234): complete Step 2 — add retry guard for workflow step timeouts\` +- \`test(FN-1234): add regression tests for paused-session cleanup\` + +Bad example: +- \`feat(FN-1234): complete Step 2\` + +## 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 +- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE. +- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal. +- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123) +- For bug-class/bug-fix tasks, the spec MUST include a \`## Symptom Verification\` section with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**. The final verification step must perform symptom-based acceptance: reproduce the original failure and prove it is gone with a real automated test. Green build/tests alone are insufficient. Feature/docs/non-bug tasks are not required to carry \`## Symptom Verification\`. +- 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, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). +For any likely match in \`done\` or \`archived\`, call \`fn_task_get\` to inspect details before deciding. +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. + +## Decision-only task flag (noCommitsExpected) +When ALL of the following are true, include this metadata line in the header block after Size: + +- Add this exact line: **No commits expected:** true + +Set it only when all of these conditions hold: +- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report", OR is an operational routing/coordination task whose only outcome is assigning/routing existing work or recording an intentional no-route/no-owner decision +- Acceptance criteria are strictly observational (record findings, routing evidence, no-route/no-owner state, log a decision, update task log/docs) with no required code/config/file mutations +- Task description explicitly says things like "no code changes expected", "no source files expected", "no product-source changes", or "the deliverable is the recorded decision" + +Anti-heuristics (bias to false-negative when ambiguous): +- SET: Decide whether FN-XYZ needs a fix +- SET: Assign ready implementation task to active owner, or record no-route state (no source files expected) +- LEAVE UNSET: Investigate FN-XYZ +- LEAVE UNSET: Investigate FN-XYZ and fix if needed +- LEAVE UNSET: Investigate and fix routing if needed + +If an executor later proves an ordinary implementation task is already satisfied on HEAD, it may close without fabricating a commit by calling \`fn_task_done\` with a leading verified no-op/duplicate sentinel summary: \`PREMISE STALE:\`, \`NO-OP:\`, \`NOOP:\`, \`DUPLICATE: FN-NNNN ...\`, or \`REDUNDANT:\`. This does not weaken ordinary tasks: zero-commit completions without one of these leading sentinels still fail the no-commits invariant. + +## 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. + +## Workflow Routing +Call \`fn_workflow_list\` and use workflow descriptions as the routing signal. For investigation/audit/research, operational routing/coordination, or decision-only tasks that meet the no-commits criteria above, include \`**No commits expected:** true\` in the PROMPT.md header and prefer \`builtin:quick-fix\` or a custom investigation workflow; standard coding tasks can stay on the default \`builtin:coding\`. Use \`fn_workflow_select\` for the current task or pass \`workflow_id\` to \`fn_task_create\` for subtasks. + +## 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 \`<rootDir>/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, logs). +- Task DB truth lives at \`<rootDir>/.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. + +Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk. + +## Output +Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` to confirm.`; + const TRIAGE_PROMPT_TEXT = `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) @@ -237,7 +456,11 @@ Follow this structure exactly: ## Surface Enumeration -{Required for bug-fix tasks: a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. Use the canonical checklist in docs/testing.md as the starting point.} +{Required for bug-fix tasks and UI-affordance add/remove tasks (adding, removing, or restructuring icons, buttons, chevrons/arrows, toggles, badges, menu entries, click targets): a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. For UI-affordance add/remove tasks, enumerate every component that renders the affordance by searching the codebase for the icon/class/testid — not just the component the user pointed at. Explicitly check for leftover shells after removal (empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels) across both desktop and mobile breakpoints. Use the canonical checklist in docs/testing.md as the starting point.} + +## Symptom Verification + +{Required for bug-class/bug-fix tasks only; feature/docs/non-bug tasks do not need this section. Use the exact heading \`## Symptom Verification\` and include: (1) **Original symptom** — what the user/issue reported was broken; (2) **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure; (3) **Assertion it is gone** — the executor's final verification must reproduce that original failure condition and assert it no longer occurs via a real automated test. Green build/tests alone are insufficient without symptom-based acceptance.} ## Dependencies @@ -258,6 +481,12 @@ Follow this structure exactly: ## Steps +> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed +> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps +> that are genuinely independent of their immediate predecessor; an unannotated step is +> assumed to depend on the one before it (fully sequential). Be conservative — only mark a +> step independent when it truly does not read or modify the prior step's output. + ### Step 0: Preflight - [ ] Required files and paths exist @@ -269,11 +498,18 @@ Follow this structure exactly: - [ ] {Specific, verifiable outcome} - [ ] Run targeted tests for changed files, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states) -For bug-fix tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: +For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: - [ ] Providers / bridges / execution paths touched by the invariant - [ ] Desktop + mobile breakpoints / platforms that exercise the behavior - [ ] Empty / undefined / duplicate / populated data states - [ ] Shared hooks / components / modules / helpers reusing the logic +- [ ] Every component that renders the affordance (search the codebase for the icon/class/testid, not just the one the user pointed at) +- [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden + +For bug-class/bug-fix tasks, add and fill in the exact \`## Symptom Verification\` section: +- [ ] **Original symptom** — what the user/issue reported was broken +- [ ] **Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure +- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test; green build/tests alone are insufficient **Artifacts:** - \`path/to/file\` (new | modified) @@ -315,9 +551,16 @@ For bug-fix tasks, paste and fill in this checklist in the \`## Surface Enumerat 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\` +- **Step completion:** \`feat({ID}): complete Step N — <short summary>\` (the \`<short summary>\` is required — use a concrete 5–10 word description) +- **Bug fixes:** \`fix({ID}): description\` (short, concrete summary required) +- **Tests:** \`test({ID}): description\` (short, concrete summary required) + +Good examples: +- \`feat(FN-1234): complete Step 2 — add retry guard for workflow step timeouts\` +- \`test(FN-1234): add regression tests for paused-session cleanup\` + +Bad example: +- \`feat(FN-1234): complete Step 2\` ## Do NOT @@ -342,16 +585,18 @@ files with assertions that run via a test runner. Typechecks and builds are NOT tests. Manual verification is NOT a test. - Each implementation step should include writing tests for the code being changed -- For bug fixes, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix spec as a blocking REVISE. -- For bug fixes, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers. -- For bug fixes, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states — not just the reported repro (see FN-5787/FN-5789/FN-5803 and FN-5751) +- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE. +- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal. +- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123) +- For bug-class/bug-fix tasks, the spec MUST include a \`## Symptom Verification\` section with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**. The final verification step must perform symptom-based acceptance: reproduce the original failure and prove it is gone with a real automated test. Green build/tests alone are insufficient. Feature/docs/non-bug tasks are not required to carry \`## Symptom Verification\`. - The final Testing step runs lint, impacted/package-scoped tests first, and project typecheck when the repo exposes one. Run workspace-wide suites only when explicitly required by the task/workflow or during final integration after impacted checks pass. - 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. +Before writing a spec, first call \`fn_task_list\` to see active tasks, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). +For any likely match in \`done\` or \`archived\`, call \`fn_task_get\` to inspect details before deciding. 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}\` @@ -370,33 +615,33 @@ When the task includes \`breakIntoSubtasks: true\`, first decide whether it shou - 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, proactively evaluate whether splitting into 2-5 child tasks would improve execution quality and reliability. +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. -**Strongly recommend splitting when ANY of these apply:** -- The task will require MORE THAN 7 implementation steps -- The task affects MORE THAN 3 different packages/modules +**Consider splitting when ANY of these apply:** +- The task will require MORE THAN {{triageSubtaskStepThreshold}} implementation steps +- The task affects MORE THAN {{triageSubtaskPackageThreshold}} 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 1-2 hours to complete -- The task has multiple independent deliverables that could be developed in parallel - -**ANTI-PATTERN:** Avoid writing single tasks with 10+ steps. If you find yourself planning more than 7 steps, STOP and create 2-5 child tasks instead. +- 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 generally NOT be split because the overhead usually outweighs the benefit -- Only keep a task as one unit if it genuinely has 5 or fewer focused steps with a clear scope +- 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 **Broad-scope decomposition signals:** -- Size L tasks, especially when the planned step count would reach 9 or more. -- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired). -- Tasks whose declared \`## File Scope\` would list 20 or more entries. -- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying. +- Size L tasks, especially when the planned step count would reach {{triageSubtaskLargeStepSignal}} or more. +- Plans whose implementation-step count would reach {{triageSubtaskAdditiveStepSignal}} or more (additive signal — counts even when the surrounding step-count threshold above has not yet fired). +- Tasks whose declared \`## File Scope\` would list {{triageSubtaskFileScopeThreshold}} or more entries. +- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above {{triageSubtaskRemediationBatchThreshold}} items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying. - When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph. ## Triage tools You have these extra tools during triage: - \`fn_task_list\` — list existing active tasks +- \`fn_task_search\` — keyword search across tasks, including done and archived 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") @@ -404,14 +649,41 @@ You have these extra tools during triage: 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 + +## Decision-only task flag (noCommitsExpected) +When ALL of the following are true, include this metadata line in the header block after Size/Review Level: + +- Add this exact line: **No commits expected:** true + +Set it only when all of these conditions hold: +- Title/mission starts with decision verbs like {{triageNoCommitsDecisionVerbs}}, OR is an operational routing/coordination task whose only outcome is assigning/routing existing work or recording an intentional no-route/no-owner decision +- Acceptance criteria are strictly observational (record findings, routing evidence, no-route/no-owner state, log a decision, update task log/docs) with no required code/config/file mutations +- Task description explicitly says things like "no code changes expected", "no source files expected", "no product-source changes", or "the deliverable is the recorded decision" + +Anti-heuristics (bias to false-negative when ambiguous): +- SET: Decide whether FN-XYZ needs a fix +- SET: Assign ready implementation task to active owner, or record no-route state (no source files expected) +- LEAVE UNSET: Investigate FN-XYZ +- LEAVE UNSET: Investigate FN-XYZ and fix if needed +- LEAVE UNSET: Investigate and fix routing if needed + +If an executor later proves an ordinary implementation task is already satisfied on HEAD, it may close without fabricating a commit by calling \`fn_task_done\` with a leading verified no-op/duplicate sentinel summary: \`PREMISE STALE:\`, \`NO-OP:\`, \`NOOP:\`, \`DUPLICATE: FN-NNNN ...\`, or \`REDUNDANT:\`. This does not weaken ordinary tasks: zero-commit completions without one of these leading sentinels still fail the no-commits invariant. + ## 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+) +- Size assessment: S (<{{triageSizeSmallMaxHours}}h), M ({{triageSizeSmallMaxHours}}-{{triageSizeMediumMaxHours}}h), L ({{triageSizeMediumMaxHours}}-{{triageSizeLargeMaxHours}}h). Split if XL ({{triageSizeLargeMaxHours}}h+) - 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 @@ -421,6 +693,14 @@ commands, use those EXACT commands in the testing/verification steps and anywher the spec references running tests or builds. Do NOT guess or infer commands from package.json when explicit commands are provided. +## Workflow Routing +- Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal. +- For investigation, audit, research, operational routing/coordination, or decision-only tasks that produce no code/config/file changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow. +- For decision-only tasks ({{triageNoCommitsDecisionVerbs}}), prefer \`{{triageDecisionOnlyWorkflowId}}\` or a custom investigation workflow when one is available. +- For standard coding tasks, \`{{triageDefaultWorkflowId}}\` is the default and is usually appropriate. +- Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks. +- Match the task nature to the workflow description; descriptions are authoritative for routing decisions. + ## Spec Review After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent quality review. @@ -431,43 +711,46 @@ After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent qua 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. + +Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk. + ## Output Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` for review. -## Frontend UX Criteria Injection +## Task Artifact Location for Forensic / Reconciliation Tasks -<!-- UX criteria mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts — keep them aligned. --> +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**: \`<rootDir>/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs). +- Authoritative task DB rows live at the **project root** SQLite file: \`<rootDir>/.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. -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.`; +<!-- Frontend UX criteria are applied deterministically by packages/core/src/frontend-ux-policy.ts and mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts. -->`;; +// FN-6235: single source for the built-in reviewer policy; the engine REVIEWER_SYSTEM_PROMPT duplicate was removed. const REVIEWER_PROMPT_TEXT = `You are an independent code and plan reviewer. +## Your Role +You are an objective quality gate for plans, code, and specs. +You are neither the implementor's advocate nor adversary: your job is evidence-based assessment that protects delivery quality. + You provide quality assessment for task implementations. You have full read access to the codebase and can run commands to inspect code. +## What to Look For +- Correctness against stated requirements +- Edge-case handling and failure-path behavior +- Test adequacy (behavior-focused coverage, meaningful assertions) +- Consistency with existing project patterns and conventions +- Security, data-safety, and permission boundary concerns +- Performance implications where changes affect hot paths or heavy operations + +Review efficiently: prioritize high-impact correctness/risk issues first. Do not spend blocking attention on style nits when substantive defects exist. + ## Verdict Criteria - **APPROVE** — Step will achieve its stated outcomes. Minor suggestions go in @@ -481,6 +764,11 @@ access to the codebase and can run commands to inspect code. ### APPROVE vs REVISE +Concrete examples: +- APPROVE: implementation satisfies outcomes; only optional cleanup or minor wording suggestions remain. +- REVISE: a required behavior is missing, tests are insufficient for changed behavior, or a likely regression exists. +- RETHINK: the approach conflicts with architecture/task goals such that incremental edits are unlikely to rescue it. + **APPROVE** when: - The approach will work, but you see a cleaner alternative - Documentation style could improve @@ -493,6 +781,7 @@ access to the codebase and can run commands to inspect code. - Backward compatibility is broken without migration - Code outside the task's File Scope is deleted, removed, or gutted (out-of-scope removal) - Existing functionality is removed without a corresponding changeset explaining the removal +- Code changes were made outside the assigned task worktree, unless the path is an expected exception such as project memory or task attachments ### Do NOT issue REVISE for - STATUS/formatting preferences @@ -535,7 +824,7 @@ access to the codebase and can run commands to inspect code. ### Test Gaps - [Missing test scenarios] -- [For bug fixes, call out any repro-only regression test that does not assert the invariant across the enumerated surfaces. Issue REVISE when coverage stops at the single reported case instead of spanning the \`## Surface Enumeration\` checklist (FN-5893; see FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751).] +- [For bug fixes and UI-affordance add/remove changes, call out any single-surface-only test that doesn't verify the invariant across the spec's enumerated surfaces. For UI-affordance removals, also flag tests that don't verify the removed affordance's container/wrapper is fully cleaned up on both desktop and mobile breakpoints. Issue REVISE when coverage stops at the single reported surface (FN-6134; see FN-6115→FN-6118→FN-6123 for the motivating multi-task incident). Keep enforcing FN-5893 for bug fixes; see FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751.] ### Suggestions - [Optional improvements, not blocking] @@ -560,18 +849,85 @@ access to the codebase and can run commands to inspect code. - **File scope accuracy:** [All affected files listed? No extras?] - **Dependency correctness:** [Dependencies exist and are appropriate?] - **Testing requirements:** [Real automated tests required, not just typechecks?] -- **Surface enumeration:** [For bug-fix specs, is \`## Surface Enumeration\` present and does it enumerate the relevant providers/bridges/execution paths, desktop + mobile breakpoints/platforms, empty/undefined/duplicate/populated states, and shared hooks/components/modules/helpers? Missing or incomplete coverage is a blocking REVISE.] +- **Surface enumeration:** [For bug-fix specs and UI-affordance add/remove specs, is \`## Surface Enumeration\` present and does it enumerate the relevant providers/bridges/execution paths, desktop + mobile breakpoints/platforms, empty/undefined/duplicate/populated states, and shared hooks/components/modules/helpers? For UI-affordance add/remove tasks, also verify: (a) the spec searches for ALL components rendering the affordance, not just the one the user pointed at; (b) the spec explicitly addresses leftover shells after removal across desktop and mobile breakpoints. Missing or incomplete coverage is a blocking REVISE.] +- **Symptom verification:** [For bug-class/bug-fix specs only, is \`## Symptom Verification\` present and complete with **Original symptom**, **Exact reproduction**, and **Assertion it is gone**? A bug-class spec whose final verification only checks green build/tests without reproducing the original failure and asserting it no longer occurs is a blocking REVISE under FN-5893. Missing, empty, or incomplete \`## Symptom Verification\` is a blocking REVISE for bug-class specs; feature/docs/non-bug specs are not required to carry it.] - **Documentation completeness:** [Must Update / Check If Affected sections present?] +- **Dangling task-document references:** [No \`.fusion/tasks/<id>/<file>\` path is cited in Context, Steps, or File Scope unless the file exists or is explicitly created as a \`(new)\` artifact in this spec. References to nonexistent task-local artifacts are a blocking REVISE.] - **Sizing & review level:** [Size and review level appropriate for the work?] -- **Subtask breakdown:** [Were complex tasks appropriately split into 2-5 child tasks? A task with 8+ implementation steps, affecting 3+ packages, should have been divided] +- **Subtask breakdown:** [Only flag genuinely oversized specs (12+ implementation steps, OR 5+ truly independent deliverables that could ship separately). Do NOT flag a coherent vertical change just because it touches multiple packages. When borderline, prefer leaving the task whole.] - **User comment coverage:** [Were all user comments addressed? Every user comment must be reflected in the spec — missing coverage is a blocking REVISE] ### Suggestions - [Optional improvements, not blocking] \`\`\` -## Safety Rules -- **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. If you need to test server endpoints, start a server on a different port (\`--port 0\` for random). If port 4040 is occupied, use a different port — do NOT kill the occupant. Issue REVISE if the executor kills or attempts to kill processes on port 4040.`; +## Spec Review — Undersplit Task Detection + +When reviewing specs, assess whether the task should have been broken into subtasks. The bar for splitting is high — most tasks should remain whole. Coordination overhead (worktrees, dependency wiring, merge sequencing) is real, so splitting must clearly pay for itself. + +**Default position:** do NOT flag undersplit. Reach for it only when the spec is genuinely oversized. + +**Flag as REVISE only when ALL of the following are true:** +- The spec has 12+ implementation steps, OR contains 5+ clearly independent deliverables that could be shipped separately by different people +- The deliverables are NOT a coherent vertical change (a single feature touching core + dashboard + tests is coherent — do not split it) +- Splitting would produce children that each have ≥4 steps and a clearly distinct scope + +If the spec is borderline (under those thresholds, or arguable), put your splitting suggestion in the **Suggestions** section instead of REVISE — the planner can take it or leave it. + +**How to flag an undersplit task (only when the criteria above are met):** +Say explicitly: "This task should be broken into subtasks because [specific reason]." +Recommend the number of child tasks (2-5) and what each should cover. +Instruct the planner to: +1. Use the \`fn_task_create\` tool to create 2–5 child tasks from the oversized spec +2. Do NOT write a parent PROMPT.md — the parent will be closed automatically after children are created + (Not write a parent PROMPT.md is also unacceptable.) +3. Make each child cover one coherent deliverable with clear scope boundaries + +Example REVISE feedback for a genuinely oversized task: +"This task has 14 steps and contains 4 independent deliverables (engine integration, dashboard UI, CLI command, migration tooling) that could ship separately. Use fn_task_create to split into: (1) engine logic, (2) dashboard UI, (3) CLI integration, (4) migration tooling. Do not write a parent PROMPT." + +**Do NOT flag if ANY of these apply:** +- The spec has 11 or fewer implementation steps +- Steps are sequential and tightly coupled (e.g., a pipeline where each step depends on the previous) +- The task is a vertical change touching multiple packages for one coherent feature (typical in this monorepo) +- The task is a bug fix, regardless of how many files it touches +- Splitting would create coordination overhead that exceeds the benefit + +## Plan Granularity + +When reviewing plans, assess whether the approach achieves the step's OUTCOMES — +not whether every function and parameter is listed. + +Good plan: identifies key behavioral changes, calls out risks, has a testing strategy. +Do NOT demand function-level implementation checklists. + +## Test Quality Review + +When reviewing tests, check that they verify observable behavior and regression risk (not only implementation trivia). +Flag REVISE when key edge cases or failure modes for changed behavior are untested. +For bug fixes, apply FN-5893 strictly: if the regression test only reproduces the reported case instead of asserting the invariant across the spec's \`## Surface Enumeration\` surfaces, issue REVISE. Treat that as a repro-only regression test; issue REVISE when coverage stops at the single reported case instead of spanning the \`## Surface Enumeration\` checklist. Use the motivating recurrences (FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751) as concrete examples of why repro-only coverage is insufficient. +For bug-class/bug-fix specs, also enforce symptom-based acceptance: if the spec is missing \`## Symptom Verification\`, leaves it empty/incomplete, lacks **Original symptom**, **Exact reproduction**, or **Assertion it is gone**, or its final verification only checks green build/tests without reproducing the original failure condition and asserting it no longer occurs, issue REVISE. Do not require \`## Symptom Verification\` for feature/docs/non-bug specs. +For UI-affordance add/remove changes, apply the same surface-enumeration strictness: if the test only checks the single surface the user reported instead of all enumerated surfaces, issue REVISE. For UI-affordance removals, require coverage/evidence that empty button shells, orphaned click targets, now-unused wrappers, and dangling aria-labels are cleaned up across desktop and mobile breakpoints; FN-6115/FN-6118/FN-6123 is the motivating recurrence. + +## Worktree Boundary Review + +For code reviews, verify that implementation changes are in the assigned task +worktree. The review request includes the current worktree path. Inspect git +state and recent commits from that worktree, and treat changes outside it as a +blocking REVISE unless they are expected project-root state such as +\`.fusion/memory/\` files, task attachments, or other explicitly documented +Fusion metadata. If you see edits or commits in the primary project checkout +instead of the task worktree, call that out directly and ask the worker to move +the changes into the assigned worktree. + +## Rules + +- Be specific — reference actual files and line numbers +- Be constructive — suggest fixes, not just problems +- Be proportional — don't block on style nits +- Output your review as plain text (not to a file) +- **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. If you need to test server endpoints, start a server on a different port (\`--port 0\` for random). If port 4040 is occupied, use a different port — do NOT kill the occupant. Issue REVISE if the executor kills or attempts to kill processes on port 4040. +`; /** * Base merger prompt text (without commit format instructions, which are @@ -990,6 +1346,14 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [ prompt: `${TRIAGE_PROMPT_TEXT}\n\n${TRIAGE_HEARTBEAT_GUIDANCE}`, builtIn: true, }, + { + id: "default-triage-fast", + name: "Default Triage (Fast)", + description: "Lean fast-path task specification agent producing executable PROMPT.md files without heavyweight review scoring.", + role: "triage", + prompt: FAST_TRIAGE_PROMPT_TEXT, + builtIn: true, + }, { id: "default-reviewer", name: "Default Reviewer", diff --git a/packages/core/src/agent-role-policy.ts b/packages/core/src/agent-role-policy.ts index df7ccce126..6c903c21b2 100644 --- a/packages/core/src/agent-role-policy.ts +++ b/packages/core/src/agent-role-policy.ts @@ -26,18 +26,25 @@ export function canAgentTakeImplementationTaskForExplicitRouting( return !isImplementationTask(task) || isExecutorRoleAgent(agent) || isEngineerRoleAgent(agent); } +export interface BacklogPickupRoleOptions { + /** Allow durable engineer-role agents to auto-claim implementation backlog work. Default: false. */ + allowEngineer?: boolean; +} + export function canAgentTakeImplementationTaskForBacklogPickup( agent: Pick<Agent, "role">, task: Pick<Task, "column">, + options: BacklogPickupRoleOptions = {}, ): boolean { - return !isImplementationTask(task) || isExecutorRoleAgent(agent); + return !isImplementationTask(task) || isExecutorRoleAgent(agent) || (options.allowEngineer === true && isEngineerRoleAgent(agent)); } export function canAgentTakeImplementationTask( agent: Pick<Agent, "role">, task: Pick<Task, "column">, + options?: BacklogPickupRoleOptions, ): boolean { - return canAgentTakeImplementationTaskForBacklogPickup(agent, task); + return canAgentTakeImplementationTaskForBacklogPickup(agent, task, options); } export function formatRoleMismatchReason( diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 6d191d4797..23c8ace7fc 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -8,7 +8,7 @@ * Features: * - Rate limiting per IP (10 requests per hour) * - Dynamic import of @fusion/engine for AI agent creation - * - Text length validation (201-2000 characters) + * - Text length validation (minimum 201 characters; model input is truncated) */ import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js"; @@ -32,9 +32,21 @@ Your ONLY job is to create a concise title (max 60 characters) that summarizes t - Maximum 60 characters - Focus on the main goal or deliverable of the task`; -/** Maximum description length in characters */ +/** + * Historical maximum accepted description length in characters. + * + * @deprecated Title summarization now accepts descriptions of any length; + * use MAX_TITLE_SUMMARIZE_INPUT_LENGTH for the bounded model-input cap. + */ export const MAX_DESCRIPTION_LENGTH = 2000; +/** + * Maximum input length for title summarization. Descriptions can be very large; + * we truncate before sending so the prompt stays bounded while preserving the + * long-input API behavior. + */ +export const MAX_TITLE_SUMMARIZE_INPUT_LENGTH = 4000; + /** Minimum description length for summarization in characters */ export const MIN_DESCRIPTION_LENGTH = 201; @@ -178,17 +190,13 @@ export function validateDescription(description: unknown): string { throw new ValidationError("description must be a string"); } - // Validate description length + // Validate description length floor. There is intentionally no upper bound: + // runTitleSummarizer truncates model input before prompting. if (description.length < MIN_DESCRIPTION_LENGTH) { throw new ValidationError( `description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization` ); } - if (description.length > MAX_DESCRIPTION_LENGTH) { - throw new ValidationError( - `description must not exceed ${MAX_DESCRIPTION_LENGTH} characters` - ); - } return description; } @@ -198,31 +206,22 @@ export function validateDescription(description: unknown): string { /** Debug flag for AI operations */ const DEBUG = process.env.FUSION_DEBUG_AI === "true"; -/** - * Summarize a task description into a concise title using AI. - * @param description - The task description to summarize (must be 201-2000 chars) - * @param rootDir - Project root directory for AI agent context - * @param provider - Optional AI model provider (e.g., "anthropic") - * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") - * @returns The generated title (guaranteed ≤60 characters), or null if validation fails - */ -export async function summarizeTitle( +function isConfiguredModelNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /Configured model .+ was not found in the pi model registry/.test(message); +} + +function formatConfiguredModel(provider?: string, modelId?: string): string { + return provider && modelId ? `${provider}/${modelId}` : "unknown configured model"; +} + +async function runTitleSummarizer( + createFnAgent: NonNullable<Awaited<ReturnType<typeof getFnAgent>>>, description: string, rootDir: string, provider?: string, - modelId?: string -): Promise<string | null> { - // Validate description length first - if (description.length <= 200) { - return null; // Too short for summarization - } - - const createFnAgent = await getFnAgent(); - if (!createFnAgent) { - if (DEBUG) console.log("[ai-summarize] AI engine not available"); - throw new AiServiceError("AI engine not available"); - } - + modelId?: string, +): Promise<string> { const agentOptions: { cwd: string; systemPrompt: string; @@ -255,12 +254,16 @@ export async function summarizeTitle( // Wrap the user-supplied description in a delimiter so the model treats it // as content to summarize, not as instructions to follow. Belt-and-suspenders // alongside the system-prompt guardrails and the engine's readonly tool - // isolation. + // isolation. Truncate before prompt construction so arbitrarily long task + // descriptions cannot produce unbounded model input. + const truncatedDescription = description.length > MAX_TITLE_SUMMARIZE_INPUT_LENGTH + ? description.slice(0, MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "\n…(truncated)" + : description; const wrappedPrompt = "Summarize the following task description into a title (≤60 chars). " + "Output ONLY the title text on a single line. Do not call any tools.\n\n" + "<description>\n" + - description + + truncatedDescription + "\n</description>"; await agentResult.session.prompt(wrappedPrompt); @@ -324,6 +327,56 @@ export async function summarizeTitle( } } +/** + * Summarize a task description into a concise title using AI. + * @param description - The task description to summarize (must be >200 chars; model input is truncated) + * @param rootDir - Project root directory for AI agent context + * @param provider - Optional AI model provider (e.g., "anthropic") + * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") + * @returns The generated title (guaranteed ≤60 characters), or null if validation fails + */ +export async function summarizeTitle( + description: string, + rootDir: string, + provider?: string, + modelId?: string +): Promise<string | null> { + // Validate description length first + if (description.length <= 200) { + return null; // Too short for summarization + } + + const createFnAgent = await getFnAgent(); + if (!createFnAgent) { + if (DEBUG) console.log("[ai-summarize] AI engine not available"); + throw new AiServiceError("AI engine not available"); + } + + try { + return await runTitleSummarizer(createFnAgent, description, rootDir, provider, modelId); + } catch (err) { + if (!provider || !modelId || !isConfiguredModelNotFoundError(err)) { + throw err; + } + + const staleModel = formatConfiguredModel(provider, modelId); + console.warn( + `[ai-summarize] Configured title summarizer model ${staleModel} was not found in the pi model registry; ` + + "retrying with automatic model resolution.", + ); + + try { + return await runTitleSummarizer(createFnAgent, description, rootDir); + } catch (retryError) { + const message = retryError instanceof Error ? retryError.message : String(retryError); + console.warn( + `[ai-summarize] Automatic title summarizer fallback after stale model ${staleModel} failed: ${message}`, + ); + return null; + } + } +} + /** System prompt for AI merge commit summary generation. */ export const MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT = `You summarize merge commits for a task management system. diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index ed61d200bc..f663d6b7fd 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -72,7 +72,23 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"), }, { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, - { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, + { id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } }, + { + id: "branch-group-member-integration", + kind: "branch-group-member-integration", + column: "in-review", + config: { reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "branch-group-promotion", kind: "branch-group-promotion", column: "in-review" }, + { + id: "merge-attempt", + kind: "merge-attempt", + column: "in-review", + config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } }, { id: "end", kind: "end", column: "done" }, ], edges: [ @@ -82,17 +98,29 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { from: "workflow-step", to: "review", condition: "success" }, { from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" }, { from: "workflow-step", to: "end", condition: "outcome:deferred-paused" }, - { from: "review", to: "merge", condition: "success" }, - { from: "merge", to: "end", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, + { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, + { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, + { from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" }, + { from: "merge-manual-hold", to: "branch-group-member-integration", condition: "success", kind: "rework" }, + { from: "branch-group-member-integration", to: "branch-group-promotion", condition: "success" }, + { from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "branch-group-promotion", to: "merge-attempt", condition: "success" }, + { from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "merge-attempt", to: "end", condition: "success" }, + { from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }, + { from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" }, { from: "planning", to: "end", condition: "failure" }, { from: "execute", to: "end", condition: "failure" }, { from: "workflow-step", to: "end", condition: "failure" }, { from: "review", to: "end", condition: "failure" }, - { from: "merge", to: "end", condition: "failure" }, + { from: "merge-attempt", to: "end", condition: "failure" }, ], // Workflow-settings (U1, R4): declare the full moved-key catalog with defaults // byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3. settings: BUILTIN_WORKFLOW_SETTINGS, + optionalSteps: [{ templateId: "browser-verification" }], }; export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR); diff --git a/packages/core/src/builtin-pr-workflow-ir.ts b/packages/core/src/builtin-pr-workflow-ir.ts index 1f774eaebf..b739172135 100644 --- a/packages/core/src/builtin-pr-workflow-ir.ts +++ b/packages/core/src/builtin-pr-workflow-ir.ts @@ -110,6 +110,7 @@ const RAW_BUILTIN_PR_WORKFLOW_IR: WorkflowIr = { // Auto-merge gate (U6, R10): routes outcome:auto-on → pr-merge, // outcome:auto-off → park back on await-review for a manual merge. { id: "gate", kind: "gate", column: "await-review", config: { gate: "auto-merge" } }, + { id: "manual-merge-hold", kind: "manual-merge-hold", column: "await-review", config: { release: "manual" } }, // await-rebase: the conflict dwell column. The reconcile fires // github:pr-conflict-cleared to release it back to await-review. { @@ -159,7 +160,8 @@ const RAW_BUILTIN_PR_WORKFLOW_IR: WorkflowIr = { // auto-merge gate routing. auto-on goes forward to pr-merge; auto-off parks // back on await-review for a manual merge (rework loop-back). { from: "gate", to: "pr-merge", condition: "outcome:auto-on" }, - { from: "gate", to: "await-review", condition: "outcome:auto-off", kind: "rework" }, + { from: "gate", to: "manual-merge-hold", condition: "outcome:auto-off" }, + { from: "manual-merge-hold", to: "pr-merge", condition: "success" }, // pr-merge outcomes: merged-requested ends (reconcile corroborates `merged`); // a stale-head race re-evaluates against the new head via await-review. { from: "pr-merge", to: "end", condition: "outcome:merged-requested" }, diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 13d3c2a33f..21e105870b 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -124,7 +124,23 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { // KTD-5: rework exhaustion escalates to a manual hold (a human releases it). { id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } }, { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, - { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, + { id: "merge-manual-hold", kind: "manual-merge-hold", column: "in-review", config: { release: "manual" } }, + { + id: "branch-group-member-integration", + kind: "branch-group-member-integration", + column: "in-review", + config: { reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "branch-group-promotion", kind: "branch-group-promotion", column: "in-review" }, + { + id: "merge-attempt", + kind: "merge-attempt", + column: "in-review", + config: { capability: "task-merge", reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "recovery-router", kind: "recovery-router", column: "in-review", config: { surfaces: ["merge", "retry"] } }, { id: "end", kind: "end", column: "done" }, ], edges: [ @@ -142,10 +158,21 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { { from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" }, { from: "rework-hold", to: "review", condition: "success" }, { from: "steps", to: "end", condition: "failure" }, - { from: "review", to: "merge", condition: "success" }, + { from: "review", to: "merge-gate", condition: "success" }, { from: "review", to: "end", condition: "failure" }, - { from: "merge", to: "end", condition: "success" }, - { from: "merge", to: "end", condition: "failure" }, + { from: "merge-gate", to: "branch-group-member-integration", condition: "outcome:auto-on" }, + { from: "merge-gate", to: "merge-manual-hold", condition: "outcome:auto-off" }, + { from: "merge-retry", to: "merge-attempt", condition: "success", kind: "rework" }, + { from: "merge-manual-hold", to: "branch-group-member-integration", condition: "success", kind: "rework" }, + { from: "branch-group-member-integration", to: "branch-group-promotion", condition: "success" }, + { from: "branch-group-member-integration", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "branch-group-promotion", to: "merge-attempt", condition: "success" }, + { from: "branch-group-promotion", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "merge-attempt", to: "end", condition: "success" }, + { from: "merge-attempt", to: "merge-retry", condition: "outcome:transient-failure" }, + { from: "merge-attempt", to: "merge-manual-hold", condition: "outcome:manual-required" }, + { from: "recovery-router", to: "merge-attempt", condition: "outcome:wake-merge", kind: "rework" }, + { from: "merge-attempt", to: "end", condition: "failure" }, ], // Workflow-settings (U1, R4): same moved-key catalog as the default builtin. settings: BUILTIN_WORKFLOW_SETTINGS, diff --git a/packages/core/src/builtin-workflow-prompts.ts b/packages/core/src/builtin-workflow-prompts.ts index 4d7ff77090..7d142a4ce3 100644 --- a/packages/core/src/builtin-workflow-prompts.ts +++ b/packages/core/src/builtin-workflow-prompts.ts @@ -1,19 +1,25 @@ import { BUILTIN_AGENT_PROMPTS } from "./agent-prompts.js"; const DEFAULT_EXECUTOR_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "executor")?.prompt ?? ""; -const DEFAULT_TRIAGE_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "triage")?.prompt ?? ""; +const DEFAULT_TRIAGE_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.id === "default-triage")?.prompt ?? ""; +const DEFAULT_TRIAGE_FAST_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.id === "default-triage-fast")?.prompt ?? ""; const DEFAULT_REVIEWER_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "reviewer")?.prompt ?? ""; const DEFAULT_MERGER_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "merger")?.prompt ?? ""; -const BUILTIN_SEAM_PROMPTS: Record<string, string> = { +export const BUILTIN_SEAM_PROMPTS: Record<string, string> = { execute: DEFAULT_EXECUTOR_PROMPT, planning: DEFAULT_TRIAGE_PROMPT, + "planning-fast": DEFAULT_TRIAGE_FAST_PROMPT, "step-execute": DEFAULT_EXECUTOR_PROMPT, "workflow-step": DEFAULT_REVIEWER_PROMPT, review: DEFAULT_REVIEWER_PROMPT, merge: DEFAULT_MERGER_PROMPT, }; -export function builtinPromptConfig(seam: string, name: string): Record<string, unknown> { - return { seam, name, prompt: BUILTIN_SEAM_PROMPTS[seam] ?? "" }; +export function builtinSeamPrompt(seam: string): string { + return BUILTIN_SEAM_PROMPTS[seam] ?? ""; +} + +export function builtinPromptConfig(seam: string, name: string): Record<string, unknown> { + return { seam, name, prompt: builtinSeamPrompt(seam) }; } diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 2d85801ebc..3faded76a1 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -1,5 +1,23 @@ +import type { Settings } from "./types.js"; import type { WorkflowSettingDefinition } from "./workflow-ir-types.js"; +/** + * Built-in workflow settings catalog. + * + * `BUILTIN_MOVED_WORKFLOW_SETTINGS` is the U4 moved-key catalog: keys that + * formerly lived in `DEFAULT_PROJECT_SETTINGS` and are tombstoned by + * `MOVED_SETTINGS_KEYS`. Keep those defaults byte-equal to the legacy literals. + * + * `BUILTIN_TRIAGE_POLICY_SETTINGS` is workflow-native triage/spec policy. These + * keys never lived in `DEFAULT_PROJECT_SETTINGS`, are NOT part of the U4 + * hard-move migration, must never be added to `MOVED_SETTINGS_KEYS`, and must + * not appear in project/global settings schemas. Canonical values are inherited + * from the post-FN-6232 planning prompt: subtask step threshold `7` (not the + * older engine copy) and packages/modules threshold `3`. Fast-mode policy is + * workflow-native here too: `leanPlanning` selects the lean planning variant, + * and `autoApproveSpec` skips the independent spec reviewer. + */ + /** * The moved-key catalog declared as workflow settings (U1, R4). * @@ -23,7 +41,7 @@ import type { WorkflowSettingDefinition } from "./workflow-ir-types.js"; * in project settings. * - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track. */ -export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ +export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ // ── Step execution ───────────────────────────────────────────────────── { id: "workflowStepTimeoutMs", @@ -230,3 +248,153 @@ export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ description: "Fallback model id for the validation phase.", }, ]; + +export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [ + { + id: "triageSizeSmallMaxHours", + name: "Triage size S max hours", + type: "number", + default: 2, + description: "Upper hour boundary for Size S triage guidance (S is below this value).", + }, + { + id: "triageSizeMediumMaxHours", + name: "Triage size M max hours", + type: "number", + default: 4, + description: "Upper hour boundary for Size M triage guidance.", + }, + { + id: "triageSizeLargeMaxHours", + name: "Triage size L max hours", + type: "number", + default: 8, + description: "Upper hour boundary for Size L triage guidance; larger work should split as XL.", + }, + { + id: "triageSubtaskStepThreshold", + name: "Triage subtask step threshold", + type: "number", + default: 7, + description: "Implementation-step count above which triage should consider splitting an M/L task.", + }, + { + id: "triageSubtaskLargeStepSignal", + name: "Triage large-step signal", + type: "number", + default: 9, + description: "Planned step count that is a broad-scope decomposition signal for Size L tasks.", + }, + { + id: "triageSubtaskAdditiveStepSignal", + name: "Triage additive step signal", + type: "number", + default: 12, + description: "Implementation-step count that independently signals possible partitioning.", + }, + { + id: "triageSubtaskPackageThreshold", + name: "Triage package/module threshold", + type: "number", + default: 3, + description: "Distinct package/module count above which triage should consider splitting coherent M/L work.", + }, + { + id: "triageSubtaskFileScopeThreshold", + name: "Triage file-scope threshold", + type: "number", + default: 20, + description: "File Scope entry count that signals broad work likely needing partitioning.", + }, + { + id: "triageSubtaskRemediationBatchThreshold", + name: "Triage remediation batch threshold", + type: "number", + default: 30, + description: "Quantified remediation batch size that strongly signals subsystem partitioning.", + }, + { + id: "triageNoCommitsDecisionVerbs", + name: "Triage no-commits decision verbs", + type: "multi-enum", + default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"], + options: [ + { value: "Decide", label: "Decide" }, + { value: "Evaluate", label: "Evaluate" }, + { value: "Verify", label: "Verify" }, + { value: "Confirm", label: "Confirm" }, + { value: "Audit", label: "Audit" }, + { value: "Review whether", label: "Review whether" }, + { value: "Investigate and report", label: "Investigate and report" }, + ], + description: "Decision-only title/mission verbs used when deciding whether a task expects no commits.", + }, + { + id: "triageDecisionOnlyWorkflowId", + name: "Triage decision-only workflow", + type: "enum", + default: "builtin:quick-fix", + options: [ + { value: "builtin:quick-fix", label: "Quick fix" }, + { value: "builtin:coding", label: "Coding" }, + ], + description: "Preferred workflow id for decision-only or investigation tasks that expect no code changes.", + }, + { + id: "triageDefaultWorkflowId", + name: "Triage default workflow", + type: "enum", + default: "builtin:coding", + options: [ + { value: "builtin:coding", label: "Coding" }, + { value: "builtin:quick-fix", label: "Quick fix" }, + ], + description: "Default workflow id for standard coding tasks.", + }, + { + id: "leanPlanning", + name: "Lean planning", + type: "boolean", + default: false, + description: "Use the lean fast-path planning prompt variant instead of the full triage spec prompt.", + }, + { + id: "autoApproveSpec", + name: "Auto-approve spec", + type: "boolean", + default: false, + description: "Auto-approve the generated PROMPT.md and skip the independent spec reviewer.", + }, +]; + +export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ + ...BUILTIN_MOVED_WORKFLOW_SETTINGS, + ...BUILTIN_TRIAGE_POLICY_SETTINGS, +]; + +const TRIAGE_POLICY_DEFAULTS = new Map( + BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting.default]), +); + +function formatTriagePolicyValue(id: string, value: unknown): string { + if (id === "triageNoCommitsDecisionVerbs") { + const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id); + return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", "); + } + return String(value ?? TRIAGE_POLICY_DEFAULTS.get(id) ?? ""); +} + +export function renderTriagePolicyPlaceholders(prompt: string, settings: Partial<Settings>): string { + let rendered = prompt; + const values = settings as Record<string, unknown>; + for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) { + const token = new RegExp(`\\{\\{${setting.id}\\}\\}`, "g"); + rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default)); + } + const leftover = rendered.match(/\{\{[^}]+\}\}/); + if (leftover) { + throw new Error(`Unresolved triage policy placeholder: ${leftover[0]}`); + } + return rendered; +} + diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 27b8afd147..bcca6fe7e2 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -110,8 +110,14 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ start: { x: 60, y: 160 }, execute: { x: 230, y: 160 }, review: { x: 400, y: 160 }, - merge: { x: 570, y: 160 }, - end: { x: 740, y: 160 }, + "merge-gate": { x: 570, y: 160 }, + "branch-group-member-integration": { x: 740, y: 80 }, + "branch-group-promotion": { x: 910, y: 80 }, + "merge-attempt": { x: 1080, y: 160 }, + "merge-retry": { x: 1250, y: 80 }, + "recovery-router": { x: 1250, y: 240 }, + "merge-manual-hold": { x: 740, y: 240 }, + end: { x: 1420, y: 160 }, }, createdAt: BUILTIN_TS, updatedAt: BUILTIN_TS, diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index e3769681ed..ee18e21fbc 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 115; +const SCHEMA_VERSION = 118; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -250,6 +250,7 @@ CREATE TABLE IF NOT EXISTS tasks ( baseBranch TEXT, branch TEXT, autoMerge INTEGER, + autoMergeProvenance TEXT, executionStartBranch TEXT, baseCommitSha TEXT, modelPresetId TEXT, @@ -262,6 +263,7 @@ CREATE TABLE IF NOT EXISTS tasks ( mergeRetries INTEGER, workflowStepRetries INTEGER, resumeLimboCount INTEGER DEFAULT 0, + graphResumeRetryCount INTEGER DEFAULT 0, resumeLimboTipSha TEXT, resumeLimboStepSignature TEXT, recoveryRetryCount INTEGER, @@ -602,6 +604,27 @@ CREATE TABLE IF NOT EXISTS completion_handoff_markers ( ); CREATE INDEX IF NOT EXISTS idx_completion_handoff_markers_acceptedAt ON completion_handoff_markers(acceptedAt); +CREATE TABLE IF NOT EXISTS workflow_work_items ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL, + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + nodeId TEXT NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + retryAfter TEXT, + leaseOwner TEXT, + leaseExpiresAt TEXT, + lastError TEXT, + blockedReason TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE(runId, taskId, nodeId, kind) +); +CREATE INDEX IF NOT EXISTS idx_workflow_work_items_due ON workflow_work_items(state, retryAfter, createdAt); +CREATE INDEX IF NOT EXISTS idx_workflow_work_items_leaseExpiresAt ON workflow_work_items(leaseExpiresAt); +CREATE INDEX IF NOT EXISTS idx_workflow_work_items_task_run ON workflow_work_items(taskId, runId); + -- Per-branch run state for concurrent workflow fan-out/join (U13, KTD-11/R21). -- Reconstructible per ADR-0001: a crashed parallel run resumes each branch from -- its persisted node; completed branches are not re-run. Additive-only. @@ -4635,8 +4658,56 @@ export class Database { }); } + // Migration 115: Workflow-owned merge/retry/scheduling S1. + // Adds durable workflow work items so runnable, held, retrying, merge, + // manual-hold, and recovery work can be claimed generically before legacy + // merge queue and retry policy are deleted. if (version < 115) { this.applyMigration(115, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_work_items ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL, + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + nodeId TEXT NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + retryAfter TEXT, + leaseOwner TEXT, + leaseExpiresAt TEXT, + lastError TEXT, + blockedReason TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE(runId, taskId, nodeId, kind) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_work_items_due + ON workflow_work_items(state, retryAfter, createdAt); + CREATE INDEX IF NOT EXISTS idx_workflow_work_items_leaseExpiresAt + ON workflow_work_items(leaseExpiresAt); + CREATE INDEX IF NOT EXISTS idx_workflow_work_items_task_run + ON workflow_work_items(taskId, runId); + `); + }); + } + + // Migration 116: Bounded transient resume-after-restart graph retries. + if (version < 116) { + this.applyMigration(116, () => { + this.addColumnIfMissing("tasks", "graphResumeRetryCount", "INTEGER DEFAULT 0"); + }); + } + + // Migration 117: Auto-merge override provenance for legacy stamp cleanup. + if (version < 117) { + this.applyMigration(117, () => { + this.addColumnIfMissing("tasks", "autoMergeProvenance", "TEXT"); + }); + } + + if (version < 118) { + this.applyMigration(118, () => { // FN: behavioral verification — classify contract assertions so the // validator can scope the default-to-fail / verification posture to // behavioral/bug assertions. Existing rows default to 'static' to diff --git a/packages/core/src/default-workflow-hooks.ts b/packages/core/src/default-workflow-hooks.ts index c3dc13495d..2b6c1936da 100644 --- a/packages/core/src/default-workflow-hooks.ts +++ b/packages/core/src/default-workflow-hooks.ts @@ -2,9 +2,10 @@ * Default-workflow trait hook implementations (U4). * * The legacy per-column side effects of `moveTaskInternal` — timing / - * `cumulativeActiveMs` accounting, reopen field/step resets, autoMerge stamping - * + merge-queue enqueue, and abort-on-exit (hard-cancel incl. `userPaused` only - * for user-source moves) — become the default workflow's trait hook + * `cumulativeActiveMs` accounting, reopen field/step resets, in-review + * auto-merge handoff preparation + merge-queue enqueue, and abort-on-exit + * (hard-cancel incl. `userPaused` only for user-source moves) — become the + * default workflow's trait hook * implementations, registered through U2's DI seam (`registerTraitHookImpl`). * * IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in @@ -73,7 +74,11 @@ export interface DefaultWorkflowMoveContext { /** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */ bypassGuards: boolean; movedAt: string; - /** Settings snapshot for autoMerge stamping (only read when entering review). */ + /** + * Settings snapshot available to move effects that need it. Review entry must + * not copy global `autoMerge` onto the task; an undefined task value follows + * the live global setting at processing time. + */ settings: Pick<Settings, "autoMerge"> | undefined; /** Move options that influence reopen/timing semantics. */ options: { @@ -165,15 +170,16 @@ export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void } } -/** `merge` trait onEnter (in-review): autoMerge stamping + scheduler-state - * clearing. The queue enqueue itself is in-txn and store-owned (handoff path); - * the field effects mirror the legacy in-review block. */ +/** `merge` trait onEnter (in-review): scheduler-state clearing while + * preserving explicit per-task autoMerge overrides. The queue enqueue itself is + * in-txn and store-owned (handoff path); the field effects mirror the legacy + * in-review block. Keep this flag-ON path in sync with the flag-OFF inline + * block in store.ts. */ export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void { - const { task, toColumn, settings } = ctx; + const { task, toColumn } = ctx; if (toColumn !== "in-review") return; - if (task.autoMerge === undefined && settings) { - task.autoMerge = settings.autoMerge; - } + // Do not snapshot the global autoMerge setting here. Undefined means "follow + // the live global setting"; only an explicit task value should stay sticky. task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; if (task.status === "queued") { diff --git a/packages/core/src/frontend-ux-policy.ts b/packages/core/src/frontend-ux-policy.ts new file mode 100644 index 0000000000..a1114e4576 --- /dev/null +++ b/packages/core/src/frontend-ux-policy.ts @@ -0,0 +1,148 @@ +/** + * Frontend UX criteria policy for generated task specifications. + * + * The checklist mirrors the `frontend-ux-design` reviewer persona in + * packages/core/src/types.ts. Keep this policy byte-equivalent with the legacy + * triage prompt checklist and idempotent when applied to generated PROMPT.md + * content. + * + * Rule 4 deterministically expands the legacy "Any CSS or TSX file inside a + * dashboard-like package" rule: match files under a package `app/` directory + * ending in `.css` or `.tsx`, excluding the `components/` and `hooks/` subtrees + * already covered by rules 2 and 3. + */ +export const FRONTEND_UX_PATH_GLOBS = [ + "packages/dashboard/**", + "packages/*/app/components/**", + "packages/*/app/hooks/**", + "packages/*/app/**/*.css", + "packages/*/app/**/*.tsx", +] as const; + +/** + * Byte-exact Frontend UX Criteria section copied from the legacy triage prompt. + * The section intentionally ends with exactly one trailing newline. + */ +export const FRONTEND_UX_CRITERIA_SECTION = `## 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 +`; + +const FRONTEND_UX_HEADING = "## Frontend UX Criteria"; + +/** + * Pure deterministic injection helper. When `fileScopePaths` is omitted, the + * helper parses `## File Scope` from the supplied prompt markdown using the same + * section shape as the engine triage parser. + */ +export function applyFrontendUxCriteria(promptMarkdown: string, fileScopePaths?: string[]): string { + if (promptMarkdown.includes(FRONTEND_UX_HEADING)) { + return promptMarkdown; + } + + const paths = fileScopePaths ?? parseFileScopeFromPromptMarkdown(promptMarkdown); + if (!paths.some((path) => matchesFrontendUxPath(path))) { + return promptMarkdown; + } + + return insertFrontendUxCriteriaAfterMission(promptMarkdown); +} + +export function matchesFrontendUxPath(path: string): boolean { + const normalized = normalizePath(path); + if (!normalized) return false; + + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[0])) return true; + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1])) return true; + if (matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2])) return true; + + const isAppCssOrTsx = matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[3]) + || matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[4]); + if (!isAppCssOrTsx) return false; + + return !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[1]) + && !matchGlob(normalized, FRONTEND_UX_PATH_GLOBS[2]); +} + +function parseFileScopeFromPromptMarkdown(text: string): string[] { + const match = text.match(/^##\s+File Scope\s*\n([\s\S]*?)(?=^##\s+|$)/m); + if (!match) return []; + + const entries: string[] = []; + for (const line of match[1].split("\n")) { + const cleaned = normalizeFileScopeLine(line); + if (cleaned) entries.push(cleaned); + } + return entries; +} + +function normalizeFileScopeLine(line: string): string { + let cleaned = line.trim(); + if (!cleaned || cleaned.startsWith("<!--")) return ""; + cleaned = cleaned.replace(/^[-*]\s+/, "").trim(); + cleaned = cleaned.replace(/^`([^`]+)`.*$/, "$1").trim(); + cleaned = cleaned.replace(/`/g, "").trim(); + return cleaned; +} + +function insertFrontendUxCriteriaAfterMission(content: string): string { + const missionMatch = content.match(/^##\s+Mission\s*$/m); + if (!missionMatch || missionMatch.index === undefined) { + return content; + } + + const headerEnd = missionMatch.index + missionMatch[0].length; + const rest = content.slice(headerEnd); + const nextHeading = rest.search(/\n##\s/); + const sectionEndAbsolute = nextHeading === -1 ? content.length : headerEnd + nextHeading; + const before = content.slice(0, sectionEndAbsolute).trimEnd(); + const after = content.slice(sectionEndAbsolute); + return `${before}\n\n${FRONTEND_UX_CRITERIA_SECTION}${after}`; +} + +/** Check if a path matches a glob pattern (simple glob support: * and **). */ +function matchGlob(path: string, pattern: string): boolean { + const regexPattern = globToRegexPattern(normalizePath(pattern)); + return new RegExp(`^${regexPattern}$`).test(normalizePath(path)); +} + +function globToRegexPattern(pattern: string): string { + let out = ""; + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i]; + const next = pattern[i + 1]; + const afterNext = pattern[i + 2]; + + if (char === "*" && next === "*" && afterNext === "/") { + out += "(?:.*/)?"; + i += 2; + continue; + } + if (char === "*" && next === "*") { + out += ".*"; + i += 1; + continue; + } + if (char === "*") { + out += "[^/]*"; + continue; + } + out += escapeRegex(char); + } + return out; +} + +function escapeRegex(char: string): string { + return /[\\^$+?.()|[\]{}]/.test(char) ? `\\${char}` : char; +} + +function normalizePath(path: string): string { + return path.trim().replace(/\\/g, "/").replace(/^\.\//, ""); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 06a8b8c618..c21110d810 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -17,6 +17,7 @@ export type { } from "./branch-assignment.js"; export { customProviderRegistryKey } from "./custom-provider-key.js"; export { redactSecrets } from "./redact-secrets.js"; +export * from "./frontend-ux-policy.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export { @@ -78,6 +79,7 @@ export type { WorkflowFieldOption, WorkflowFieldRender, // Workflow-settings (U1): typed setting declaration IR types. + WorkflowOptionalStep, WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, @@ -103,9 +105,21 @@ export type { EffectiveAgentResult, } from "./column-agent-resolver.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js"; +export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js"; export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; export { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; -export { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +export { + BUILTIN_WORKFLOW_SETTINGS, + BUILTIN_MOVED_WORKFLOW_SETTINGS, + BUILTIN_TRIAGE_POLICY_SETTINGS, + renderTriagePolicyPlaceholders, +} from "./builtin-workflow-settings.js"; +export { + BUILTIN_SEAM_PROMPTS, + builtinPromptConfig, + builtinSeamPrompt, +} from "./builtin-workflow-prompts.js"; export { MOVED_SETTINGS_KEYS, SETTINGS_MIGRATION_VERSION, @@ -309,6 +323,10 @@ export { export { resolveWorkflowIrForTask, resolveWorkflowIrById, + resolveSeamPromptFromIr, + resolvePlanningPromptFromIr, + resolveTaskSeamPrompt, + resolveTaskPlanningPrompt, type WorkflowIrResolverStore, } from "./workflow-ir-resolver.js"; export { @@ -437,6 +455,7 @@ export { InvalidMergeQueueLeaseDurationError, HandoffInvariantViolationError, TransitionRejectionError, + type LegacyAutoMergeStampReconcileResult, } from "./store.js"; export { STOPWORDS, @@ -461,6 +480,11 @@ export { parseExplicitDuplicateMarker, type ExplicitDuplicateMarker, } from "./explicit-duplicate-marker.js"; +export { + parseNoOpCompletionMarker, + type NoOpCompletionMarker, + type NoOpCompletionMarkerKind, +} from "./no-op-completion-marker.js"; export { __getDeterministicGuardMutexSize, deterministicGuardLocks, @@ -996,6 +1020,7 @@ export { MAX_COMMIT_SUBJECT_LENGTH, DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS, MAX_DESCRIPTION_LENGTH, + MAX_TITLE_SUMMARIZE_INPUT_LENGTH, MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, diff --git a/packages/core/src/manual-retry-reset.ts b/packages/core/src/manual-retry-reset.ts index 938177ca26..903b89131f 100644 --- a/packages/core/src/manual-retry-reset.ts +++ b/packages/core/src/manual-retry-reset.ts @@ -5,6 +5,7 @@ export const IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON = "in-review-stall-deadlock"; export const MANUAL_RETRY_RESET_COUNTER_KEYS = [ "stuckKillCount", "resumeLimboCount", + "graphResumeRetryCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", diff --git a/packages/core/src/moved-settings.ts b/packages/core/src/moved-settings.ts index d0f7de9aed..a1be408899 100644 --- a/packages/core/src/moved-settings.ts +++ b/packages/core/src/moved-settings.ts @@ -4,9 +4,10 @@ * `MOVED_SETTINGS_KEYS` is the single, authoritative record of the settings keys * that left `DEFAULT_PROJECT_SETTINGS` and now live exclusively as **workflow * setting values** per `(workflowId, projectId)`. It is derived directly from the - * built-in workflow declaration catalog (`BUILTIN_WORKFLOW_SETTINGS`) so the move - * has exactly one source of truth — a key is "moved" iff a built-in workflow - * declares it. Adding/removing a key from the catalog automatically reflows the + * moved workflow declaration catalog (`BUILTIN_MOVED_WORKFLOW_SETTINGS`) so the move + * has exactly one source of truth. Workflow-native declarations (for example + * triage policy thresholds) are deliberately excluded from this tombstone. + * Adding/removing a key from the moved catalog automatically reflows the * tombstone list, the migration write target, and the stale-writer guard. * * What the tombstone shields (KTD-5, R8): @@ -35,7 +36,7 @@ * setting and is intentionally ABSENT from this list. */ -import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +import { BUILTIN_MOVED_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; /** * The version of the per-project settings hard-move migration. Persisted per @@ -49,11 +50,11 @@ export const SETTINGS_MIGRATION_VERSION = 1; export const SETTINGS_MIGRATION_MARKER_KEY = "settingsMigrationVersion"; /** - * The definitive moved-key catalog — derived from the built-in workflow + * The definitive moved-key catalog — derived from the moved workflow * declarations so it cannot drift from them. Frozen so callers cannot mutate it. */ export const MOVED_SETTINGS_KEYS: readonly string[] = Object.freeze( - BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id), + BUILTIN_MOVED_WORKFLOW_SETTINGS.map((s) => s.id), ); /** Set form for O(1) membership checks on the hot write path. */ diff --git a/packages/core/src/no-op-completion-marker.ts b/packages/core/src/no-op-completion-marker.ts new file mode 100644 index 0000000000..2337c02871 --- /dev/null +++ b/packages/core/src/no-op-completion-marker.ts @@ -0,0 +1,48 @@ +export type NoOpCompletionMarkerKind = "premise-stale" | "no-op" | "duplicate" | "redundant"; + +export interface NoOpCompletionMarker { + kind: NoOpCompletionMarkerKind; + reason: string; + canonicalId?: string; +} + +const PREFIXES: Array<{ pattern: RegExp; kind: NoOpCompletionMarkerKind }> = [ + { pattern: /^PREMISE STALE:\s*/i, kind: "premise-stale" }, + { pattern: /^NO-OP:\s*/i, kind: "no-op" }, + { pattern: /^NOOP:\s*/i, kind: "no-op" }, + { pattern: /^DUPLICATE:\s*/i, kind: "duplicate" }, + { pattern: /^REDUNDANT:\s*/i, kind: "redundant" }, +]; + +/** + * Detects explicit executor completion summaries that mean the task was + * verified as already satisfied on HEAD (no source commit is appropriate). + * + * The marker must be a leading, case-insensitive prefix. Mid-summary mentions + * intentionally do not match so ordinary prose cannot accidentally bypass the + * no-commits invariant. + */ +export function parseNoOpCompletionMarker(summary: string | undefined): NoOpCompletionMarker | null { + const trimmed = summary?.trim() ?? ""; + if (!trimmed) { + return null; + } + + for (const { pattern, kind } of PREFIXES) { + const match = trimmed.match(pattern); + if (!match) continue; + + const reason = trimmed.slice(match[0].length).trim(); + const idMatch = kind === "duplicate" || kind === "redundant" + ? reason.match(/\b(FN-\d+)\b/i) + : null; + + return { + kind, + reason, + ...(idMatch ? { canonicalId: idMatch[1].toUpperCase() } : {}), + }; + } + + return null; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 200126d39b..3aefafdfd7 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -245,6 +245,7 @@ export const DEFAULT_PROJECT_SETTINGS = { pollIntervalMs: 15000, heartbeatMultiplier: 1, autoClaimCandidatesInPrompt: 5, + engineerBacklogAutoClaim: false, tombstoneStickyWindowDays: 7, heartbeatScopeDiscipline: "strict", heartbeatPromptTemplate: "default", diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 12855b97c9..af54729181 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,9 +3,9 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { MOVED_SETTINGS_KEYS, @@ -77,7 +77,7 @@ import type { WorkflowDefinitionUpdate, WorkflowNodeLayout, } from "./workflow-definition-types.js"; -import { compileWorkflowToSteps } from "./workflow-compiler.js"; +import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, @@ -191,6 +191,7 @@ interface TaskRow { executionStartBranch: string | null; branch: string | null; autoMerge: number | null; + autoMergeProvenance: string | null; baseCommitSha: string | null; modelPresetId: string | null; modelProvider: string | null; @@ -203,6 +204,7 @@ interface TaskRow { workflowStepRetries: number | null; stuckKillCount: number | null; resumeLimboCount: number | null; + graphResumeRetryCount: number | null; resumeLimboTipSha: string | null; resumeLimboStepSignature: string | null; postReviewFixCount: number | null; @@ -310,6 +312,7 @@ function defineTaskColumn( } const serializeTaskAutoMerge: TaskColumnDescriptor["serialize"] = (task) => task.autoMerge === undefined ? null : (task.autoMerge ? 1 : 0); +const serializeTaskAutoMergeProvenance: TaskColumnDescriptor["serialize"] = (task) => task.autoMergeProvenance ?? null; // Keep this descriptor order in lockstep with the named-column INSERT/UPSERT // clauses we generate below. SQLite binds by the explicit column list we emit, @@ -335,6 +338,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("baseBranch", (task) => task.baseBranch ?? null), defineTaskColumn("branch", (task) => task.branch ?? null), defineTaskColumn("autoMerge", serializeTaskAutoMerge), + defineTaskColumn("autoMergeProvenance", serializeTaskAutoMergeProvenance), defineTaskColumn("executionStartBranch", (task) => task.executionStartBranch ?? null), defineTaskColumn("baseCommitSha", (task) => task.baseCommitSha ?? null), defineTaskColumn("modelPresetId", (task) => task.modelPresetId ?? null), @@ -348,6 +352,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("workflowStepRetries", (task) => task.workflowStepRetries ?? null), defineTaskColumn("stuckKillCount", (task) => task.stuckKillCount ?? 0), defineTaskColumn("resumeLimboCount", (task) => task.resumeLimboCount ?? 0), + defineTaskColumn("graphResumeRetryCount", (task) => task.graphResumeRetryCount === undefined ? 0 : task.graphResumeRetryCount), defineTaskColumn("resumeLimboTipSha", (task) => task.resumeLimboTipSha ?? null), defineTaskColumn("resumeLimboStepSignature", (task) => task.resumeLimboStepSignature ?? null), defineTaskColumn("postReviewFixCount", (task) => task.postReviewFixCount ?? 0), @@ -625,6 +630,23 @@ interface CompletionHandoffMarkerRow { source: string; } +interface WorkflowWorkItemRow { + id: string; + runId: string; + taskId: string; + nodeId: string; + kind: string; + state: string; + attempt: number; + retryAfter: string | null; + leaseOwner: string | null; + leaseExpiresAt: string | null; + lastError: string | null; + blockedReason: string | null; + createdAt: string; + updatedAt: string; +} + /** Database row shape for the config table. */ interface ConfigRow { nextId: number; @@ -1388,6 +1410,15 @@ interface MoveTaskInternalOptions { const WORKFLOW_MOVE_POLICY_TIMEOUT_MS = 5000; +export interface LegacyAutoMergeStampReconcileResult { + taskId: string; + column: string; + cleared: boolean; +} + +const LEGACY_AUTO_MERGE_STAMP_MARKER_KEY = "legacyAutoMergeStampMarkedVersion"; +const LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION = "1"; + export class TaskStore extends EventEmitter<TaskStoreEvents> { private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL'; /** U6: sentinel effective-workflow id for default-workflow (null-selection) @@ -1788,6 +1819,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateAgentLogEntriesToFilesOnce(); await this.cleanupNoOpTaskMovedActivityRowsOnce(); + try { + await this.markLegacyAutoMergeStampsOnce(); + } catch (err) { + storeLog.warn("Legacy auto-merge stamp marker failed during init (non-fatal)", { + phase: "init:legacy-auto-merge-stamp-marker", + error: err instanceof Error ? err.message : String(err), + }); + } // U4: one-time per-project hard-move of MOVED_SETTINGS_KEYS into workflow // setting values (marker-gated, idempotent, never blocks startup). try { @@ -1901,6 +1940,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { executionStartBranch: row.executionStartBranch || undefined, branch: row.branch || undefined, autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1, + autoMergeProvenance: row.autoMergeProvenance === "user" || row.autoMergeProvenance === "legacy-stamp" + ? row.autoMergeProvenance + : undefined, baseCommitSha: row.baseCommitSha || undefined, scopeOverride: row.scopeOverride ? true : undefined, scopeOverrideReason: row.scopeOverrideReason || undefined, @@ -1916,6 +1958,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { workflowStepRetries: row.workflowStepRetries ?? undefined, stuckKillCount: row.stuckKillCount ?? undefined, resumeLimboCount: row.resumeLimboCount ?? undefined, + graphResumeRetryCount: row.graphResumeRetryCount ?? undefined, resumeLimboTipSha: row.resumeLimboTipSha || undefined, resumeLimboStepSignature: row.resumeLimboStepSignature || undefined, postReviewFixCount: row.postReviewFixCount ?? undefined, @@ -2089,6 +2132,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { description: entry.description, priority: normalizeTaskPriority(entry.priority), column: "archived", + preArchiveColumn: entry.preArchiveColumn, dependencies: entry.dependencies ?? [], steps: entry.steps ?? [], currentStep: entry.currentStep ?? 0, @@ -2222,6 +2266,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { description: task.description, priority: normalizeTaskPriority(task.priority), column: "archived", + preArchiveColumn: task.preArchiveColumn, dependencies: task.dependencies, steps: task.steps, currentStep: task.currentStep, @@ -2426,11 +2471,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { const prefix = tableAlias ? `${tableAlias}.` : ""; return [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", - "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha", + "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", - "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", + "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", @@ -2475,11 +2520,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> { private getTaskSelectClauseWithActivityLogLimit(limit: number): string { const columns = [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", - "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha", + "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", - "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", + "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", @@ -4438,6 +4483,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext), branchContext: input.branchContext, autoMerge: input.autoMerge, + autoMergeProvenance: input.autoMerge === undefined ? undefined : "user", column: input.column || "triage", dependencies: input.dependencies || [], breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, @@ -6815,11 +6861,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } - const settingsForInReview = - toColumn === "in-review" && task.autoMerge === undefined - ? await this.getSettingsFast() - : undefined; - const movedAt = internal.now ?? new Date().toISOString(); task.column = toColumn; task.columnMovedAt = movedAt; @@ -6837,7 +6878,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} moveSource, bypassGuards, movedAt, - settings: settingsForInReview, + settings: undefined, options: { preserveStatus: options?.preserveStatus, preserveResumeState: options?.preserveResumeState, @@ -6941,9 +6982,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } if (toColumn === "in-review") { - if (task.autoMerge === undefined && settingsForInReview) { - task.autoMerge = settingsForInReview.autoMerge; - } + // Keep this flag-OFF inline path in sync with applyInReviewEnterEffects. + // Do not snapshot global autoMerge: undefined follows the live setting, + // while explicit per-task true/false overrides remain sticky. task.recoveryRetryCount = undefined; task.nextRecoveryAt = undefined; // Clear scheduler-side dispatch state: `queued`, `blockedBy`, and @@ -7132,6 +7173,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); } } + this.cancelActiveWorkflowWorkItemsForTask(id, { + kinds: ["merge", "manual-hold"], + now: movedAt, + lastError: "cancelled-by-user-hard-cancel", + }); this.clearCompletionHandoffAcceptedMarker(id); } if (toColumn === "done") { @@ -7481,7 +7527,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise<Task> { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); @@ -8031,20 +8077,28 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.baseBranch !== undefined) { task.baseBranch = updates.baseBranch; } + // Explicit task-level auto-merge overrides written through updateTask are + // user provenance. Task creation mirrors this for create-time overrides. if (updates.autoMerge === null) { task.autoMerge = undefined; + task.autoMergeProvenance = undefined; } else if (updates.autoMerge !== undefined) { task.autoMerge = updates.autoMerge; + task.autoMergeProvenance = "user"; } if (updates.branch === null) { task.branch = undefined; } else if (updates.branch !== undefined) { task.branch = updates.branch; } + // Keep in sync with the first autoMerge block above; both legacy update + // paths may run before persistence. if (updates.autoMerge === null) { task.autoMerge = undefined; + task.autoMergeProvenance = undefined; } else if (updates.autoMerge !== undefined) { task.autoMerge = updates.autoMerge; + task.autoMergeProvenance = "user"; } if (updates.executionStartBranch === null) { task.executionStartBranch = undefined; @@ -8070,6 +8124,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.resumeLimboCount !== undefined) { task.resumeLimboCount = updates.resumeLimboCount; } + if (updates.graphResumeRetryCount === null) { + task.graphResumeRetryCount = null; + } else if (updates.graphResumeRetryCount !== undefined) { + task.graphResumeRetryCount = updates.graphResumeRetryCount; + } if (updates.resumeLimboTipSha === null) { task.resumeLimboTipSha = undefined; } else if (updates.resumeLimboTipSha !== undefined) { @@ -8803,6 +8862,72 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }; } + private normalizeWorkflowWorkItemKind(value: string): WorkflowWorkItemKind { + switch (value) { + case "task": + case "merge": + case "retry": + case "manual-hold": + case "recovery": + return value; + default: + return "task"; + } + } + + private normalizeWorkflowWorkItemState(value: string): WorkflowWorkItemState { + switch (value) { + case "runnable": + case "running": + case "held": + case "retrying": + case "manual-required": + case "succeeded": + case "failed": + case "cancelled": + case "exhausted": + return value; + default: + return "runnable"; + } + } + + private isTerminalWorkflowWorkItemState(state: WorkflowWorkItemState): boolean { + return state === "succeeded" || state === "failed" || state === "cancelled" || state === "exhausted"; + } + + private workflowStateForMergeRequestState(state: MergeRequestState): WorkflowWorkItemState { + const states: Record<MergeRequestState, WorkflowWorkItemState> = { + queued: "runnable", + running: "running", + retrying: "retrying", + succeeded: "succeeded", + exhausted: "exhausted", + cancelled: "cancelled", + "manual-required": "manual-required", + }; + return states[state]; + } + + private rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkItem { + return { + id: row.id, + runId: row.runId, + taskId: row.taskId, + nodeId: row.nodeId, + kind: this.normalizeWorkflowWorkItemKind(row.kind), + state: this.normalizeWorkflowWorkItemState(row.state), + attempt: row.attempt, + retryAfter: row.retryAfter, + leaseOwner: row.leaseOwner, + leaseExpiresAt: row.leaseExpiresAt, + lastError: row.lastError, + blockedReason: row.blockedReason, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + private isValidMergeRequestTransition(from: MergeRequestState, to: MergeRequestState): boolean { if (from === to) return true; const allowed: Record<MergeRequestState, ReadonlySet<MergeRequestState>> = { @@ -8892,6 +9017,274 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return row ? this.rowToMergeRequestRecord(row) : null; } + projectMergeRequestToWorkflowWorkItem( + taskId: string, + opts: MergeRequestWorkflowProjectionOptions = {}, + ): WorkflowWorkItem | null { + return this.db.transactionImmediate(() => { + const record = this.getMergeRequestRecord(taskId); + if (!record) return null; + const state = this.workflowStateForMergeRequestState(record.state); + const kind = record.state === "manual-required" ? "manual-hold" : "merge"; + const item = this.upsertWorkflowWorkItem({ + runId: opts.runId ?? `merge-request:${taskId}`, + taskId, + nodeId: opts.nodeId ?? "builtin.merge.request", + kind, + state, + attempt: record.attemptCount, + lastError: record.lastError, + blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null, + now: opts.now ?? record.updatedAt, + }); + this.cancelActiveWorkflowWorkItemsForTask(taskId, { + kinds: [kind === "manual-hold" ? "merge" : "manual-hold"], + now: opts.now ?? record.updatedAt, + lastError: "superseded-by-merge-request-projection", + }); + this.insertRunAuditEventRow({ + taskId, + runId: item.runId, + domain: "database", + mutationType: "mergeRequest:workflow-projection", + target: item.id, + metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind }, + }); + return item; + }); + } + + upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem { + return this.db.transactionImmediate(() => { + const existing = this.db + .prepare("SELECT * FROM workflow_work_items WHERE runId = ? AND taskId = ? AND nodeId = ? AND kind = ?") + .get(input.runId, input.taskId, input.nodeId, input.kind) as WorkflowWorkItemRow | undefined; + const now = input.now ?? new Date().toISOString(); + const existingState = existing ? this.normalizeWorkflowWorkItemState(existing.state) : null; + const state = input.state ?? existingState ?? "runnable"; + if (existingState && this.isTerminalWorkflowWorkItemState(existingState) && existingState !== state) { + throw new Error( + `Workflow work item ${existing?.id ?? input.id ?? input.nodeId} is terminal (${existingState}) and cannot be requeued as ${state}`, + ); + } + + const id = existing?.id ?? input.id ?? randomUUID(); + this.db + .prepare( + `INSERT INTO workflow_work_items ( + id, runId, taskId, nodeId, kind, state, attempt, retryAfter, + leaseOwner, leaseExpiresAt, lastError, blockedReason, createdAt, updatedAt + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(runId, taskId, nodeId, kind) DO UPDATE SET + state = excluded.state, + attempt = excluded.attempt, + retryAfter = excluded.retryAfter, + leaseOwner = excluded.leaseOwner, + leaseExpiresAt = excluded.leaseExpiresAt, + lastError = excluded.lastError, + blockedReason = excluded.blockedReason, + updatedAt = excluded.updatedAt`, + ) + .run( + id, + input.runId, + input.taskId, + input.nodeId, + input.kind, + state, + input.attempt ?? existing?.attempt ?? 0, + input.retryAfter === undefined ? existing?.retryAfter ?? null : input.retryAfter, + input.leaseOwner === undefined ? existing?.leaseOwner ?? null : input.leaseOwner, + input.leaseExpiresAt === undefined ? existing?.leaseExpiresAt ?? null : input.leaseExpiresAt, + input.lastError === undefined ? existing?.lastError ?? null : input.lastError, + input.blockedReason === undefined ? existing?.blockedReason ?? null : input.blockedReason, + existing?.createdAt ?? now, + now, + ); + + const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; + if (!row) throw new Error(`Failed to upsert workflow work item ${id}`); + this.insertRunAuditEventRow({ + taskId: row.taskId, + runId: row.runId, + domain: "database", + mutationType: "workflowWorkItem:upsert", + target: row.id, + metadata: { id: row.id, nodeId: row.nodeId, kind: row.kind, state: row.state, attempt: row.attempt }, + }); + return this.rowToWorkflowWorkItem(row); + }); + } + + transitionWorkflowWorkItem( + id: string, + state: WorkflowWorkItemState, + patch: WorkflowWorkItemTransitionPatch = {}, + ): WorkflowWorkItem { + return this.db.transactionImmediate(() => { + const now = patch.now ?? new Date().toISOString(); + const existing = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; + if (!existing) throw new Error(`Workflow work item ${id} not found`); + const fromState = this.normalizeWorkflowWorkItemState(existing.state); + if (this.isTerminalWorkflowWorkItemState(fromState) && fromState !== state) { + throw new Error(`Workflow work item ${id} is terminal (${fromState}) and cannot transition to ${state}`); + } + + this.db + .prepare( + `UPDATE workflow_work_items + SET state = ?, + attempt = ?, + retryAfter = ?, + leaseOwner = ?, + leaseExpiresAt = ?, + lastError = ?, + blockedReason = ?, + updatedAt = ? + WHERE id = ?`, + ) + .run( + state, + patch.attempt ?? existing.attempt, + patch.retryAfter === undefined ? existing.retryAfter : patch.retryAfter, + patch.leaseOwner === undefined ? existing.leaseOwner : patch.leaseOwner, + patch.leaseExpiresAt === undefined ? existing.leaseExpiresAt : patch.leaseExpiresAt, + patch.lastError === undefined ? existing.lastError : patch.lastError, + patch.blockedReason === undefined ? existing.blockedReason : patch.blockedReason, + now, + id, + ); + + const updated = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; + if (!updated) throw new Error(`Workflow work item ${id} disappeared`); + this.insertRunAuditEventRow({ + taskId: updated.taskId, + runId: updated.runId, + domain: "database", + mutationType: "workflowWorkItem:transition", + target: updated.id, + metadata: { id: updated.id, fromState, toState: state, attempt: updated.attempt }, + }); + return this.rowToWorkflowWorkItem(updated); + }); + } + + getWorkflowWorkItem(id: string): WorkflowWorkItem | null { + const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; + return row ? this.rowToWorkflowWorkItem(row) : null; + } + + listWorkflowWorkItemsForTask(taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): WorkflowWorkItem[] { + const conditions = ["taskId = ?"]; + const params: unknown[] = [taskId]; + if (opts.kinds?.length) { + conditions.push(`kind IN (${opts.kinds.map(() => "?").join(", ")})`); + params.push(...opts.kinds); + } + const rows = this.db + .prepare( + `SELECT * + FROM workflow_work_items + WHERE ${conditions.join(" AND ")} + ORDER BY createdAt ASC, id ASC`, + ) + .all(...params) as WorkflowWorkItemRow[]; + return rows.map((row) => this.rowToWorkflowWorkItem(row)); + } + + cancelActiveWorkflowWorkItemsForTask( + taskId: string, + opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null } = {}, + ): WorkflowWorkItem[] { + return this.db.transactionImmediate(() => { + const activeStates: WorkflowWorkItemState[] = ["runnable", "running", "held", "retrying", "manual-required"]; + const items = this.listWorkflowWorkItemsForTask(taskId, opts).filter((item) => activeStates.includes(item.state)); + return items.map((item) => + this.transitionWorkflowWorkItem(item.id, "cancelled", { + now: opts.now, + leaseOwner: null, + leaseExpiresAt: null, + lastError: opts.lastError ?? item.lastError ?? "cancelled-by-user-hard-cancel", + }), + ); + }); + } + + listDueWorkflowWorkItems(filter: WorkflowWorkItemDueFilter = {}): WorkflowWorkItem[] { + const now = filter.now ?? new Date().toISOString(); + const includeExpiredRunning = !filter.states || filter.states.includes("running"); + const states = filter.states?.length ? filter.states : ["runnable", "retrying"]; + const stateConditions = [`(state IN (${states.map(() => "?").join(", ")}) AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?))`]; + const params: unknown[] = [...states, now]; + if (includeExpiredRunning) { + stateConditions.push("(state = 'running' AND leaseExpiresAt IS NOT NULL AND leaseExpiresAt <= ?)"); + params.push(now); + } + const conditions = [ + `(${stateConditions.join(" OR ")})`, + "(retryAfter IS NULL OR retryAfter <= ?)", + ]; + params.push(now); + if (filter.kinds?.length) { + conditions.push(`kind IN (${filter.kinds.map(() => "?").join(", ")})`); + params.push(...filter.kinds); + } + params.push(filter.limit ?? 100); + + const rows = this.db + .prepare( + `SELECT * + FROM workflow_work_items + WHERE ${conditions.join(" AND ")} + ORDER BY retryAfter IS NOT NULL, retryAfter ASC, createdAt ASC + LIMIT ?`, + ) + .all(...params) as WorkflowWorkItemRow[]; + return rows.map((row) => this.rowToWorkflowWorkItem(row)); + } + + acquireWorkflowWorkItemLease( + id: string, + leaseOwner: string, + opts: { leaseDurationMs: number; now?: string }, + ): WorkflowWorkItem | null { + if (opts.leaseDurationMs <= 0) { + throw new Error(`workflow work item leaseDurationMs must be > 0 (received ${opts.leaseDurationMs})`); + } + + return this.db.transactionImmediate(() => { + const now = opts.now ?? new Date().toISOString(); + const leaseExpiresAt = new Date(new Date(now).getTime() + opts.leaseDurationMs).toISOString(); + const result = this.db + .prepare( + `UPDATE workflow_work_items + SET state = 'running', + leaseOwner = ?, + leaseExpiresAt = ?, + updatedAt = ? + WHERE id = ? + AND state IN ('runnable', 'retrying', 'running') + AND (retryAfter IS NULL OR retryAfter <= ?) + AND (leaseExpiresAt IS NULL OR leaseExpiresAt <= ?)`, + ) + .run(leaseOwner, leaseExpiresAt, now, id, now, now); + if (result.changes === 0) return null; + + const row = this.db.prepare("SELECT * FROM workflow_work_items WHERE id = ?").get(id) as WorkflowWorkItemRow | undefined; + if (!row) throw new Error(`Workflow work item ${id} disappeared`); + this.insertRunAuditEventRow({ + taskId: row.taskId, + runId: row.runId, + domain: "database", + mutationType: "workflowWorkItem:lease-acquired", + target: row.id, + metadata: { id: row.id, leaseOwner: row.leaseOwner, leaseExpiresAt: row.leaseExpiresAt }, + }); + return this.rowToWorkflowWorkItem(row); + }); + } + setCompletionHandoffAcceptedMarker( taskId: string, opts: { source: string; acceptedAt?: string }, @@ -9005,6 +9398,118 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return event; } + private isLegacyAutoMergeStampCandidate(task: Pick<Task, "column" | "autoMerge" | "autoMergeProvenance">): boolean { + return task.column === "in-review" && task.autoMerge === true && task.autoMergeProvenance !== "user"; + } + + private async listLegacyAutoMergeStampCandidates(): Promise<Task[]> { + const inReview = await this.listTasks({ column: "in-review" }); + return inReview.filter((task) => this.isLegacyAutoMergeStampCandidate(task)); + } + + /** + * Dry-run or apply the operator-driven cleanup for legacy review-entry + * auto-merge stamps. Dry-run is the default and only reports candidates. + * With apply=true, ambiguous legacy stamps are cleared so the task follows the + * live global autoMerge setting again. Explicit user overrides are never + * candidates and are preserved. + */ + async reconcileLegacyAutoMergeStamps(options?: { apply?: boolean }): Promise<LegacyAutoMergeStampReconcileResult[]> { + const candidates = await this.listLegacyAutoMergeStampCandidates(); + const results: LegacyAutoMergeStampReconcileResult[] = []; + + if (options?.apply !== true) { + return candidates.map((task) => ({ taskId: task.id, column: task.column, cleared: false })); + } + + for (const candidate of candidates) { + const current = await this.getTask(candidate.id); + if (!current || !this.isLegacyAutoMergeStampCandidate(current)) { + continue; + } + + const priorAutoMerge = current.autoMerge; + const priorProvenance = current.autoMergeProvenance; + current.autoMerge = undefined; + current.autoMergeProvenance = undefined; + current.updatedAt = new Date().toISOString(); + + await this.atomicWriteTaskJson(this.taskDir(current.id), current); + if (this.isWatching) this.taskCache.set(current.id, { ...current }); + this.emitTaskLifecycleEventSafely("task:updated", [current]); + + this.recordRunAuditEvent({ + taskId: current.id, + agentId: "system", + runId: `legacy-auto-merge-stamp-clear-${current.id}-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-cleared", + target: current.id, + metadata: { + taskId: current.id, + priorAutoMerge, + priorAutoMergeProvenance: priorProvenance ?? null, + action: "cleared-to-follow-global-autoMerge", + }, + }); + results.push({ taskId: current.id, column: current.column, cleared: true }); + } + + return results; + } + + private async markLegacyAutoMergeStampsOnce(): Promise<void> { + const markerRow = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(LEGACY_AUTO_MERGE_STAMP_MARKER_KEY) as + | { value: string } + | undefined; + if (markerRow?.value === LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION) { + return; + } + + const candidates = await this.listLegacyAutoMergeStampCandidates(); + const markedTaskIds: string[] = []; + for (const candidate of candidates) { + const current = await this.getTask(candidate.id); + if (!current || !this.isLegacyAutoMergeStampCandidate(current)) { + continue; + } + current.autoMergeProvenance = "legacy-stamp"; + current.updatedAt = new Date().toISOString(); + await this.atomicWriteTaskJson(this.taskDir(current.id), current); + if (this.isWatching) this.taskCache.set(current.id, { ...current }); + this.emitTaskLifecycleEventSafely("task:updated", [current]); + markedTaskIds.push(current.id); + + this.recordRunAuditEvent({ + taskId: current.id, + agentId: "system", + runId: `legacy-auto-merge-stamp-mark-${current.id}-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-marked", + target: current.id, + metadata: { + taskId: current.id, + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + action: "marked-only-no-behavior-change", + }, + }); + } + + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(LEGACY_AUTO_MERGE_STAMP_MARKER_KEY, LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION); + this.db.bumpLastModified(); + + storeLog.log("legacy auto-merge stamp marker completed", { + phase: "legacy-auto-merge-stamp-marker", + markedCount: markedTaskIds.length, + markedTaskIds: markedTaskIds.slice(0, 50), + truncated: markedTaskIds.length > 50, + }); + } + /** * Query run-audit events with optional filters. * @@ -10298,7 +10803,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } /** - * Archive a done task (move from done → archived). + * Archive a live task (move from any non-archived column → archived). * Logs the action and emits `task:moved` event. * @param optionsOrCleanup - Boolean cleanup flag for backward compatibility, * or an options object that also allows removeLineageReferences. @@ -10316,12 +10821,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task.log = []; } - if (task.column !== "done") { + if (task.column === "archived") { throw new Error( - `Cannot archive ${id}: task is in '${task.column}', must be in 'done'`, + `Cannot archive ${id}: task is already archived`, ); } + const fromColumn = task.column as Column; + task.preArchiveColumn = fromColumn; + const cleanup = typeof optionsOrCleanup === "boolean" ? optionsOrCleanup : optionsOrCleanup.cleanup !== false; const removeLineageReferences = typeof optionsOrCleanup === "object" && optionsOrCleanup.removeLineageReferences === true; const lineageChildIds = this.findLiveLineageChildren(id); @@ -10349,11 +10857,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); await this.atomicWriteTaskJson(dir, task); + await this.writeTaskJsonFile(dir, task); if (this.isWatching) this.taskCache.set(id, { ...task }); for (const lineageChild of rewrittenLineageChildren) { this.emit("task:updated", lineageChild); } - this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" }); + this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); return task; } @@ -10386,7 +10895,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} for (const lineageChild of rewrittenLineageChildren) { this.emit("task:updated", lineageChild); } - this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column, source: "engine" }); + this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); return this.archiveEntryToTask(entry, false); }); } @@ -10399,8 +10908,28 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return this.archiveTask(id, true); } + private resolveUnarchiveTargetColumn(preArchiveColumn: unknown): Column { + if (!isColumn(preArchiveColumn) || preArchiveColumn === "archived") { + return "done"; + } + if (preArchiveColumn === "in-progress" || preArchiveColumn === "in-review") { + return "todo"; + } + return preArchiveColumn; + } + + private async readPreArchiveColumnFromTaskFile(dir: string): Promise<Column | undefined> { + try { + const raw = await readFile(join(dir, "task.json"), "utf-8"); + const parsed = JSON.parse(raw) as { preArchiveColumn?: unknown }; + return isColumn(parsed.preArchiveColumn) ? parsed.preArchiveColumn : undefined; + } catch { + return undefined; + } + } + /** - * Unarchive an archived task (move from archived → done). + * Unarchive an archived task (move from archived → its recorded source column). * If the active task row was cleaned up, restores from archive.db first. * Logs the action and emits `task:moved` event. */ @@ -10438,16 +10967,18 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // NOTE: No getTaskMergeBlocker check here — intentionally. // The merge blocker validates in-review → done transitions (ensuring code // has been properly reviewed before merging). An unarchived task was already - // merged in its previous lifecycle; this is just a restoration. The transient - // field clearing above ensures no stale blocker state leaks through. - task.column = "done"; + // archived in its previous lifecycle; this is just a restoration. The transient + // field clearing below ensures no stale blocker state leaks through. + const preArchiveColumn = task.preArchiveColumn ?? await this.readPreArchiveColumnFromTaskFile(dir); + const toColumn = this.resolveUnarchiveTargetColumn(preArchiveColumn); + task.column = toColumn; + task.preArchiveColumn = undefined; task.columnMovedAt = new Date().toISOString(); task.updatedAt = task.columnMovedAt; - // Clear transient fields that should not persist into "done" column. - // Matches the clearing done by moveTask() for consistency — archived - // tasks may have been archived with stale state that should not reappear - // after unarchiving. + // Clear transient fields regardless of the restored column. Archived tasks + // may have been archived with stale execution state that should not reappear + // after unarchiving, especially when active columns are downgraded to todo. this.clearDoneTransientFields(task); task.log.push({ @@ -10461,7 +10992,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Update cache if watcher is active if (this.isWatching) this.taskCache.set(id, { ...task }); - this.emit("task:moved", { task, from: "archived" as Column, to: "done" as Column, source: "engine" }); + this.emit("task:moved", { task, from: "archived" as Column, to: toColumn, source: "engine" }); return task; }); } @@ -10541,6 +11072,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} this.taskCache.set(task.id, { ...task }); } + try { + await this.markLegacyAutoMergeStampsOnce(); + } catch (err) { + storeLog.warn("Legacy auto-merge stamp marker failed during watch startup (non-fatal)", { + phase: "watch:legacy-auto-merge-stamp-marker", + error: err instanceof Error ? err.message : String(err), + }); + } + if (!this.donePauseBackfillDone) { const repairedTaskIds: string[] = []; for (const [taskId, cachedTask] of this.taskCache.entries()) { @@ -12678,7 +13218,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} title: entry.title, description: entry.description, priority: normalizeTaskPriority(entry.priority), - column: "archived", // Will be changed to "done" by unarchiveTask + column: "archived", // Will be changed by unarchiveTask + preArchiveColumn: entry.preArchiveColumn, dependencies: entry.dependencies, steps: entry.steps, currentStep: entry.currentStep, @@ -14521,8 +15062,21 @@ ${stepsSection}`; // selectable workflow); fall back to no default rather than materializing it. if (def.kind === "fragment") return undefined; // Compile (and validate) before creating any rows so a non-compilable - // default falls back cleanly with nothing written. - const inputs = compileWorkflowToSteps(def.ir); + // default falls back cleanly with nothing written. Interpreter-deferred + // built-ins are valid selectable workflows but not lowerable to legacy + // WorkflowStep rows, so default materialization falls back to legacy defaults. + // Built-ins that compile to zero steps still record a stepless selection, + // mirroring explicit workflow materialization. + let inputs: import("./types.js").WorkflowStepInput[]; + try { + inputs = compileWorkflowToSteps(def.ir); + } catch (err) { + if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return undefined; + throw err; + } + if (isBuiltinWorkflowId(workflowId) && inputs.length === 0) { + return { workflowId, stepIds: [] }; + } const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); return { workflowId, stepIds }; } @@ -14541,16 +15095,23 @@ ${stepsSection}`; if (def.kind === "fragment") { throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`); } - const inputs = compileWorkflowToSteps(def.ir); + let inputs: import("./types.js").WorkflowStepInput[]; + try { + inputs = compileWorkflowToSteps(def.ir); + } catch (err) { + if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return { workflowId, stepIds: [] }; + throw err; + } const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); return { workflowId, stepIds }; } /** - * Select a workflow for a task: compile it, materialize its steps, and write - * their ids into the task's enabledWorkflowSteps. Replaces any prior selection - * (no orphaned steps). Throws WorkflowCompileError for non-linear graphs - * before any state is written. + * Select a workflow for a task: compile it when possible, materialize its + * steps, and write their ids into the task's enabledWorkflowSteps. Replaces + * any prior selection (no orphaned steps). Interpreter-deferred workflow IRs + * record the selection with zero materialized steps; genuinely invalid graphs + * still throw before any state is written. */ async selectTaskWorkflow(taskId: string, workflowId: string): Promise<string[]> { // Hold the task lock across the whole sequence (materialize → owner write → @@ -14566,8 +15127,16 @@ ${stepsSection}`; if (def.kind === "fragment") { throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`); } - // Compile once up front: a non-linear graph aborts before any mutation. - const inputs = compileWorkflowToSteps(def.ir); + // Compile once up front: invalid graphs abort before any mutation, while + // interpreter-deferred graphs keep the selection but materialize no legacy + // WorkflowStep rows. + let inputs: import("./types.js").WorkflowStepInput[]; + try { + inputs = compileWorkflowToSteps(def.ir); + } catch (err) { + if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) inputs = []; + else throw err; + } // Materialize the new steps and point the task at them BEFORE deleting the // prior selection's rows, so a mid-flight failure never leaves the task diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 0caaac894d..caa3d7bcde 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -38,7 +38,8 @@ function isFusionSiblingBranch(branch: string): boolean { * Resolves a task's effective auto-merge behavior. * Explicit per-task values (`true`/`false`) take precedence over the global * setting; when `task.autoMerge` is `undefined`, falls back to - * `settings.autoMerge`. + * `settings.autoMerge`. `autoMergeProvenance` is metadata used by legacy-stamp + * remediation; this resolver intentionally keys only on the value. */ export function resolveEffectiveAutoMerge( task: Pick<Task, "autoMerge">, @@ -52,8 +53,9 @@ export function resolveEffectiveAutoMerge( * Additive relative to the global setting: when `settings.autoMerge` is on, * every task flows through — tasks with an explicit `autoMerge: false` are * parked as `manual-required` downstream by the merger, not silently skipped - * here. When the global setting is off, only tasks with an explicit per-task - * `autoMerge: true` override proceed. Distinct from + * here. When the global setting is off, only tasks with a per-task + * `autoMerge: true` value proceed; legacy stamp provenance is surfaced and + * reconciled separately. Distinct from * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would * (incorrectly for processing gates) starve the manual-required parking path. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7202a1bc73..c8a0b1f83e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -86,6 +86,86 @@ export const MERGE_REQUEST_STATES = [ export type MergeRequestState = (typeof MERGE_REQUEST_STATES)[number]; +export const WORKFLOW_WORK_ITEM_KINDS = [ + "task", + "merge", + "retry", + "manual-hold", + "recovery", +] as const; + +export type WorkflowWorkItemKind = (typeof WORKFLOW_WORK_ITEM_KINDS)[number]; + +export const WORKFLOW_WORK_ITEM_STATES = [ + "runnable", + "running", + "held", + "retrying", + "manual-required", + "succeeded", + "failed", + "cancelled", + "exhausted", +] as const; + +export type WorkflowWorkItemState = (typeof WORKFLOW_WORK_ITEM_STATES)[number]; + +export interface WorkflowWorkItem { + id: string; + runId: string; + taskId: string; + nodeId: string; + kind: WorkflowWorkItemKind; + state: WorkflowWorkItemState; + attempt: number; + retryAfter: string | null; + leaseOwner: string | null; + leaseExpiresAt: string | null; + lastError: string | null; + blockedReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WorkflowWorkItemUpsertInput { + id?: string; + runId: string; + taskId: string; + nodeId: string; + kind: WorkflowWorkItemKind; + state?: WorkflowWorkItemState; + attempt?: number; + retryAfter?: string | null; + leaseOwner?: string | null; + leaseExpiresAt?: string | null; + lastError?: string | null; + blockedReason?: string | null; + now?: string; +} + +export interface WorkflowWorkItemTransitionPatch { + attempt?: number; + retryAfter?: string | null; + leaseOwner?: string | null; + leaseExpiresAt?: string | null; + lastError?: string | null; + blockedReason?: string | null; + now?: string; +} + +export interface WorkflowWorkItemDueFilter { + now?: string; + limit?: number; + kinds?: WorkflowWorkItemKind[]; + states?: WorkflowWorkItemState[]; +} + +export interface MergeRequestWorkflowProjectionOptions { + runId?: string; + nodeId?: string; + now?: string; +} + export interface MergeQueueEntry { taskId: string; enqueuedAt: string; @@ -1990,6 +2070,8 @@ export interface Task { /** The task's current column id. Widened to {@link ColumnId} so workflow-defined * custom columns are representable; flag-OFF paths only ever store legacy ids. */ column: ColumnId; + /** Source column captured when this task is archived; used to restore sensibly. */ + preArchiveColumn?: Column; dependencies: string[]; /** User-requested hint for triage: prefer splitting into child tasks when appropriate. */ breakIntoSubtasks?: boolean; @@ -2049,12 +2131,16 @@ export interface Task { * Defaults to the project default branch when omitted. */ baseBranch?: string; /** Per-task auto-merge override. - * `undefined` means no explicit per-task value: follow `settings.autoMerge` - * and snapshot that global setting when the task enters `in-review`. - * `true`/`false` are explicit user overrides and take precedence. + * `undefined` means no explicit per-task value: follow live `settings.autoMerge`. + * `true`/`false` are explicit overrides when paired with `autoMergeProvenance: "user"`. * Distinct from GitHub PR metadata (`PrInfo.autoMergeOnGreen` / * `PrInfo.autoMergeStrategy`), which must not be conflated with this field. */ autoMerge?: boolean; + /** Provenance for `autoMerge`. + * `"user"` means a sticky explicit user-set override. + * `"legacy-stamp"` means an ambiguous value written by the pre-FN-6245 + * review-entry stamp and is operator-clearable. Absent means unknown/none. */ + autoMergeProvenance?: "user" | "legacy-stamp"; /** Actual git working branch name used for this task's worktree. May differ from * the conventional `fn/{task-id}` when conflict recovery generated a * unique suffixed name (e.g., `fn/fn-042-2`). */ @@ -2185,6 +2271,11 @@ export interface Task { * Incremented by self-healing for resume-limbo detection and reset when * progress is observed or recovery escalates to a fresh todo dispatch. */ resumeLimboCount?: number; + /** Bounded auto-retry attempts for transient workflow-graph failures observed + * immediately after engine-restart or unpause resume. Reset by manual retry + * and by successful forward progress; capped by the executor before terminal + * `status:"failed"` is recorded to preserve the FN-5704 anti-loop exemption. */ + graphResumeRetryCount?: number | null; /** Branch tip SHA snapshot captured at the last reclaim/unpause attempt used * by resume-limbo detection to determine whether commits advanced. */ resumeLimboTipSha?: string; @@ -2288,6 +2379,8 @@ export interface Task { sourceMessageId?: string; sourceParentTaskId?: string; sourceMetadata?: Record<string, unknown>; + /** Reconstructed task prompt content when available on in-memory execution tasks. */ + prompt?: string; /** Explicitly assigned user ID for task-user linking. Used during review handoff to indicate * which user should review the task. The sentinel value "requesting-user" indicates the * user who created or steered the task. */ @@ -3241,6 +3334,8 @@ export interface ProjectSettings { heartbeatMultiplier?: number; /** Number of auto-claim candidates rendered in no-task heartbeat prompts. Range: 0-10. Default: 5. */ autoClaimCandidatesInPrompt?: number; + /** Opt engineer-role agents into no-task backlog auto-claim. Default: false. */ + engineerBacklogAutoClaim?: boolean; /** Sticky window for intake duplicate checks against soft-deleted tasks. * Unit: days. Default: 7. Set to 0 to disable tombstone-window widening. */ tombstoneStickyWindowDays?: number; @@ -4066,6 +4161,10 @@ export interface Settings extends GlobalSettings, ProjectSettings { /** Whether PR authentication is currently available (read-only, set by server). * True when authenticated gh CLI access is available or token fallback exists. */ prAuthAvailable?: boolean; + /** Use the lean fast-path planning prompt variant instead of the full triage spec prompt. */ + leanPlanning?: boolean; + /** Auto-approve generated specs and skip the independent spec reviewer. */ + autoApproveSpec?: boolean; /** Index signature for dynamic settings access */ [key: string]: unknown; } @@ -4289,6 +4388,8 @@ export interface ArchivedTaskEntry { */ priority?: TaskPriority; column: "archived"; // Always archived when in the log + /** Source column captured at archive time; absent on legacy archive entries. */ + preArchiveColumn?: Column; dependencies: string[]; steps: TaskStep[]; currentStep: number; @@ -6179,6 +6280,8 @@ export interface AgentHeartbeatConfig { autoClaimRelevantTasks?: boolean; /** Number of auto-claim candidates to inject into no-task heartbeat prompts. Default: 5, range: 0-10. */ autoClaimCandidatesInPrompt?: number; + /** Per-agent override for opting engineer-role agents into no-task backlog auto-claim. Default: project setting or false. */ + engineerBacklogAutoClaim?: boolean; /** Polling interval in ms (default: 30000). Min: 1000 */ heartbeatIntervalMs?: number; /** Heartbeat timeout in ms (default: 60000). Min: 5000 */ diff --git a/packages/core/src/workflow-compiler.ts b/packages/core/src/workflow-compiler.ts index 1b6247b6e9..5d617d0a7b 100644 --- a/packages/core/src/workflow-compiler.ts +++ b/packages/core/src/workflow-compiler.ts @@ -15,11 +15,52 @@ export class WorkflowCompileError extends Error { } } +export const WORKFLOW_INTERPRETER_DEFERRED_SUFFIX = "require the workflow interpreter (deferred)"; + +export function isInterpreterDeferredWorkflowCompileError(error: unknown): boolean { + return error instanceof WorkflowCompileError && error.message.includes(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); +} + +/** Workflow-owned merge/retry/recovery policy primitives. The WorkflowStep + * compiler treats this region as a terminal engine-owned boundary: these nodes + * may branch internally, are not emitted as steps, and are not walked by the + * linear step compiler. */ +export const MERGE_REGION_NODE_KINDS: ReadonlySet<WorkflowIrNode["kind"]> = new Set([ + "merge-gate", + "merge-attempt", + "manual-merge-hold", + "retry-backoff", + "recovery-router", + "branch-group-member-integration", + "branch-group-promotion", +]); + +function isMergeRegionKind(node: WorkflowIrNode): boolean { + return MERGE_REGION_NODE_KINDS.has(node.kind); +} + /** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the * fixed planning → execute → workflow-step → review → merge pipeline and are * not emitted as steps. */ const SEAM_NAMES = new Set(["planning", "execute", "workflow-step", "review", "merge"]); +const ENGINE_PRIMITIVE_NODE_KINDS = new Set<WorkflowIrNode["kind"]>([ + "merge-gate", + "merge-attempt", + "manual-merge-hold", + "retry-backoff", + "recovery-router", + "branch-group-member-integration", + "branch-group-promotion", + "pr-create", + "pr-respond", + "pr-merge", +]); + +function isEnginePrimitive(node: WorkflowIrNode): boolean { + return ENGINE_PRIMITIVE_NODE_KINDS.has(node.kind); +} + function seamOf(node: WorkflowIrNode): string | undefined { const seam = node.config?.seam; return typeof seam === "string" && SEAM_NAMES.has(seam) ? seam : undefined; @@ -49,9 +90,11 @@ function mainEdge(edges: WorkflowIrEdge[]): WorkflowIrEdge | undefined { * post-merge chain the WorkflowStep engine can run. Returns a * WorkflowCompileError describing the first problem, or null when compilable. * - * Allowed shape: a single path from start to end. Seam nodes may carry an extra - * `failure` edge to the end node; every other non-terminal node has exactly one - * outgoing edge. Anything else (true branching) requires the deferred interpreter. + * Allowed shape: a single path from start to end or to the engine-owned merge + * policy region. Seam nodes may carry an extra `failure` edge to the end node; + * merge-policy primitives are terminal and may fan out internally; every other + * non-terminal node has exactly one outgoing edge. Anything else (true + * branching) requires the deferred interpreter. */ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { const nodesById = new Map(ir.nodes.map((node) => [node.id, node])); @@ -74,6 +117,13 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { if (outs.length > 0) return new WorkflowCompileError("end node must have no outgoing edges"); continue; } + if (isEnginePrimitive(node)) { + continue; + } + + if (isMergeRegionKind(node)) { + continue; + } const seam = seamOf(node); if (seam) { @@ -101,12 +151,12 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { return new WorkflowCompileError(`node '${node.id}' has no outgoing edge`); } if (outs.length > 1) { - // NOTE: the `require the workflow interpreter (deferred)` suffix is matched - // by the dashboard editor (WorkflowNodeEditor handleSave, KTD-4) to render - // an info-tone "interpreter-only" banner instead of an error. Keep both - // interpreter-deferred messages carrying this exact suffix in sync. + // NOTE: WORKFLOW_INTERPRETER_DEFERRED_SUFFIX is matched by the dashboard + // editor/routes (KTD-4) to render an info-tone "interpreter-only" banner + // instead of an error. Keep interpreter-deferred messages carrying this + // exact suffix in sync. return new WorkflowCompileError( - `node '${node.id}' branches into ${outs.length} edges — graphs with branches require the workflow interpreter (deferred)`, + `node '${node.id}' branches into ${outs.length} edges — graphs with branches ${WORKFLOW_INTERPRETER_DEFERRED_SUFFIX}`, ); } } @@ -121,10 +171,15 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { const seenSeams = new Set<string>(); let nextExpectedSeamIndex = 0; const visited = new Set<string>(); + let reachedTerminal = false; let cursor: string | undefined = startNode.id; while (cursor && !visited.has(cursor)) { visited.add(cursor); const node = nodesById.get(cursor); + if (node && isMergeRegionKind(node)) { + reachedTerminal = true; + break; + } const seam = node ? seamOf(node) : undefined; if (seam) { if (seenSeams.has(seam)) { @@ -144,16 +199,19 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { seenSeams.add(seam); nextExpectedSeamIndex += 1; } - if (cursor === endNode.id) break; + if (cursor === endNode.id || (node && isEnginePrimitive(node))) { + reachedTerminal = true; + break; + } cursor = mainEdge(outgoing.get(cursor) ?? [])?.to; } - if (!visited.has(endNode.id)) { + if (!reachedTerminal) { return new WorkflowCompileError("workflow main path does not reach the end node"); } - const unreached = ir.nodes.filter((node) => !visited.has(node.id)); + const unreached = ir.nodes.filter((node) => !visited.has(node.id) && node.kind !== "end" && !isEnginePrimitive(node)); if (unreached.length > 0) { return new WorkflowCompileError( - `node '${unreached[0].id}' is not on the main path — disconnected nodes require the workflow interpreter (deferred)`, + `node '${unreached[0].id}' is not on the main path — disconnected nodes ${WORKFLOW_INTERPRETER_DEFERRED_SUFFIX}`, ); } @@ -212,7 +270,9 @@ function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge" * Compile a workflow graph into an ordered list of WorkflowStep inputs ready to * persist and run on the existing engine. User prompt/script/gate nodes become * steps; execute/review seams are skipped; the merge seam is the pre-/post-merge - * boundary. Throws WorkflowCompileError for non-linear graphs. + * boundary; merge-policy primitives form a terminal engine-owned region that is + * skipped and never emitted as steps. Throws WorkflowCompileError for non-linear + * graphs. * * The returned array order is the execution order (it maps directly onto a * task's `enabledWorkflowSteps`). @@ -236,7 +296,15 @@ export function compileWorkflowToSteps(ir: WorkflowIr): WorkflowStepInput[] { const node = nodesById.get(cursor); if (!node) break; + if (isMergeRegionKind(node)) { + break; + } + const seam = seamOf(node); + if (isEnginePrimitive(node)) { + break; + } + if (seam === "merge") { phase = "post-merge"; } else if (!seam && node.kind !== "start" && node.kind !== "end") { diff --git a/packages/core/src/workflow-ir-resolver.ts b/packages/core/src/workflow-ir-resolver.ts index c2c703aa29..5f25a733a2 100644 --- a/packages/core/src/workflow-ir-resolver.ts +++ b/packages/core/src/workflow-ir-resolver.ts @@ -25,6 +25,53 @@ export interface WorkflowIrResolverStore { getWorkflowDefinition(id: string): Promise<{ ir: string | WorkflowIr } | undefined>; } +/** + * Extract a prompt seam's prompt text from a resolved workflow IR. + * + * Seam prompt nodes are prompt nodes with `config.seam === seam`; + * `config.prompt` carries the text installed by builtinPromptConfig or a custom + * workflow author. Empty/missing prompts return undefined so callers can apply + * their own fail-soft fallback. + */ +export function resolveSeamPromptFromIr(ir: WorkflowIr, seam: string): string | undefined { + for (const node of ir.nodes) { + if (node.kind !== "prompt") continue; + if (node.config?.seam !== seam) continue; + const prompt = node.config.prompt; + if (typeof prompt === "string" && prompt.trim().length > 0) return prompt; + } + return undefined; +} + +/** Extract the planning seam prompt from a resolved workflow IR. */ +export function resolvePlanningPromptFromIr(ir: WorkflowIr): string | undefined { + return resolveSeamPromptFromIr(ir, "planning"); +} + +/** Resolve a task's seam prompt via its selected workflow IR. */ +export async function resolveTaskSeamPrompt( + store: WorkflowIrResolverStore, + taskId: string, + seam: string, + irCache?: Map<string, WorkflowIr>, +): Promise<string | undefined> { + try { + const ir = await resolveWorkflowIrForTask(store, taskId, irCache); + return resolveSeamPromptFromIr(ir, seam); + } catch { + return undefined; + } +} + +/** Resolve a task's planning seam prompt via its selected workflow IR. */ +export async function resolveTaskPlanningPrompt( + store: WorkflowIrResolverStore, + taskId: string, + irCache?: Map<string, WorkflowIr>, +): Promise<string | undefined> { + return resolveTaskSeamPrompt(store, taskId, "planning", irCache); +} + /** * Resolve a workflow IR by its id (built-in or custom). * diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 6256537432..f2d5c90e1d 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -5,6 +5,10 @@ * verdicts as outcome edges), `parse-steps` (graph-native step-list parsing), * `code` (sandboxed TypeScript), `notify` (workflow-authored notifications), * and `loop` (bounded repeat-until region); + * and the workflow-owned merge/retry/recovery policy additions: + * `merge-gate`, `merge-attempt`, `manual-merge-hold`, `retry-backoff`, + * `recovery-router`, `branch-group-member-integration`, and + * `branch-group-promotion`; * and the unified PR-entity additions (U3): * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ @@ -23,6 +27,13 @@ export type WorkflowIrNodeKind = | "parse-steps" | "code" | "notify" + | "merge-gate" + | "merge-attempt" + | "manual-merge-hold" + | "retry-backoff" + | "recovery-router" + | "branch-group-member-integration" + | "branch-group-promotion" | "pr-create" | "pr-respond" | "pr-merge"; @@ -300,6 +311,14 @@ export interface WorkflowIrV1 { edges: WorkflowIrEdge[]; } +/** Workflow-declared optional step backed by a workflow-step template. + * Execution-inert: consumed by create/edit UI to seed per-task + * `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */ +export interface WorkflowOptionalStep { + templateId: string; + defaultOn?: boolean; +} + /** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. * Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13) * declarations — both additive; absent on legacy graphs. */ @@ -314,6 +333,9 @@ export interface WorkflowIrV2 { /** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on * legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */ settings?: WorkflowSettingDefinition[]; + /** Optional workflow-step templates tasks may independently enable/disable via + * `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */ + optionalSteps?: WorkflowOptionalStep[]; } /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index a0cbdf400d..ff13a996b3 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -13,6 +13,7 @@ import type { WorkflowFieldType, WorkflowSettingDefinition, WorkflowSettingType, + WorkflowOptionalStep, } from "./workflow-ir-types.js"; import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; @@ -1070,6 +1071,29 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo } } +function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void { + if (optionalSteps === undefined) return; + if (!Array.isArray(optionalSteps)) { + throw new WorkflowIrError("Workflow IR optionalSteps must be an array"); + } + for (const optionalStep of optionalSteps) { + if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) { + throw new WorkflowIrError("Workflow optional step must be an object"); + } + if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") { + throw new WorkflowIrError("Workflow optional step must have a non-empty templateId"); + } + if ( + optionalStep.defaultOn !== undefined && + typeof optionalStep.defaultOn !== "boolean" + ) { + throw new WorkflowIrError( + `Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`, + ); + } + } +} + function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(ir.columns)) { throw new WorkflowIrError("Workflow IR v2 columns must be an array"); @@ -1231,6 +1255,7 @@ function validateV2(ir: WorkflowIrV2): void { validateNotifyNodes(ir.nodes); validateFields(ir.fields); validateSettings(ir.settings); + validateOptionalSteps(ir.optionalSteps); // Rework edges are legal intra-template (foreach, KTD-5) and — since U6 // generalized the bounded-rework mechanism to the top-level walk — for a @@ -1338,12 +1363,13 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { if (!V1_NODE_KINDS.has(node.kind)) return ir; } - // Step-inversion declarations (artifacts/fields) and workflow settings (U1) - // are v2-only features. + // Step-inversion declarations (artifacts/fields), workflow settings (U1), and + // optional workflow-step declarations are v2-only features. if ( (ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0) || - (ir.settings && ir.settings.length > 0) + (ir.settings && ir.settings.length > 0) || + (ir.optionalSteps && ir.optionalSteps.length > 0) ) { return ir; } diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts new file mode 100644 index 0000000000..6b1c714ee2 --- /dev/null +++ b/packages/core/src/workflow-optional-steps.ts @@ -0,0 +1,46 @@ +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { WORKFLOW_STEP_TEMPLATES, type WorkflowStepTemplate } from "./types.js"; + +export interface ResolvedWorkflowOptionalStep { + templateId: string; + name: string; + description: string; + icon?: string; + phase: NonNullable<WorkflowStepTemplate["phase"]>; + defaultOn: boolean; +} + +/** + * Resolve workflow-declared optional step template ids into display metadata. + * + * The declaration is intentionally execution-inert: it only advertises which + * template-backed workflow steps a task may toggle into `enabledWorkflowSteps`. + * Unknown template ids are skipped so stale/custom declarations never render + * blank UI rows or break workflow loading. + */ +export function resolveWorkflowOptionalSteps( + ir: WorkflowIr, + pluginTemplates: WorkflowStepTemplate[] = [], +): ResolvedWorkflowOptionalStep[] { + if (ir.version !== "v2" || !ir.optionalSteps?.length) return []; + + const templates = new Map<string, WorkflowStepTemplate>(); + for (const template of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) { + templates.set(template.id, template); + } + + const resolved: ResolvedWorkflowOptionalStep[] = []; + for (const optionalStep of ir.optionalSteps) { + const template = templates.get(optionalStep.templateId); + if (!template) continue; + resolved.push({ + templateId: optionalStep.templateId, + name: template.name, + description: template.description, + icon: template.icon, + phase: template.phase ?? "pre-merge", + defaultOn: optionalStep.defaultOn ?? template.defaultOn ?? false, + }); + } + return resolved; +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 468dba67ac..ec8e682047 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -7,12 +7,14 @@ const maxWorkers = computeMaxWorkers(); export default defineConfig({ resolve: { alias: { + "@fusion/core": resolve(__dirname, "./src/index.ts"), "@fusion/test-utils": resolve(__dirname, "./src/__test-utils__/workspace.ts"), "@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"), }, }, test: { include: ["src/**/*.test.ts"], + exclude: [], setupFiles: [ "./src/__test-utils__/vitest-setup.ts", ], diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index 3e08081142..5481aa048f 100644 --- a/packages/dashboard/CHANGELOG.md +++ b/packages/dashboard/CHANGELOG.md @@ -1,5 +1,23 @@ # @fusion/dashboard +## 0.42.0 + +### Patch Changes + +- Updated dependencies [630b2a8] + - @fusion/engine@0.42.0 + - @fusion/core@0.42.0 + - @fusion/i18n@0.39.4 + - @fusion-plugin-examples/cli-printing-press@0.1.21 + - @fusion-plugin-examples/compound-engineering@0.1.4 + - @fusion-plugin-examples/dependency-graph@0.1.35 + - @fusion-plugin-examples/roadmap@0.1.23 + - @fusion-plugin-examples/cursor-runtime@0.1.23 + - @fusion-plugin-examples/droid-runtime@0.1.30 + - @fusion-plugin-examples/hermes-runtime@0.2.54 + - @fusion-plugin-examples/openclaw-runtime@0.2.54 + - @fusion-plugin-examples/paperclip-runtime@0.2.54 + ## 0.41.0 ### Patch Changes diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d4e6272c93..358e603710 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -63,7 +63,7 @@ import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; -import { isIOS, useMobileScrollLock } from "./hooks/useMobileScrollLock"; +import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; @@ -508,6 +508,8 @@ function AppInner() { } }, [initialLoadComplete]); + const [quickChatOpen, setQuickChatOpen] = useState(false); + const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); // Keyboard visibility controls both MobileNavBar rendering and whether // the project content reserves bottom padding for the mobile nav bar. @@ -532,6 +534,7 @@ function AppInner() { isMobile, keyboardOpen, anyModalOpen: modalManager.anyModalOpen, + overlayOpen: isMobile && quickChatOpen, isIOS: isIOS(), }); const mobileKeyboardOpen = footerHidden; @@ -541,7 +544,9 @@ function AppInner() { // shift the document or visualViewport, and so the dashboard snaps back // into place when the keyboard dismisses. Modals manage their own lock // via useMobileScrollLock — the reference-counted hook handles overlap. - useMobileScrollLock(mobileKeyboardOpen); + useMobileKeyboardViewportLock(mobileKeyboardOpen); + // Complements FN-6362's keyboard metrics reset by recovering stale document scroll on foreground. + useMobileViewportRestoreReset(isMobile); // App-level mailbox/chat unread state (used for header/mobile nav badges) const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); @@ -787,7 +792,6 @@ function AppInner() { setSelectedPrId(undefined); } }, [selectedPrId, taskView]); - const [quickChatOpen, setQuickChatOpen] = useState(false); const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false); const [dashboardHealth, setDashboardHealth] = useState<DashboardHealthResponse | null>(null); const [dbCorruptionRefreshing, setDbCorruptionRefreshing] = useState(false); @@ -1815,6 +1819,7 @@ function AppInner() { searchQuery={searchQuery} lastFetchTimeMs={lastFetchTimeMs} prAuthAvailable={prAuthAvailable} + autoMerge={autoMerge} onCreateWorkflow={openCreateWorkflowWithNav} /> </PageErrorBoundary> @@ -2136,7 +2141,7 @@ function AppInner() { }} taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }} deepLink={{ handleDetailClose }} - settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }} + settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }} onSettingsClose={handleSettingsCloseWithNav} onReopenOnboarding={reopenOnboardingWithNav} onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")} diff --git a/packages/dashboard/app/__tests__/activity-log-tablet-layout.test.ts b/packages/dashboard/app/__tests__/activity-log-tablet-layout.test.ts new file mode 100644 index 0000000000..4452309089 --- /dev/null +++ b/packages/dashboard/app/__tests__/activity-log-tablet-layout.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; + +/** + * Stylesheet regression test for Activity Log tablet layout. + * + * The desktop .modal-lg width is too narrow for the Activity Log header at + * tablet widths, so a component-scoped tablet media block must widen only the + * Activity Log modal and wrap the header controls. If these tablet rules are + * removed, refresh/close can be clipped between 769px and 1024px. + */ +describe("activity-log-tablet-layout.css", () => { + const cssContent = loadAllAppCss(); + + function extractTabletMediaBlocks(content: string): string { + const blocks: string[] = []; + const regex = /@media[^{}]*\(min-width:\s*769px\)[^{}]*\(max-width:\s*1024px\)[^{}]*\{/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + + while (braceCount > 0 && endIdx < content.length) { + if (content[endIdx] === "{") braceCount++; + if (content[endIdx] === "}") braceCount--; + endIdx++; + } + + if (braceCount === 0) { + blocks.push(content.slice(startIdx, endIdx - 1)); + } + } + + return blocks.join("\n"); + } + + const tabletCss = extractTabletMediaBlocks(cssContent); + + it("defines tablet Activity Log rules for the broken 769px–1024px range", () => { + expect(tabletCss).toContain(".activity-log-modal"); + expect(tabletCss).toContain(".activity-log-header"); + }); + + it("widens only the Activity Log modal beyond the modal-lg base width", () => { + expect(tabletCss).toMatch(/\.activity-log-modal\s*\{[^}]*width:\s*calc\(100vw\s*-\s*var\(--space-2xl\)\)/); + expect(tabletCss).toMatch(/\.activity-log-modal\s*\{[^}]*max-width:\s*calc\(100vw\s*-\s*var\(--space-2xl\)\)/); + }); + + it("does not redefine the global modal-lg width inside the tablet block", () => { + expect(tabletCss).not.toMatch(/\.modal-lg\s*\{/); + }); + + it("allows the Activity Log header to wrap on tablet", () => { + expect(tabletCss).toMatch(/\.activity-log-header\s*\{[^}]*flex-wrap:\s*wrap/); + }); + + it("moves actions to a reachable wrapping row on tablet", () => { + expect(tabletCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex:\s*1\s+1\s+100%/); + expect(tabletCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex-wrap:\s*wrap/); + }); + + it("keeps the close button pinned to the top-right row on tablet", () => { + expect(tabletCss).toMatch(/\.activity-log-header\s+\.modal-close\s*\{[^}]*order:\s*\d/); + expect(tabletCss).toMatch(/\.activity-log-header\s+\.modal-close\s*\{[^}]*margin-left:\s*auto/); + }); + + it("keeps filters and refresh/clear controls reachable when optional controls render", () => { + expect(tabletCss).toMatch(/\.activity-log-filter,\s*\n\s*\.activity-log-filter--project\s*\{[^}]*flex:\s*1\s+1\s+0/); + expect(tabletCss).toMatch(/\.activity-log-refresh,\s*\n\s*\.activity-log-clear\s*\{[^}]*flex-shrink:\s*0/); + }); +}); diff --git a/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts new file mode 100644 index 0000000000..72dbb4cb5d --- /dev/null +++ b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture"; + +/** Extract all content inside @media (max-width: 768px) blocks. */ +function extractMobileMediaBlocks(content: string): string { + const blocks: string[] = []; + const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g; + let match; + + while ((match = regex.exec(content)) !== null) { + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + while (braceCount > 0 && endIdx < content.length) { + if (content[endIdx] === "{") braceCount++; + if (content[endIdx] === "}") braceCount--; + endIdx++; + } + if (braceCount === 0) { + blocks.push(content.slice(startIdx, endIdx - 1)); + } + } + return blocks.join("\n"); +} + +function extractRuleBlock(content: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return content.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0] ?? ""; +} + +describe("board-mobile-overscroll-containment (FN-6378)", () => { + const cssContent = loadAllAppCss(); + const baseCss = loadAllAppCssBaseOnly(); + const mobileCss = extractMobileMediaBlocks(cssContent); + + it("mobile .board contains horizontal overscroll while preserving intentional scroll and proximity snap", () => { + const boardBlock = extractRuleBlock(mobileCss, ".board"); + + expect(boardBlock).toContain("overflow-x: auto"); + expect(boardBlock).toContain("overscroll-behavior-x: contain"); + expect(boardBlock).toContain("scroll-snap-type: x proximity"); + expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); + }); + + it("base .board contains horizontal overscroll for shared and tablet board scrollers", () => { + const boardBlock = extractRuleBlock(baseCss, ".board"); + + expect(boardBlock).toContain("overflow-x: auto"); + expect(boardBlock).toContain("overscroll-behavior-x: contain"); + expect(boardBlock).toContain("scroll-snap-type: x proximity"); + expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); + }); + + it("workflow columns and multi-lane column strips contain horizontal overscroll", () => { + const workflowColumnsBlock = extractRuleBlock(baseCss, ".board.board-workflow-columns"); + const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns"); + + for (const block of [workflowColumnsBlock, laneColumnsBlock]) { + expect(block).toContain("overflow-x: auto"); + expect(block).toContain("overscroll-behavior-x: contain"); + expect(block).toContain("scroll-snap-type: x proximity"); + expect(block).not.toContain("scroll-snap-type: x mandatory"); + } + }); +}); diff --git a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts index b3e806056b..247a1e1fff 100644 --- a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts +++ b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, relative, resolve, sep } from "node:path"; import { describe, expect, it } from "vitest"; const componentsDir = resolve(__dirname, "..", "components"); @@ -8,27 +8,72 @@ function stripVarFallbackRgba(content: string): string { return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, ""); } -describe("component CSS color token hygiene", () => { - it("contains no raw rgb/rgba calls outside var() fallbacks", () => { - const cssFiles = readdirSync(componentsDir) - .filter((name) => name.endsWith(".css")) - .sort(); +function findComponentCssFiles(dir = componentsDir): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = entries.flatMap((entry) => { + const entryPath = join(dir, entry.name); - const violations: string[] = []; - - for (const fileName of cssFiles) { - const filePath = join(componentsDir, fileName); - const source = readFileSync(filePath, "utf8"); - const withoutFallbacks = stripVarFallbackRgba(source); - const lines = withoutFallbacks.split(/\r?\n/); - - for (let index = 0; index < lines.length; index += 1) { - if (/rgba?\(/.test(lines[index])) { - violations.push(`${fileName}:${index + 1}:${lines[index].trim()}`); - } - } + if (entry.isDirectory()) { + return findComponentCssFiles(entryPath); } - expect(violations).toEqual([]); + return entry.isFile() && entry.name.endsWith(".css") ? [entryPath] : []; + }); + + return files.sort((left, right) => + relative(componentsDir, left).localeCompare(relative(componentsDir, right)) + ); +} + +function formatComponentCssPath(filePath: string): string { + return relative(componentsDir, filePath).split(sep).join("/"); +} + +function findRawRgbViolations(source: string, fileName: string): string[] { + const withoutFallbacks = stripVarFallbackRgba(source); + const lines = withoutFallbacks.split(/\r?\n/); + + return lines.flatMap((line, index) => + /rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : [] + ); +} + +function buildRawRgbFailureMessage(violations: string[]): string { + return [ + "Raw rgb/rgba() found in component CSS.", + "Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead:", + ...violations, + ].join("\n"); +} + +describe("component CSS color token hygiene", () => { + it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => { + const source = [ + ".clean { color: var(--color-text); }", + ".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }", + ".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ].join("\n"); + + const violations = findRawRgbViolations(source, "fixture.css"); + + expect(violations).toEqual([ + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ]); + expect(buildRawRgbFailureMessage(violations)).toContain( + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }" + ); + expect(buildRawRgbFailureMessage(violations)).toContain( + "color-mix(in srgb, var(--color-X) N%, transparent)" + ); + }); + + it("contains no raw rgb/rgba calls outside var() fallbacks", () => { + const cssFiles = findComponentCssFiles(); + + const violations = cssFiles.flatMap((filePath) => + findRawRgbViolations(readFileSync(filePath, "utf8"), formatComponentCssPath(filePath)) + ); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); }); }); diff --git a/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts b/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts new file mode 100644 index 0000000000..2cb72cef28 --- /dev/null +++ b/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; + +function extractMediaBlocks(content: string, pattern: RegExp): string { + const blocks: string[] = []; + + for (const match of content.matchAll(pattern)) { + const start = match.index! + match[0].length; + let index = start; + let depth = 1; + while (index < content.length && depth > 0) { + if (content[index] === "{") depth++; + if (content[index] === "}") depth--; + index++; + } + expect(depth).toBe(0); + blocks.push(content.slice(start, index - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks.join("\n"); +} + +function ruleBlock(css: string, selector: string): string { + const blocks = ruleBlocks(css, selector); + expect(blocks.length, `missing CSS rule for ${selector}`).toBeGreaterThan(0); + return blocks[0]; +} + +function ruleBlocks(css: string, selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "gs"))].map((match) => match[0]); +} + +function declarationValue(rule: string, property: string): string | null { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = rule.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return match?.[1]?.trim() ?? null; +} + +describe("mobile horizontal pan containment (FN-6365)", () => { + const css = loadAllAppCss(); + const mobileCss = extractMediaBlocks(css, /@media\s*\([^)]*max-width:\s*768px[^)]*\)[^{]*\{/g); + const tabletCss = extractMediaBlocks(css, /@media\s*\(\s*min-width:\s*769px\s*\)\s*and\s*\(\s*max-width:\s*1024px\s*\)\s*\{/g); + + it("locks the document root against horizontal page panning on mobile", () => { + const rootBlock = ruleBlock(mobileCss, "html,\n body"); + const appRootBlock = ruleBlock(mobileCss, "#root"); + const starBlocks = ruleBlocks(mobileCss, "*"); + const defaultTouchBlock = starBlocks.find((block) => block.includes("touch-action: pan-y;")) ?? ""; + const widthContainmentBlock = starBlocks.find((block) => block.includes("max-inline-size: 100%;")) ?? ""; + + expect(rootBlock).toContain("overflow-x: hidden;"); + expect(rootBlock).toContain("overscroll-behavior-x: none;"); + expect(rootBlock).toContain("touch-action: pan-y;"); + expect(rootBlock).toContain("width: 100%;"); + expect(rootBlock).toContain("max-width: 100%;"); + + expect(appRootBlock).toContain("overflow-x: hidden;"); + expect(appRootBlock).toContain("overscroll-behavior-x: none;"); + expect(appRootBlock).toContain("touch-action: pan-y;"); + expect(appRootBlock).toContain("min-width: 0;"); + + expect(declarationValue(defaultTouchBlock, "touch-action")).toBe("pan-y"); + expect(widthContainmentBlock).toContain("max-width: 100%;"); + expect(widthContainmentBlock).toContain("max-inline-size: 100%;"); + }); + + it("preserves intentional horizontal scrolling for the mobile board and other opt-in scrollers", () => { + const boardBlock = ruleBlock(mobileCss, ".board"); + const codeBlock = ruleBlock(mobileCss, "pre,\n code,\n .code-block"); + const tableBlock = ruleBlock(mobileCss, "table"); + + expect(boardBlock).toContain("overflow-x: auto;"); + expect(boardBlock).toContain("scroll-snap-type: x proximity;"); + expect(boardBlock).toContain("-webkit-overflow-scrolling: touch;"); + expect(boardBlock).toContain("overscroll-behavior-x: contain;"); + expect(boardBlock).toContain("touch-action: pan-x pan-y;"); + expect(boardBlock).toContain("max-inline-size: 100%;"); + + expect(codeBlock).toContain("overflow-x: auto;"); + expect(codeBlock).toContain("touch-action: pan-x pan-y;"); + expect(tableBlock).toContain("overflow-x: auto;"); + expect(tableBlock).toContain("touch-action: pan-x pan-y;"); + }); + + it("constrains mobile fullscreen overlays to the viewport inline size", () => { + const overlayBlock = ruleBlock( + mobileCss, + ".modal-overlay:not(.confirm-dialog-overlay),\n .agent-detail-overlay,\n .agent-dialog-overlay,\n .workflow-output-modal-overlay", + ); + const modalBlock = ruleBlock( + mobileCss, + ".modal:not(.confirm-dialog),\n .modal-lg,\n .modal-md,\n .gm-modal", + ); + + expect(overlayBlock).toContain("inline-size: 100%;"); + expect(overlayBlock).toContain("max-inline-size: 100%;"); + expect(overlayBlock).toContain("overflow-x: hidden;"); + expect(overlayBlock).toContain("overscroll-behavior-x: none;"); + expect(overlayBlock).toContain("touch-action: pan-y;"); + + expect(modalBlock).toContain("inline-size: 100%;"); + expect(modalBlock).toContain("max-inline-size: 100%;"); + expect(modalBlock).toContain("min-width: 0;"); + expect(modalBlock).toContain("height: 100dvh;"); + }); + + it("leaves the tablet board horizontal overflow rule intact", () => { + const boardBlock = ruleBlock(tabletCss, ".board"); + + expect(boardBlock).toContain("grid-template-columns: repeat(6, minmax(260px, 1fr));"); + expect(boardBlock).toContain("overflow-x: auto;"); + expect(boardBlock).not.toContain("touch-action: pan-y;"); + }); +}); diff --git a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts index 382354ef58..9d3486e9ab 100644 --- a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts +++ b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts @@ -23,8 +23,8 @@ describe("task detail modal tablet width (FN-5599)", () => { const tabletBlock = tabletBlockMatch![1]; const modalRuleMatch = tabletBlock.match(/\.modal\.task-detail-modal\s*\{[^}]*\}/s); expect(modalRuleMatch).toBeTruthy(); - expect(modalRuleMatch![0]).toContain("width: min(92vw, 960px);"); - expect(modalRuleMatch![0]).toContain("max-width: 92vw;"); + expect(modalRuleMatch![0]).toContain("width: min(96vw, 1024px);"); + expect(modalRuleMatch![0]).toContain("max-width: 96vw;"); }); it("keeps mobile full-screen sheet width behavior", () => { diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b8a579d067..f8d98c8740 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5111,6 +5111,16 @@ export function fetchWorkflow(id: string, projectId?: string): Promise<import("@ return api<import("@fusion/core").WorkflowDefinition>(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId)); } +/** Fetch resolved optional step declarations for a workflow. */ +export function fetchWorkflowOptionalSteps( + workflowId: string, + projectId?: string, +): Promise<import("@fusion/core").ResolvedWorkflowOptionalStep[]> { + return api<import("@fusion/core").ResolvedWorkflowOptionalStep[]>( + withProjectId(`/workflows/${encodeURIComponent(workflowId)}/optional-steps`, projectId), + ); +} + /** Create a workflow definition. */ export function createWorkflow( input: import("@fusion/core").WorkflowDefinitionInput, @@ -6439,7 +6449,7 @@ export interface SummarizeTitleResponse { } /** Summarize a task description into a concise title using AI. - * @param description - The task description to summarize (must be 201-2000 chars) + * @param description - The task description to summarize (must be >200 chars; model input is truncated) * @param provider - Optional AI model provider (e.g., "anthropic") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") * @param projectId - Optional project ID for scoped settings resolution diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index f56125c1e2..6eeab880fb 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -241,6 +241,13 @@ border-bottom: 1px solid var(--border); background: var(--bg-secondary); flex-shrink: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.agent-detail-tabs::-webkit-scrollbar { + display: none; } .agent-detail-tab { @@ -255,6 +262,7 @@ font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) * 0.25); cursor: pointer; transition: all var(--transition-fast); + white-space: nowrap; } .agent-detail-tab:hover { diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index a2cb6c84f4..38a70ea5f9 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -3310,6 +3310,10 @@ function deriveAutoClaimRelevantTasksEnabled(runtimeConfig: AgentDetail["runtime return runtimeConfig?.autoClaimRelevantTasks !== false; } +function deriveEngineerBacklogAutoClaim(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { + return runtimeConfig?.engineerBacklogAutoClaim === true; +} + function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { return runtimeConfig?.runMissedHeartbeatOnStartup === true; } @@ -3692,6 +3696,9 @@ function ConfigTab({ const [autoClaimRelevantTasksEnabled, setAutoClaimRelevantTasksEnabled] = useState<boolean>( () => deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig), ); + const [engineerBacklogAutoClaimEnabled, setEngineerBacklogAutoClaimEnabled] = useState<boolean>( + () => deriveEngineerBacklogAutoClaim(agent.runtimeConfig), + ); const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>( () => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig), ); @@ -3979,6 +3986,7 @@ function ConfigTab({ const rc = agent.runtimeConfig ?? {}; if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true; if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true; + if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true; if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true; if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true; if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true; @@ -4058,6 +4066,7 @@ function ConfigTab({ setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig)); setHeartbeatEnabled(deriveHeartbeatEnabled(agent.runtimeConfig)); setAutoClaimRelevantTasksEnabled(deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)); + setEngineerBacklogAutoClaimEnabled(deriveEngineerBacklogAutoClaim(agent.runtimeConfig)); setRunMissedHeartbeatOnStartup(deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)); setAllowParallelExecution(deriveAllowParallelExecution(agent.runtimeConfig)); setSkipHeartbeatWhenIdle(deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)); @@ -4216,6 +4225,7 @@ function ConfigTab({ const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig }; newRuntimeConfig.enabled = heartbeatEnabled; newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled; + newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled; newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup; newRuntimeConfig.allowParallelExecution = allowParallelExecution; newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle; @@ -4326,7 +4336,7 @@ function ConfigTab({ runtimeConfig: newRuntimeConfig, bundleConfig: newBundleConfig, }; - }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); + }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => { const payload = buildSavePayload(); @@ -4769,6 +4779,19 @@ function ConfigTab({ {t("agents.autoClaimRelevantTasks", "Auto-Claim Relevant Tasks")} </label> <span className="config-hint">{t("agents.autoClaimHint", "When enabled (default), no-task heartbeats scan open unowned work and auto-claim tasks aligned with this agent's role and soul.")}</span> + <label className="checkbox-label" htmlFor="hb-engineerBacklogAutoClaim"> + <input + id="hb-engineerBacklogAutoClaim" + type="checkbox" + checked={engineerBacklogAutoClaimEnabled} + onChange={(e) => { + setEngineerBacklogAutoClaimEnabled(e.target.checked); + void scheduleAutoSave(); + }} + /> + {t("agents.engineerBacklogAutoClaim", "Engineer Backlog Auto-Claim")} + </label> + <span className="config-hint">{t("agents.engineerBacklogAutoClaimHint", "Per-agent override of the project default. Allows this engineer-role agent to auto-claim unowned backlog tasks; explicit assignment and delegation are unchanged.")}</span> </div> <div className="config-field"> diff --git a/packages/dashboard/app/components/AgentLogViewer.tsx b/packages/dashboard/app/components/AgentLogViewer.tsx index bb37865633..c71229ca4c 100644 --- a/packages/dashboard/app/components/AgentLogViewer.tsx +++ b/packages/dashboard/app/components/AgentLogViewer.tsx @@ -48,7 +48,7 @@ function formatTimestamp(iso: string, t: TFunction<"app">): string { return date.toLocaleDateString(); } -const markdownComponents: Components = { +export const markdownComponents: Components = { p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>, li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>, code: ({ children, ...props }) => { diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 773777e6de..df64775462 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -1852,11 +1852,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return ( <> <span className="agent-heartbeat-last text-secondary" title={lastAt.toLocaleString()}> - {t("agents.lastHeartbeat", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} + {t("agents.lastHeartbeatAt", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} </span> {isTicking && ( <span className="agent-heartbeat-next text-secondary" title={nextAt.toLocaleString()}> - {t("agents.nextHeartbeat", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} + {t("agents.nextHeartbeatAt", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} </span> )} </> diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 5bb9b13458..c626cd2700 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -73,6 +73,7 @@ interface AppModalsProps { }; settings: { prAuthAvailable: boolean; + autoMerge: boolean; themeMode: ThemeMode; colorTheme: ColorTheme; dashboardFontScalePct: number; @@ -298,6 +299,7 @@ export function AppModals({ onTaskUpdated={modalManager.updateDetailTask} addToast={addToast} prAuthAvailable={settings.prAuthAvailable} + autoMergeEnabled={settings.autoMerge} onOpenWorkflowEditor={() => modalManager.openWorkflowEditor()} initialTab={modalManager.detailTaskInitialTab} /> diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 4502759b59..5ce373ad8d 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -82,6 +82,37 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean { const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map(); let boardWasPreviouslyInactive = false; +// Real mobile browsers can pan the document horizontally while focusing/clicking +// an offscreen in-review auto-merge control. Keep that scroll container pinned; +// the board itself remains the only horizontal scroller. +function resetDocumentHorizontalScroll() { + const scrollingElement = document.scrollingElement as HTMLElement | null; + if (window.scrollX !== 0) { + window.scrollTo(0, window.scrollY); + } + if (scrollingElement) { + scrollingElement.scrollLeft = 0; + } + document.documentElement.scrollLeft = 0; + if (document.body) { + document.body.scrollLeft = 0; + } +} + +function scheduleDocumentHorizontalScrollReset() { + const run = () => { + resetDocumentHorizontalScroll(); + setTimeout(resetDocumentHorizontalScroll, 0); + }; + + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(run); + return; + } + + setTimeout(run, 0); +} + function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean { if (previous.size !== next.size) return false; for (const [key, value] of previous) { @@ -212,6 +243,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask if (!boardEl) return; void boardEl.offsetWidth; if (mobileQuery.matches) { + resetDocumentHorizontalScroll(); boardEl.scrollLeft = 0; } }; @@ -348,6 +380,13 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask await promoteTask(taskId, projectId); }, [projectId]); + const handleToggleAutoMerge = useCallback(() => { + onToggleAutoMerge(); + if (window.matchMedia(MOBILE_MEDIA_QUERY).matches) { + scheduleDocumentHorizontalScrollReset(); + } + }, [onToggleAutoMerge]); + const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []); const flagOn = boardWorkflows?.flagEnabled === true; @@ -563,7 +602,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} {...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} - {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge } : {})} + {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} /> ); @@ -656,7 +695,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} - {...(col === "in-review" ? { onToggleAutoMerge } : {})} + {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(col === "done" ? { onArchiveAllDone } : {})} {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} /> diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index f1a510f0b8..4e8b0c9ed5 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -14,6 +14,7 @@ /* Sidebar */ .chat-sidebar { min-width: 0; + max-width: 500px; /* FN-6210: defensive cap — matches CHAT_SIDEBAR_MAX_WIDTH */ border-right: 1px solid var(--border); display: flex; flex-direction: column; @@ -85,7 +86,6 @@ .chat-sidebar-scope-btn--active { background: var(--card); color: var(--text); - box-shadow: inset 0 calc(var(--btn-border-width) * -2) 0 var(--todo); } .chat-sidebar-rooms { @@ -267,8 +267,6 @@ } .chat-session-item--active { - border-left: calc(var(--btn-border-width) * 3) solid var(--todo); - padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3)); background: color-mix(in srgb, var(--todo) 12%, transparent); } @@ -1476,6 +1474,15 @@ overscroll-behavior: contain; } +/* FN-6212: On tablet viewports (769–1024px), cap the composer to 200px + so it doesn't consume most of the visible thread. Desktop keeps 640px; + mobile is already flex-constrained by the chat-thread layout. */ +@media (min-width: 769px) and (max-width: 1024px) { + .chat-input-textarea { + max-height: 200px; + } +} + .chat-input-textarea:focus { outline: none; border-color: var(--accent); @@ -1716,6 +1723,7 @@ .chat-sidebar { width: 100%; min-width: 100%; + max-width: 100%; /* FN-6210: mobile sidebar spans full viewport */ height: 100%; max-height: none; border-right: none; @@ -1747,12 +1755,16 @@ .chat-thread--keyboard-active { height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height)); max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height)); - /* Re-anchored: useMobileKeyboard now only updates --vv-offset-top - on resize/focus transitions (not on every visualViewport scroll - during a pan), so the transform tracks the keyboard open/close - without jittering during a swipe. */ - transform: translateY(var(--vv-offset-top, 0px)); - will-change: transform; + /* NOTE: the translateY drift compensation is applied imperatively in + JS (see ChatView's vv `apply()`), NOT here. Declaring + `transform`/`will-change: transform` in CSS keeps a non-`none` + transform on .chat-thread for the entire keyboard-active window — + and since .chat-thread is an ancestor of the focused composer + textarea, iOS Safari treats establishing that containing block as a + reason to blur the input and collapse the keyboard the instant it + opens. JS only sets a transform when there is real viewport drift + (offsetTop > 0); at the focus moment offsetTop is 0, so the + ancestor stays `transform: none` and the keyboard stays up. */ } /* On mobile, the active scope affordance uses a full-width pinned footer. */ diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 04b646e992..64d1bea9e1 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -43,7 +43,7 @@ import { useModelsCache } from "../hooks/useModelsCache"; import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; -import { useMobileScrollLock, isIOS } from "../hooks/useMobileScrollLock"; +import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock"; import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; @@ -60,19 +60,23 @@ export interface ChatViewProps { // Keep a generous cap so pasted multi-paragraph text stays visible while // still preventing the composer from overtaking the message pane on short viewports. const CHAT_INPUT_MAX_HEIGHT_PX = 640; +const TABLET_INPUT_MAX_HEIGHT_PX = 200; /** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */ const ROOM_SKIP_SENTINEL = "__SKIP__"; let chatViewWasPreviouslyInactive = false; -export function resolveChatInputOverflowY(scrollHeight: number): "auto" | "hidden" { - return scrollHeight > CHAT_INPUT_MAX_HEIGHT_PX ? "auto" : "hidden"; +export function resolveChatInputOverflowY( + scrollHeight: number, + maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX, +): "auto" | "hidden" { + return scrollHeight > maxHeight ? "auto" : "hidden"; } -export function clampChatInputHeight(scrollHeight: number): number { +export function clampChatInputHeight(scrollHeight: number, maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX): number { // Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height, // so a 0-scrollHeight measurement (e.g. before layout) still yields a // sensible inline height instead of collapsing the composer to 0. - return Math.max(40, Math.min(scrollHeight, CHAT_INPUT_MAX_HEIGHT_PX)); + return Math.max(40, Math.min(scrollHeight, maxHeight)); } function formatRelativeTime(dateStr: string, t: TFunction<"app">): string { @@ -1084,6 +1088,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView // visualViewport shrink samples do not jerk the chat thread/composer. const suppressVvShrinkRef = useRef(false); const suppressVvShrinkTimeoutRef = useRef<number | null>(null); + // Deferred drift-reset scheduled on blur; cancelled on the next focus so a + // quick re-tap never scrolls the document while iOS is raising the keyboard. + const blurScrollResetTimeoutRef = useRef<number | null>(null); const inputRef = useRef<HTMLTextAreaElement>(null); const fileInputRef = useRef<HTMLInputElement>(null); const pendingAttachmentsRef = useRef<PendingAttachment[]>([]); @@ -1584,9 +1591,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView }, [keyboardOverlap, scrollToBottom]); // Lock body scroll on mobile while the keyboard is up so iOS can't shift - // the visual viewport (offsetTop > 0). Shared hook also restores + // the visual viewport (offsetTop > 0). Uses the overflow-only keyboard + // lock (NOT position:fixed): the composer is focused before the lock + // applies, and pinning body to position:fixed afterwards blurs the input + // on iOS, collapsing the keyboard the instant it opens. Restores // window.scrollTo(0, 0) on cleanup to recover from any iOS drift. - useMobileScrollLock(isMobile && keyboardOpen); + useMobileKeyboardViewportLock(isMobile && keyboardOpen); // FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport // metrics directly to .chat-thread, avoiding React commit lag/jitter. @@ -1609,6 +1619,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const apply = () => { if (suppressVvShrinkRef.current) { thread.classList.remove("chat-thread--keyboard-active"); + thread.style.transform = ""; + thread.style.willChange = ""; return; } const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height); @@ -1619,6 +1631,22 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement); thread.classList.toggle("chat-thread--keyboard-active", keyboardActive); + + // Drift compensation is applied here (not in CSS) so .chat-thread — + // an ancestor of the focused composer textarea — only gets a + // non-`none` transform when iOS actually shifts the visual viewport + // (offsetTop > 0). Keeping a transform/will-change on it at all times + // (as the old CSS did) makes iOS Safari blur the input and collapse + // the keyboard the moment it opens, because at focus time offsetTop + // is 0 and translateY(0) still establishes a containing block over + // the focused element. + if (keyboardActive && offsetTop > 0) { + thread.style.transform = `translateY(${offsetTop}px)`; + thread.style.willChange = "transform"; + } else { + thread.style.transform = ""; + thread.style.willChange = ""; + } }; apply(); @@ -1636,6 +1664,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView window.removeEventListener("pageshow", apply); document.removeEventListener("visibilitychange", apply); thread.classList.remove("chat-thread--keyboard-active"); + thread.style.transform = ""; + thread.style.willChange = ""; }; }, [activeSession, isMobile, roomThreadActive]); @@ -1671,30 +1701,23 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView }; }, [isMobile, keyboardOpen]); - // On mount and on visibility/page restore, if iOS thinks the keyboard is - // up but the textarea isn't actually focused (or vice versa), the - // visualViewport metrics get stuck in a half-state — composer pushed up - // or covered by a blank pane. Force a blur+refocus on the textarea to - // make iOS resync. Only runs on mobile and only when ChatView holds the - // active session (avoids stealing focus from other views). - useEffect(() => { - if (!isMobile || !activeSession) return; - const resync = () => { - const ta = inputRef.current; - if (!ta) return; - if (document.activeElement !== ta) return; // only if it was focused - ta.blur(); - window.setTimeout(() => { - ta.focus({ preventScroll: true }); - }, 0); - }; - document.addEventListener("visibilitychange", resync); - window.addEventListener("pageshow", resync); - return () => { - document.removeEventListener("visibilitychange", resync); - window.removeEventListener("pageshow", resync); - }; - }, [isMobile, activeSession]); + // NOTE: a previous iOS-only "resync" effect here force-blurred and + // re-focused the active textarea on visibilitychange/pageshow to nudge + // iOS out of a stuck visualViewport half-state (composer pushed up / + // blank pane). It was removed because it was the cause of the iOS + // "keyboard won't stay up" bug: the effect only ever ran while the + // composer was already focused (its `document.activeElement !== ta` + // guard), and on iOS a programmatic focus() fired from setTimeout has + // no user-gesture context, so it cannot re-raise the keyboard after the + // blur(). In practice it never resynced the keyboard up — it only + // dismissed it whenever iOS emitted a visibilitychange (Control Center, + // notification banners, app switches, etc.) mid-session. + // + // The visualViewport half-state it targeted is now owned by + // useMobileKeyboard, which re-snapshots vv metrics on + // visibilitychange/pageshow via its settle tail + rAF stability poll — + // without ever touching textarea focus. Do not reintroduce a + // blur()+focus() resync here. useEffect(() => { const previousScope = previousChatScopeRef.current; @@ -1863,10 +1886,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView return; } + const effectiveMax = mode === "tablet" ? TABLET_INPUT_MAX_HEIGHT_PX : CHAT_INPUT_MAX_HEIGHT_PX; + composer.style.height = "auto"; - composer.style.height = `${clampChatInputHeight(composer.scrollHeight)}px`; - composer.style.overflowY = resolveChatInputOverflowY(composer.scrollHeight); - }, []); + composer.style.height = `${clampChatInputHeight(composer.scrollHeight, effectiveMax)}px`; + composer.style.overflowY = resolveChatInputOverflowY(composer.scrollHeight, effectiveMax); + }, [mode]); const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => { inputRef.current = textarea; @@ -2270,6 +2295,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView suppressVvShrinkRef.current = false; suppressVvShrinkTimeoutRef.current = null; }, 450); + + // Undo iOS layout-viewport drift HERE, on blur, not on the next focus. + // After a keyboard dismiss iOS can leave window.scrollY > 0; if that + // residual scroll is still present on the next focus, the keyboard + // lock's scrollTo(0,0) fires a *real* scroll while iOS is raising the + // keyboard and dismisses it (the "second tap dismisses" regression). + // Resetting on blur — when the keyboard is already closing, so there is + // nothing to dismiss — means the next focus starts at scrollY 0 and the + // lock's scroll is a no-op. We reset immediately and once more after the + // dismiss animation settles (iOS can re-drift mid-animation). The + // deferred reset is cancelled on focus so a fast re-tap can't scroll + // mid-raise. + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + } + blurScrollResetTimeoutRef.current = window.setTimeout(() => { + blurScrollResetTimeoutRef.current = null; + if (document.activeElement?.tagName === "TEXTAREA") return; + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } + }, 350); } if (hideSkillMenuTimeoutRef.current !== null) { @@ -2297,22 +2347,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.current = null; } - // iOS quirk: after the keyboard has been dismissed once, re-focusing - // an input leaves window.scrollY > 0 *and* visualViewport.offsetTop - // > 0 — the layout viewport drifts up, and the position:fixed - // useMobileScrollLock applies to a body that is no longer at the - // top of the document. Result: the message thread anchors above - // the visible viewport with a large blank area below it. Forcing - // scroll back to (0,0) on the focus event neutralizes the drift - // before lock applies. Done in a microtask so iOS finishes its - // own scroll-into-view first. - if (typeof window !== "undefined" && window.innerWidth <= 768) { - queueMicrotask(() => { - if (window.scrollY !== 0 || window.scrollX !== 0) { - window.scrollTo(0, 0); - } - }); + // Cancel any deferred blur drift-reset: it would scroll the document while + // iOS is raising the keyboard for THIS focus and dismiss it. + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + blurScrollResetTimeoutRef.current = null; } + // NOTE: deliberately no window.scrollTo(0,0) here. Scrolling on the focus + // event fires while iOS is still raising the soft keyboard, and iOS treats + // a programmatic scroll mid-raise as a reason to abort it — the keyboard + // opens then immediately dismisses, so the input can't be typed in. This + // mirrors QuickChatFAB's handleInputFocus, which does not scroll and works. + // Drift is instead reset on blur (see handleInputBlur), so by the time this + // focus runs the document is already at scrollY 0. }, []); useEffect(() => { @@ -2320,6 +2367,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView if (suppressVvShrinkTimeoutRef.current !== null) { window.clearTimeout(suppressVvShrinkTimeoutRef.current); } + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + } }; }, []); @@ -2869,8 +2919,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView if (window.innerWidth > 768) return; if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} rows={1} data-testid="chat-input" @@ -3409,16 +3460,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView onTouchStart={(event) => { if (typeof window === "undefined") return; if (window.innerWidth > 768) return; - // iOS-only: preventDefault + programmatic focus avoids - // iOS's visual-viewport scroll on re-focus. On Android, - // preventDefault here blocks the soft keyboard from - // opening at all (programmatic focus() does not raise - // the keyboard on Android), so the input "focuses" but - // the keyboard never appears. if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} rows={1} data-testid="chat-input" diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 658d1f162a..0f2bc0483a 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -143,7 +143,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C setName(provider.name); setApiType(provider.apiType); setBaseUrl(provider.baseUrl); - setApiKey(provider.apiKey ?? ""); + // The loaded provider's apiKey is masked (e.g. "abc•••••wxyz") for display. + // Never seed the editable field with the mask — echoing it back would send a + // masked value to save/probe (which the server rejects). Start empty; an + // unchanged blank field leaves the stored key untouched on save. + setApiKey(""); setModels((provider.models ?? []).map((model) => model.id).join(", ")); setFormError(null); setDetectError(null); @@ -345,6 +349,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C <option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option> <option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option> <option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option> + <option value="google-generative-ai">{t("providers.apiTypeGoogle", "Google Generative AI")}</option> </select> </div> @@ -366,6 +371,9 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C id="custom-provider-api-key" type="password" className="input" + placeholder={editingProvider?.apiKey + ? t("providers.apiKeyKeepPlaceholder", "Leave blank to keep current key") + : undefined} value={apiKey} onChange={(event) => setApiKey(event.target.value)} disabled={saving} @@ -461,6 +469,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C <option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option> <option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option> <option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option> + <option value="google-generative-ai">{t("providers.apiTypeGoogle", "Google Generative AI")}</option> </select> </div> diff --git a/packages/dashboard/app/components/InlineCreateCard.css b/packages/dashboard/app/components/InlineCreateCard.css index 505d845d34..6d361c59f0 100644 --- a/packages/dashboard/app/components/InlineCreateCard.css +++ b/packages/dashboard/app/components/InlineCreateCard.css @@ -153,6 +153,18 @@ margin-left: auto; } +.inline-create-optional-steps { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.inline-create-optional-step[aria-pressed="true"] { + border-color: var(--accent); + color: var(--accent); +} + .inline-create-hint { font-size: 11px; color: var(--text-dim); @@ -302,6 +314,14 @@ font-size: 12px; } + .inline-create-optional-steps { + width: 100%; + } + + .inline-create-optional-step { + flex: 1 1 auto; + } + .inline-create-priority-select { min-height: 36px; } diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index e42ac4bbe4..82d689cd12 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -3,10 +3,10 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react"; -import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings } from "@fusion/core"; +import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings, type ResolvedWorkflowOptionalStep } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; -import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, selectTaskWorkflow, DuplicateCandidatesError } from "../api"; +import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, selectTaskWorkflow, fetchWorkflowOptionalSteps, DuplicateCandidatesError } from "../api"; import type { CreateTaskInput, ModelInfo, Agent, NodeInfo, DuplicateMatch } from "../api"; import { useNodes } from "../hooks/useNodes"; import { ModelSelectionModal } from "./ModelSelectionModal"; @@ -110,7 +110,8 @@ export function InlineCreateCard({ const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined); const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined); const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined); - const [browserVerification, setBrowserVerification] = useState(false); + const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]); + const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState<string[]>([]); const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState<string | null>(null); @@ -264,6 +265,37 @@ export function InlineCreateCard({ const hasValidatorOverride = Boolean(validatorProvider && validatorModelId); const hasPlanningOverride = Boolean(planningProvider && planningModelId); const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride); + const effectiveWorkflowId = selectedWorkflowId || settings?.defaultWorkflowId || "builtin:coding"; + + useEffect(() => { + let cancelled = false; + setOptionalSteps([]); + setEnabledOptionalStepIds([]); + + fetchWorkflowOptionalSteps(effectiveWorkflowId, projectId) + .then((steps) => { + if (cancelled) return; + setOptionalSteps(steps); + setEnabledOptionalStepIds(steps.filter((step) => step.defaultOn).map((step) => step.templateId)); + }) + .catch(() => { + if (cancelled) return; + setOptionalSteps([]); + setEnabledOptionalStepIds([]); + }); + + return () => { + cancelled = true; + }; + }, [effectiveWorkflowId, projectId]); + + const toggleOptionalStep = useCallback((templateId: string) => { + setEnabledOptionalStepIds((prev) => ( + prev.includes(templateId) + ? prev.filter((id) => id !== templateId) + : [...prev, templateId] + )); + }, []); // Track focus-out for conditional cancel behavior and justResetRef cleanup. useEffect(() => { @@ -389,7 +421,7 @@ export function InlineCreateCard({ setValidatorModelId(undefined); setPlanningProvider(undefined); setPlanningModelId(undefined); - setBrowserVerification(false); + setEnabledOptionalStepIds([]); setPriority(DEFAULT_TASK_PRIORITY); setDependencies([]); setSelectedAgentId(null); @@ -400,6 +432,7 @@ export function InlineCreateCard({ setIsModelModalOpen(false); setShowPresets(false); setSelectedWorkflowId(null); + setEnabledOptionalStepIds([]); addToast(`Created ${task.id}`, "success"); // Collapse and clear localStorage after successful task creation @@ -442,7 +475,7 @@ export function InlineCreateCard({ validatorModelId: hasValidatorOverride ? validatorModelId : undefined, planningModelProvider: hasPlanningOverride ? planningProvider : undefined, planningModelId: hasPlanningOverride ? planningModelId : undefined, - enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined, + enabledWorkflowSteps: enabledOptionalStepIds.length ? enabledOptionalStepIds : undefined, priority, nodeId, }; @@ -459,7 +492,7 @@ export function InlineCreateCard({ } await submitTask(input); - }, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, browserVerification, priority, nodeId, projectId, addToast, submitTask]); + }, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, enabledOptionalStepIds, priority, nodeId, projectId, addToast, submitTask]); const handleDuplicateProceed = useCallback(async () => { const matches = duplicateMatches; @@ -671,7 +704,7 @@ export function InlineCreateCard({ setValidatorModelId(undefined); setPlanningProvider(undefined); setPlanningModelId(undefined); - setBrowserVerification(false); + setEnabledOptionalStepIds([]); setSelectedPresetId(undefined); setSelectedAgentId(null); setNodeId(undefined); @@ -699,7 +732,7 @@ export function InlineCreateCard({ setValidatorModelId(undefined); setPlanningProvider(undefined); setPlanningModelId(undefined); - setBrowserVerification(false); + setEnabledOptionalStepIds([]); setSelectedPresetId(undefined); setSelectedAgentId(null); setNodeId(undefined); @@ -1036,16 +1069,34 @@ export function InlineCreateCard({ )} </div> - <button - type="button" - className="btn btn-sm" - data-testid="inline-create-browser-verification-toggle" - aria-pressed={browserVerification} - onClick={() => setBrowserVerification((prev) => !prev)} - title={t("inline.enableBrowserVerification", "Enable browser verification workflow step")} - > - {browserVerification ? t("inline.browserVerifyChecked", "Browser Verify ✓") : t("inline.browserVerify", "Browser Verify")} - </button> + {optionalSteps.length > 0 && ( + <div + className="inline-create-optional-steps" + aria-label={t("inline.optionalWorkflowSteps", "Optional workflow steps")} + > + {optionalSteps.map((step) => { + const enabled = enabledOptionalStepIds.includes(step.templateId); + const testId = step.templateId === "browser-verification" + ? "inline-create-browser-verification-toggle" + : `inline-create-optional-step-${step.templateId}`; + return ( + <button + key={step.templateId} + type="button" + className="btn btn-sm inline-create-optional-step" + data-testid={testId} + aria-pressed={enabled} + onClick={() => toggleOptionalStep(step.templateId)} + title={t("inline.toggleOptionalWorkflowStep", "Toggle optional workflow step: {{name}}", { name: step.name })} + > + {enabled + ? t("inline.optionalWorkflowStepChecked", "{{name}} ✓", { name: step.name }) + : step.name} + </button> + ); + })} + </div> + )} <WorkflowSelector value={selectedWorkflowId} diff --git a/packages/dashboard/app/components/Lane.css b/packages/dashboard/app/components/Lane.css index 5cd16313ea..a9514fc3e6 100644 --- a/packages/dashboard/app/components/Lane.css +++ b/packages/dashboard/app/components/Lane.css @@ -20,6 +20,8 @@ flex-direction: column; flex: 1 1 auto; width: 100%; + height: 100%; + max-height: 100%; min-width: 0; min-height: 0; overflow: hidden; @@ -51,15 +53,20 @@ flex-direction: row; align-items: stretch; width: 100%; + height: 100%; + max-height: 100%; min-height: 0; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; } .board.board-workflow-columns > .column { flex: 1 0 300px; min-width: 300px; + height: 100%; + min-height: 0; scroll-snap-align: center; } @@ -117,6 +124,7 @@ padding: 12px; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-color: var(--border) transparent; scrollbar-width: thin; @@ -165,7 +173,13 @@ } .board.board-workflow-columns { + flex: 1 1 auto; flex-direction: row; + align-items: stretch; + width: 100%; + height: 100%; + max-height: 100%; + min-height: 0; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x proximity; diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 66365f18e6..b1de9f3d22 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -233,6 +233,7 @@ interface ListViewProps { /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; prAuthAvailable?: boolean; + autoMerge?: boolean; onCreateWorkflow?: () => void; } @@ -296,6 +297,7 @@ export function ListView({ searchQuery = "", lastFetchTimeMs, prAuthAvailable, + autoMerge, onCreateWorkflow, }: ListViewProps) { const { t } = useTranslation("app"); @@ -2332,6 +2334,7 @@ export function ListView({ }} addToast={addToast} prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} /> </div> )} diff --git a/packages/dashboard/app/components/MobileNavBar.css b/packages/dashboard/app/components/MobileNavBar.css index 61ae3aaf11..2ead65917e 100644 --- a/packages/dashboard/app/components/MobileNavBar.css +++ b/packages/dashboard/app/components/MobileNavBar.css @@ -44,13 +44,19 @@ bottom: var(--icb-bottom-offset, 0px); } - /* When the on-screen keyboard is open, ignore the visualViewport - compensation that would otherwise push the bar up by the keyboard - height (iOS shrinks vv.height; bottomOffset becomes ~keyboard height). - Pin the nav to the page bottom so the keyboard simply covers it. */ + /* When the on-screen keyboard is open, hide the nav outright instead of + pinning it to bottom:0 and trusting the keyboard to cover it. On iOS the + layout viewport does NOT shrink, so a bottom:0 bar stays on screen and + overlaps the composer that has lifted above the keyboard. Sliding it fully + off the bottom edge guarantees it's gone while typing. Safe for the + keyboard: the nav is a sibling of the chat input, not an ancestor, so the + transform does not establish a containing block over the focused field + (which is what would otherwise make iOS collapse the keyboard). */ .mobile-nav-bar.mobile-nav-bar--keyboard-open, .mobile-nav-bar.mobile-nav-bar--with-footer.mobile-nav-bar--keyboard-open { bottom: 0; + transform: translateY(100%); + pointer-events: none; } /* Content padding: mobile nav only (no footer). Mirrors the visible fixed stack, diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 626c515c58..2590c6ef5d 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -109,10 +109,10 @@ function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: Parsed .trim(); } -export function clampQuickChatInputHeight(scrollHeight: number): number { +export function clampQuickChatInputHeight(scrollHeight: number, maxHeight: number = 640): number { // Match ChatView's 640px cap so pasted multi-paragraph text remains visible, // while keeping an upper bound that protects message visibility on short screens. - return Math.max(40, Math.min(scrollHeight, 640)); + return Math.max(40, Math.min(scrollHeight, maxHeight)); } function truncateToolValue(value: string, maxLength: number): string { @@ -371,7 +371,16 @@ const QUICK_CHAT_VIEWPORT_PADDING = 8; * @param projectId - Optional project ID for localStorage key * @param externalDidDragRef - External ref to track drag state for click detection */ -function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefObject<boolean>) { +function useDraggable( + projectId?: string, + externalDidDragRef?: React.MutableRefObject<boolean>, + onTap?: () => void, +) { + // Latest onTap kept in a ref so the imperatively-bound document + // pointerup handler always calls the current closure without forcing + // listener re-binds. + const onTapRef = useRef(onTap); + onTapRef.current = onTap; // Get executor footer height from CSS variable const getFooterHeight = useCallback((): number => { if (typeof window === "undefined") return 0; @@ -475,6 +484,12 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO if (didDragRef.current) { savePosition(positionRef.current); + } else { + // A tap (not a drag). Fire the toggle from pointerup rather than + // relying on the synthetic click: iOS Safari suppresses the click + // when setPointerCapture() was called in pointerdown (a WebKit + // quirk), so onClick alone never opens the panel on iPhone. + onTapRef.current?.(); } document.removeEventListener("pointermove", handleDocumentPointerMove); @@ -998,13 +1013,21 @@ export function QuickChatFAB({ const hideMentionPopupTimeoutRef = useRef<number | null>(null); const hideSkillMenuTimeoutRef = useRef<number | null>(null); const dragDepthRef = useRef(0); + // Set by the latest tap handler (defined further down, after isOpen / + // stealthInputRef exist). Indirection keeps the useDraggable call above + // those declarations. + const fabTapHandlerRef = useRef<(() => void) | null>(null); + // True for ~the click-delay window after a pointerup tap fired the + // toggle, so the trailing synthetic click (when iOS does emit one) + // doesn't double-toggle. + const suppressNextFabClickRef = useRef(false); // Draggable hook for FAB positioning const { position, isDragging, handlePointerDown, - } = useDraggable(projectId, didDragRef); + } = useDraggable(projectId, didDragRef, () => fabTapHandlerRef.current?.()); // Panel stays 60px above FAB (FAB is 48px tall + 12px gap) const panelY = position.y + 60; @@ -1047,6 +1070,7 @@ export function QuickChatFAB({ const pendingAttachmentsRef = useRef<PendingAttachment[]>([]); const shouldAutoFocusComposerRef = useRef(false); const handledMobileActionRef = useRef(false); + const handledMobileActionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const preserveComposerFocusRef = useRef(false); // Always-mounted offscreen input used to claim the iOS soft keyboard // synchronously inside the FAB click gesture, before the real composer @@ -1949,6 +1973,43 @@ export function QuickChatFAB({ preserveComposerFocusRef.current = true; }, []); + // Latch that a mobile pointer/touch handler already performed a button's + // action, so the synthetic onClick that trails the gesture is ignored + // (prevents a double send/stop). On iOS, preventDefault() in + // touchstart/pointerdown frequently suppresses that click entirely, so we + // also clear the latch on a timer: without it the ref stays stuck `true` and + // swallows the *next* real click (e.g. after switching chats), making the + // button look dead. The latch is shared by the send and stop buttons, so a + // stuck value cross-contaminates between them. + const markHandledMobileAction = useCallback(() => { + handledMobileActionRef.current = true; + if (handledMobileActionTimerRef.current != null) { + clearTimeout(handledMobileActionTimerRef.current); + } + handledMobileActionTimerRef.current = setTimeout(() => { + handledMobileActionRef.current = false; + handledMobileActionTimerRef.current = null; + }, 700); + }, []); + + // If a mobile handler already ran this gesture's action, consume the latch + // (and cancel its timer) so the trailing onClick bails without double-firing. + const consumeHandledMobileAction = useCallback(() => { + if (!handledMobileActionRef.current) return false; + handledMobileActionRef.current = false; + if (handledMobileActionTimerRef.current != null) { + clearTimeout(handledMobileActionTimerRef.current); + handledMobileActionTimerRef.current = null; + } + return true; + }, []); + + useEffect(() => () => { + if (handledMobileActionTimerRef.current != null) { + clearTimeout(handledMobileActionTimerRef.current); + } + }, []); + const handleSendMessage = useCallback(async () => { const trimmed = messageInput.trim(); const attachmentsToSend = pendingAttachmentsRef.current; @@ -2427,9 +2488,9 @@ export function QuickChatFAB({ ], ); - // Handle FAB click - only toggle if this was a click (not a drag) - // Reset didDragRef after checking to prevent double-toggle - const handleFABClick = useCallback(() => { + // Core open/close toggle. Only toggles if this was a tap (not a drag); + // resets didDragRef after checking to prevent a double-toggle. + const toggleQuickChat = useCallback(() => { if (didDragRef.current) { // Was a drag, don't toggle didDragRef.current = false; @@ -2452,6 +2513,34 @@ export function QuickChatFAB({ setIsOpen(true); }, [isOpen, setIsOpen]); + // Fired from the drag hook's pointerup when the gesture was a tap, not a + // drag. This is the reliable open path on iOS: setPointerCapture() in + // pointerdown makes iOS Safari swallow the synthetic click, so onClick + // alone never opens the panel on iPhone. pointerup is itself a user + // gesture, so the stealth-input focus inside toggleQuickChat still + // raises the keyboard. + const handleFABTap = useCallback(() => { + suppressNextFabClickRef.current = true; + if (typeof window !== "undefined") { + window.setTimeout(() => { + suppressNextFabClickRef.current = false; + }, 500); + } + toggleQuickChat(); + }, [toggleQuickChat]); + fabTapHandlerRef.current = handleFABTap; + + // Synthetic click path — still used for mouse (where pointerup also + // fires handleFABTap, so we de-dupe) and for click-only callers like + // tests (no preceding pointerup tap, so we handle it). + const handleFABClick = useCallback(() => { + if (suppressNextFabClickRef.current) { + suppressNextFabClickRef.current = false; + return; + } + toggleQuickChat(); + }, [toggleQuickChat]); + return ( <> <input @@ -2976,26 +3065,14 @@ export function QuickChatFAB({ onBlur={handleInputBlur} onFocus={handleInputFocus} onPaste={handlePaste} - // Intercept the touch *before* iOS's default focus-and- - // scroll handler runs. Without this, on the second focus - // (after a keyboard dismiss) iOS shifts the visual - // viewport to "scroll" the input into view, which yanks - // the position:fixed panel up off-screen for ~1s before - // settling back. preventDefault on touchstart suppresses - // that auto-scroll; we then focus programmatically with - // preventScroll so the keyboard still comes up. onTouchStart={(event) => { if (typeof window === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; - // iOS-only workaround. On Android, preventDefault on - // textarea touchstart prevents the soft keyboard from - // opening at all (programmatic focus() does not raise - // the keyboard on Android — only the default touch - // action does), so the tap silently dismisses. if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} placeholder={inputPlaceholder} disabled={inputDisabled} @@ -3009,14 +3086,14 @@ export function QuickChatFAB({ if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); if (event.pointerType && event.pointerType !== "mouse") { - handledMobileActionRef.current = true; + markHandledMobileAction(); stopStreaming(); } }} onTouchStart={(event) => { if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); - handledMobileActionRef.current = true; + markHandledMobileAction(); stopStreaming(); }} onMouseDown={(event) => { @@ -3024,10 +3101,7 @@ export function QuickChatFAB({ event.preventDefault(); }} onClick={() => { - if (handledMobileActionRef.current) { - handledMobileActionRef.current = false; - return; - } + if (consumeHandledMobileAction()) return; stopStreaming(); }} aria-label={t("chat.stopGeneration", "Stop generation")} @@ -3043,7 +3117,7 @@ export function QuickChatFAB({ if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); if (event.pointerType && event.pointerType !== "mouse") { - handledMobileActionRef.current = true; + markHandledMobileAction(); markPreserveComposerFocus(); focusComposerInput(); void handleSendMessage(); @@ -3052,7 +3126,7 @@ export function QuickChatFAB({ onTouchStart={(event) => { if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); - handledMobileActionRef.current = true; + markHandledMobileAction(); markPreserveComposerFocus(); focusComposerInput(); void handleSendMessage(); @@ -3062,10 +3136,7 @@ export function QuickChatFAB({ event.preventDefault(); }} onClick={() => { - if (handledMobileActionRef.current) { - handledMobileActionRef.current = false; - return; - } + if (consumeHandledMobileAction()) return; void handleSendMessage(); }} disabled={sendDisabled} diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 4a639b7bca..4399da6389 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -106,6 +106,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const fileInputRef = useRef<HTMLInputElement>(null); const touchButtonRef = useRef<HTMLButtonElement | null>(null); const justResetRef = useRef(false); + const justSubmittedRef = useRef(false); const previousProjectIdRef = useRef(projectId); const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); const pendingImagesRef = useRef<PendingImage[]>([]); @@ -320,17 +321,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, } }, [description, isExpanded, autoResize]); - // Restore focus after submission completes (when textarea is re-enabled) + // Restore focus after an in-component submission completes (when textarea is re-enabled). useEffect(() => { - if (!isSubmitting && description === "" && textareaRef.current) { - // Use setTimeout to ensure focus happens after React re-enables the textarea - const focusTimeout = setTimeout(() => { - if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) { - textareaRef.current?.focus(); - } - }, 0); - return () => clearTimeout(focusTimeout); + if (!justSubmittedRef.current || isSubmitting || description !== "" || !textareaRef.current) { + return; } + + justSubmittedRef.current = false; + // Use setTimeout to ensure focus happens after React re-enables the textarea. + const focusTimeout = setTimeout(() => { + if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) { + textareaRef.current?.focus(); + } + }, 0); + return () => clearTimeout(focusTimeout); }, [isSubmitting, description]); // Clear dep search when dropdown closes @@ -540,6 +544,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, } } resetForm(); + justSubmittedRef.current = true; } catch (err) { setDescription(originalDescription); addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error"); @@ -1487,7 +1492,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, if (!(target instanceof Element)) return; const button = target.closest("button"); if (button && !button.disabled) { - e.preventDefault(); + if (document.activeElement === textareaRef.current) { + e.preventDefault(); + } touchButtonRef.current = button; } }} diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 33be4d3ecd..332ad44312 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1595,6 +1595,55 @@ border-color: var(--ws-error-dark); } +/* ── Activity Log — Tablet (769px–1024px) ────────────────────────── */ + +@media (min-width: 769px) and (max-width: 1024px) { + /* Widen only the Activity Log modal; keep the global .modal-lg width unchanged. */ + .activity-log-modal { + width: calc(100vw - var(--space-2xl)); + max-width: calc(100vw - var(--space-2xl)); + } + + /* Header: title on left, close on right of top row, actions wrap below. */ + .activity-log-header { + flex-wrap: wrap; + gap: var(--space-sm); + } + + .activity-log-title { + flex: 1 1 auto; + order: 0; + } + + .activity-log-actions { + flex: 1 1 100%; + flex-wrap: wrap; + gap: var(--space-sm); + order: 2; + } + + .activity-log-header .modal-close { + order: 1; + margin-left: auto; + flex: 0 0 auto; + } + + .activity-log-filter, + .activity-log-filter--project { + flex: 1 1 0; + min-width: 0; + } + + .activity-log-filter-select { + width: 100%; + } + + .activity-log-refresh, + .activity-log-clear { + flex-shrink: 0; + } +} + /* ── Activity Log — Mobile (≤ 768px) ─────────────────────────────── */ @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index cb60b4ec8d..6011b5893e 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -7,6 +7,7 @@ import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; import { appendTokenQuery } from "../auth"; import { api } from "../api"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; +import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; /** * SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI @@ -27,12 +28,6 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; const ACK_THRESHOLD_BYTES = 32 * 1024; const RESIZE_DEBOUNCE_MS = 100; -/** - * Canonical mobile breakpoint (matches the repo CSS convention). Landscape - * phones exceed 768px wide, so the height clause covers them too. - */ -const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)"; - /** * Control sequences emitted by the accessory key bar (U13). These are * deliberate user keystrokes routed straight to the session input path — @@ -65,19 +60,14 @@ function ctrlCombo(key: string): string | null { /** Reactive mobile-viewport detection via the repo breakpoint convention. */ function useIsMobileViewport(): boolean { - const [isMobile, setIsMobile] = useState<boolean>(() => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return false; - } - return window.matchMedia(MOBILE_MEDIA_QUERY).matches; - }); + const [isMobile, setIsMobile] = useState<boolean>(() => isMobileViewport()); useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return; } const mql = window.matchMedia(MOBILE_MEDIA_QUERY); - const onChange = () => setIsMobile(mql.matches); + const onChange = () => setIsMobile(isMobileViewport()); onChange(); // Safari < 14 only has addListener/removeListener. if (typeof mql.addEventListener === "function") { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a4a3425915..84c56f8829 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } fro import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; import { splitSettingsSave } from "./settings/save-split"; +import type { SectionSaveHandler } from "./settings/sections/context"; import { AppearanceSection } from "./settings/sections/AppearanceSection"; import { ExperimentalSection } from "./settings/sections/ExperimentalSection"; import { NodeSyncSection } from "./settings/sections/NodeSyncSection"; @@ -26,7 +27,7 @@ import { import { SecretsSection } from "./settings/sections/SecretsSection"; import { PromptsSection } from "./settings/sections/PromptsSection"; import { GeneralSection } from "./settings/sections/GeneralSection"; -import { ProjectModelsSection } from "./settings/sections/ProjectModelsSection"; +import { ProjectModelsSection, WorkflowLaneFlushRejection } from "./settings/sections/ProjectModelsSection"; import { SchedulingSection } from "./settings/sections/SchedulingSection"; import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection"; import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection"; @@ -613,6 +614,10 @@ export function SettingsModal({ : {}; const modalRef = useRef<HTMLDivElement>(null); const settingsContentRef = useRef<HTMLDivElement>(null); + const workflowLaneSaverRef = useRef<SectionSaveHandler | null>(null); + const registerWorkflowLaneSaver = useCallback((saver: SectionSaveHandler | null) => { + workflowLaneSaverRef.current = saver; + }, []); useModalResizePersist(modalRef, true, "fusion:settings-modal-size"); const sessionBannersHidden = useSessionBannersHidden(); const [form, setForm] = useState<SettingsFormState>({ @@ -2211,9 +2216,12 @@ export function SettingsModal({ : Promise.resolve(), ]); + await workflowLaneSaverRef.current?.(); + addToast(t("settings.general.settingsSaved", "Settings saved"), "success"); onClose(); } catch (err) { + if (err instanceof WorkflowLaneFlushRejection) return; addToast(getErrorMessage(err), "error"); } finally { setIsSaving(false); @@ -2483,6 +2491,7 @@ export function SettingsModal({ projectId={projectId} addToast={addToast} onOpenWorkflowSettings={onOpenWorkflowSettings} + registerWorkflowLaneSaver={registerWorkflowLaneSaver} models={{ modelLanes: MODEL_LANES, getLaneStatus, diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css new file mode 100644 index 0000000000..7c6b7f6a46 --- /dev/null +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -0,0 +1,416 @@ +.task-chat-tab { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-md); + min-height: 0; + height: 100%; +} + +.task-chat-toolbar { + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + gap: var(--space-sm); +} + +.task-chat-expand-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.task-chat-transcript { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: var(--space-lg); + min-height: 0; + overflow-y: auto; + padding: var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-secondary); +} + +.task-chat-empty { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + min-height: calc(var(--space-2xl) * 3); + padding: var(--space-xl); + color: var(--text-muted); + text-align: center; +} + +.task-chat-jump-to-bottom { + position: sticky; + right: var(--space-md); + bottom: var(--space-md); + width: fit-content; + min-inline-size: var(--space-2xl); + min-block-size: var(--space-2xl); + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + color: var(--text-muted); + background: var(--surface); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + cursor: pointer; + transition: all var(--transition-fast); + z-index: 2; +} + +.task-chat-jump-to-bottom:hover { + background: var(--card-hover); + color: var(--text); +} + +.task-chat-jump-to-bottom:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.task-chat-group { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-sm); +} + +.task-chat-group-header { + display: flex; + align-items: center; + align-self: start; + gap: var(--space-sm); + min-width: min(calc(var(--space-2xl) * 4), 32vw); + color: var(--text-muted); +} + +.task-chat-avatar { + flex: 0 0 auto; +} + +.task-chat-role-label { + color: var(--text); + font-weight: 600; +} + +.task-chat-group-meta { + color: var(--text-muted); + font-size: var(--space-md); +} + +.task-chat-group-bubbles { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-sm); +} + +.task-chat-user-group { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-end; + gap: var(--space-xs); +} + +.task-chat-user-header { + padding-inline: var(--space-sm); + color: var(--text-muted); +} + +.task-chat-entry { + min-width: 0; + padding: var(--space-sm) var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-entry--user { + max-width: min(100%, calc(var(--space-2xl) * 18)); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); + box-shadow: var(--shadow-sm); +} + +.task-chat-tool-group, +.task-chat-thinking { + min-width: 0; + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-tool-group { + background: var(--bg-tertiary); +} + +.task-chat-tool-group-summary, +.task-chat-thinking-summary { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + cursor: pointer; + list-style: none; +} + +.task-chat-tool-group-summary { + flex-wrap: wrap; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); +} + +.task-chat-tool-group-summary::-webkit-details-marker, +.task-chat-thinking-summary::-webkit-details-marker { + display: none; +} + +.task-chat-tool-group-summary::marker, +.task-chat-thinking-summary::marker { + content: ""; +} + +.task-chat-tool-group-summary:focus-visible, +.task-chat-thinking-summary:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.task-chat-tool-group-count { + flex: 0 0 auto; + font-weight: 600; +} + +.task-chat-tool-group-names { + min-width: 0; + color: var(--text-muted); + font-size: calc(var(--space-md) - (var(--space-xs) / 2)); + overflow-wrap: anywhere; +} + +.task-chat-tool-group-overflow { + color: var(--text-muted); + white-space: nowrap; +} + +.task-chat-tool-group-error-count { + flex: 0 0 auto; + color: var(--color-error); + font-size: calc(var(--space-md) - (var(--space-xs) / 2)); + font-weight: 600; +} + +.task-chat-tool-group-entries, +.task-chat-thinking-body { + display: flex; + flex-direction: column; + padding: 0 var(--space-md) var(--space-md); +} + +.task-chat-tool-group-entries { + gap: var(--space-xs); + padding: 0 var(--space-sm) var(--space-sm); +} + +.task-chat-tool-entry { + min-width: 0; + padding: var(--space-xs) var(--space-sm); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-tool-entry--tool-error { + border-color: color-mix(in srgb, var(--color-error) 45%, var(--border)); + background: color-mix(in srgb, var(--color-error) 8%, var(--surface)); +} + +.task-chat-thinking { + border-color: color-mix(in srgb, var(--color-warning) 35%, var(--border)); + background: color-mix(in srgb, var(--color-warning) 8%, var(--surface)); +} + +.task-chat-thinking-summary { + color: var(--color-warning); + font-weight: 600; +} + +.task-chat-entry-kicker { + margin-bottom: calc(var(--space-xs) / 2); + color: var(--text-muted); + font-size: calc(var(--space-sm) + (var(--space-xs) / 2)); + font-weight: 600; + text-transform: none; + letter-spacing: normal; +} + +.task-chat-tool-entry--tool-error .task-chat-entry-kicker { + color: var(--color-error); +} + +.task-chat-entry-text { + white-space: pre-wrap; +} + +.task-chat-markdown > :first-child, +.task-chat-markdown p:first-child { + margin-top: 0; +} + +.task-chat-markdown > :last-child, +.task-chat-markdown p:last-child { + margin-bottom: 0; +} + +.task-chat-tool-detail-block { + margin-top: var(--space-xs); +} + +.task-chat-tool-detail-label { + color: var(--text-muted); + font-size: var(--space-md); + font-weight: 600; +} + +.task-chat-tool-detail { + margin: var(--space-xs) 0 0; + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.task-chat-composer { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); +} + +.task-chat-session-hint { + color: var(--text-muted); + font-size: var(--space-md); +} + +.task-chat-composer-row { + display: flex; + align-items: flex-end; + gap: var(--space-sm); +} + +.task-chat-input { + min-height: calc(var(--space-2xl) + var(--space-sm)); + max-height: var(--task-chat-composer-max-height, 40vh); + resize: none; + flex: 1 1 auto; +} + +.task-chat-send { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: calc(var(--space-2xl) + var(--space-sm)); + min-inline-size: calc(var(--space-2xl) + var(--space-sm)); + block-size: calc(var(--space-2xl) + var(--space-sm)); + min-block-size: calc(var(--space-2xl) + var(--space-sm)); + padding: 0; +} + +@media (max-width: 768px) { + .task-chat-tab { + gap: var(--space-sm); + } + + .task-chat-toolbar { + justify-content: stretch; + } + + .task-chat-expand-toggle { + justify-content: center; + width: 100%; + } + + .task-chat-transcript { + flex: 1 1 auto; + min-height: 0; + padding: var(--space-sm); + } + + .task-chat-jump-to-bottom { + right: var(--space-sm); + bottom: var(--space-sm); + min-inline-size: calc(var(--space-2xl) + var(--space-sm)); + min-block-size: calc(var(--space-2xl) + var(--space-sm)); + } + + .task-chat-group { + grid-template-columns: 1fr; + } + + .task-chat-group-header { + min-width: 0; + } + + .task-chat-user-group { + align-items: stretch; + } + + .task-chat-user-header { + align-self: flex-end; + } + + .task-chat-entry--user { + max-width: 100%; + } + + .task-chat-tool-group-summary, + .task-chat-thinking-summary { + align-items: flex-start; + flex-direction: column; + } + + .task-chat-tool-group-names, + .task-chat-tool-group-error-count { + width: 100%; + } + + .task-chat-tool-group-entries, + .task-chat-thinking-body { + padding-inline: var(--space-sm); + } + + .task-chat-tool-entry { + padding: var(--space-sm); + } + + .task-chat-composer { + padding: var(--space-sm); + } + + .task-chat-composer-row { + align-items: flex-end; + gap: var(--space-xs); + } + + .task-chat-send { + inline-size: calc(var(--space-2xl) + var(--space-sm)); + min-inline-size: calc(var(--space-2xl) + var(--space-sm)); + } +} diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx new file mode 100644 index 0000000000..0b6dd377b8 --- /dev/null +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -0,0 +1,722 @@ +import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core"; +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { ChevronDown, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; +import { addSteeringComment, refineTask } from "../api"; +import { useAgentLogs } from "../hooks/useAgentLogs"; +import type { ToastType } from "../hooks/useToast"; +import { getErrorMessage } from "@fusion/core"; +import { linkifyFilePaths } from "../utils/filePathLinkify"; +import { AgentAvatar } from "./AgentAvatar"; +import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; +import { markdownComponents } from "./AgentLogViewer"; +import "./TaskChatTab.css"; + +interface TaskChatTabProps { + task: Task | TaskDetail; + projectId?: string; + active: boolean; + addToast: (msg: string, type?: ToastType) => void; + sessionLive?: boolean; + onTaskUpdated?: (task: Task) => void; + expanded?: boolean; + onToggleExpanded?: () => void; +} + +type AgentLogRole = AgentRole | undefined; + +type UserChatMessage = Pick<SteeringComment, "id" | "text" | "createdAt"> & { optimistic?: boolean }; + +type TaskChatTranscriptItem = + | { kind: "agent"; role: AgentLogRole; label: string; entries: AgentLogEntry[] } + | { kind: "user"; message: UserChatMessage }; + +type TaskChatSegment = + | { kind: "tool"; entries: AgentLogEntry[]; startIndex: number } + | { kind: "thinking"; entries: AgentLogEntry[]; startIndex: number } + | { kind: "text"; entries: AgentLogEntry[]; startIndex: number }; + +type TaskChatToolGroupRow = + | { kind: "invocation"; call: AgentLogEntry; completion?: AgentLogEntry; callIndex: number; completionIndex?: number } + | { kind: "entry"; entry: AgentLogEntry; index: number }; + +const STEERING_BLOCKED_STATUSES = new Set([ + "paused", + "awaiting-user-input", + "awaiting-cli-approval", + "awaiting-user-review", + "failed", + "needs-replan", +]); +const REVIEW_STEERABLE_STATUSES = new Set(["reviewing", "merging", "merging-fix", "fixing"]); +const BOTTOM_FOLLOW_THRESHOLD = 48; + +function isTranscriptNearBottom(container: HTMLElement): boolean { + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; +} + +function getRoleLabel(role: AgentLogRole): string { + switch (role) { + case "triage": + return "Planner"; + case "executor": + return "Executor"; + case "reviewer": + return "Reviewer"; + case "merger": + return "Merger"; + default: + return "Agent"; + } +} + +function getRoleIcon(role: AgentLogRole): string | undefined { + switch (role) { + case "triage": + return "🧭"; + case "executor": + return "⚙️"; + case "reviewer": + return "🔎"; + case "merger": + return "🔀"; + default: + return undefined; + } +} + +function getEntryKey(entry: AgentLogEntry, index: number): string { + return [entry.taskId, entry.timestamp, entry.agent ?? "agent", entry.type, index].join(":"); +} + +function getTimestampMs(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getUserMessageDedupKey(message: Pick<SteeringComment, "id" | "text" | "createdAt">): string { + return message.id ? `id:${message.id}` : `fallback:${message.text}:${message.createdAt}`; +} + +function getUserMessageFallbackKey(message: Pick<SteeringComment, "text" | "createdAt">): string { + return `fallback:${message.text}:${message.createdAt}`; +} + +function mergeUserMessages(persistedComments: readonly SteeringComment[] | undefined, optimisticMessages: readonly UserChatMessage[]): UserChatMessage[] { + const messages: UserChatMessage[] = []; + const seen = new Set<string>(); + const seenFallbacks = new Set<string>(); + const addMessage = (message: UserChatMessage) => { + const idKey = getUserMessageDedupKey(message); + const fallbackKey = getUserMessageFallbackKey(message); + if (seen.has(idKey) || seenFallbacks.has(fallbackKey)) return; + seen.add(idKey); + seenFallbacks.add(fallbackKey); + messages.push(message); + }; + + for (const comment of persistedComments ?? []) { + if (comment.author !== "user") continue; + addMessage({ id: comment.id, text: comment.text, createdAt: comment.createdAt }); + } + for (const message of optimisticMessages) { + addMessage(message); + } + + return messages; +} + +function buildTranscriptItems(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[]): TaskChatTranscriptItem[] { + const orderedItems = [ + ...entries.map((entry, index) => ({ kind: "agent" as const, entry, index, timestamp: getTimestampMs(entry.timestamp) })), + ...userMessages.map((message, index) => ({ kind: "user" as const, message, index, timestamp: getTimestampMs(message.createdAt) })), + ].sort((a, b) => a.timestamp - b.timestamp || a.index - b.index || (a.kind === "agent" ? -1 : 1)); + + return orderedItems.reduce<TaskChatTranscriptItem[]>((items, item) => { + if (item.kind === "user") { + items.push({ kind: "user", message: item.message }); + return items; + } + + const previousItem = items[items.length - 1]; + const role = item.entry.agent; + if (previousItem?.kind === "agent" && previousItem.role === role) { + previousItem.entries.push(item.entry); + return items; + } + items.push({ kind: "agent", role, label: getRoleLabel(role), entries: [item.entry] }); + return items; + }, []); +} + +function isActiveAgentSession(task: Task | TaskDetail, opts: { sessionLive?: boolean } = {}): boolean { + if (task.paused || task.userPaused) return false; + if (opts.sessionLive) return true; + + const hasAssignedAgent = Boolean(task.assignedAgentId || task.checkedOutBy); + const statusBlocksProgressSteering = task.status ? STEERING_BLOCKED_STATUSES.has(task.status) : false; + const statusAllowsProgressSteering = !statusBlocksProgressSteering; + const statusAllowsReviewSteering = !task.status || REVIEW_STEERABLE_STATUSES.has(task.status); + const columnAllowsSteering = (task.column === "in-progress" && statusAllowsProgressSteering) + || (task.column === "in-review" && statusAllowsReviewSteering); + return columnAllowsSteering + && hasAssignedAgent; +} + +function isToolLikeEntry(entry: AgentLogEntry): boolean { + return entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; +} + +function formatEntryLabel(entry: AgentLogEntry): string { + switch (entry.type) { + case "tool": + return "Tool call"; + case "tool_result": + return "Tool result"; + case "tool_error": + return "Tool error"; + case "thinking": + return "Thinking"; + default: + return "Message"; + } +} + +const TOOL_NAME_SUMMARY_LIMIT = 5; + +function formatToolCallCount(count: number): string { + return count === 1 ? "1 tool call" : `${count} tool calls`; +} + +function getToolInvocationEntries(entries: AgentLogEntry[]): AgentLogEntry[] { + const callEntries = entries.filter((entry) => entry.type === "tool"); + return callEntries.length > 0 ? callEntries : entries.filter((entry) => isToolLikeEntry(entry)); +} + +function getToolNameSummary(entries: AgentLogEntry[]): { visibleNames: string[]; overflowCount: number } { + const invocationEntries = getToolInvocationEntries(entries); + const names = Array.from(new Set(invocationEntries.map((entry) => entry.text).filter(Boolean))); + const visibleNames = names.slice(0, TOOL_NAME_SUMMARY_LIMIT); + return { visibleNames, overflowCount: Math.max(0, names.length - visibleNames.length) }; +} + +function segmentGroupEntries(entries: AgentLogEntry[]): TaskChatSegment[] { + const segments: TaskChatSegment[] = []; + let index = 0; + + while (index < entries.length) { + const entry = entries[index]; + if (isToolLikeEntry(entry)) { + const startIndex = index; + const toolEntries: AgentLogEntry[] = []; + while (index < entries.length && isToolLikeEntry(entries[index])) { + toolEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "tool", entries: toolEntries, startIndex }); + continue; + } + + if (entry.type === "thinking") { + const startIndex = index; + const thinkingEntries: AgentLogEntry[] = []; + while (index < entries.length && entries[index].type === "thinking") { + thinkingEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "thinking", entries: thinkingEntries, startIndex }); + continue; + } + + const startIndex = index; + const textEntries: AgentLogEntry[] = []; + while (index < entries.length && !isToolLikeEntry(entries[index]) && entries[index].type !== "thinking") { + textEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "text", entries: textEntries, startIndex }); + } + + return segments; +} + +function TaskChatText({ entries }: { entries: AgentLogEntry[] }) { + const firstEntry = entries[0]; + if (!firstEntry) return null; + + return ( + <article + className={`task-chat-entry task-chat-entry--${firstEntry.type.replace("_", "-")}`} + data-testid={`task-chat-entry-${firstEntry.type}`} + > + <div className="markdown-body task-chat-markdown"> + <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> + {entries.map((entry) => entry.text).join("")} + </ReactMarkdown> + </div> + </article> + ); +} + +function TaskChatToolEntry({ entry }: { entry: AgentLogEntry }) { + return ( + <article + className={`task-chat-tool-entry task-chat-tool-entry--${entry.type.replace("_", "-")}`} + data-testid={`task-chat-entry-${entry.type}`} + > + <div className="task-chat-entry-kicker">{formatEntryLabel(entry)}</div> + <div className="task-chat-entry-text">{entry.text}</div> + {entry.detail ? <pre className="task-chat-tool-detail">{linkifyFilePaths(entry.detail)}</pre> : null} + </article> + ); +} + +function getToolGroupRows(entries: AgentLogEntry[]): TaskChatToolGroupRow[] { + const rows: TaskChatToolGroupRow[] = []; + let index = 0; + + while (index < entries.length) { + const entry = entries[index]; + if (entry.type === "tool") { + const nextEntry = entries[index + 1]; + const hasCompletion = nextEntry?.type === "tool_result" || nextEntry?.type === "tool_error"; + rows.push({ + kind: "invocation", + call: entry, + completion: hasCompletion ? nextEntry : undefined, + callIndex: index, + completionIndex: hasCompletion ? index + 1 : undefined, + }); + index += hasCompletion ? 2 : 1; + continue; + } + + rows.push({ kind: "entry", entry, index }); + index += 1; + } + + return rows; +} + +function TaskChatToolInvocation({ row }: { row: Extract<TaskChatToolGroupRow, { kind: "invocation" }> }) { + const completion = row.completion; + const completionLabel = completion ? formatEntryLabel(completion).replace("Tool ", "") : undefined; + const className = `task-chat-tool-entry task-chat-tool-invocation${completion?.type === "tool_error" ? " task-chat-tool-entry--tool-error" : ""}`; + + return ( + <article className={className} data-testid="task-chat-tool-invocation"> + <div className="task-chat-entry-kicker"> + {completionLabel ? `Tool call → ${completionLabel}` : "Tool call"} + </div> + <div className="task-chat-entry-text">{row.call.text}</div> + {row.call.detail ? ( + <div className="task-chat-tool-detail-block"> + <div className="task-chat-tool-detail-label">Arguments</div> + <pre className="task-chat-tool-detail">{linkifyFilePaths(row.call.detail)}</pre> + </div> + ) : null} + {completion?.detail ? ( + <div className="task-chat-tool-detail-block"> + <div className="task-chat-tool-detail-label">{completion.type === "tool_error" ? "Error" : "Result"}</div> + <pre className="task-chat-tool-detail">{linkifyFilePaths(completion.detail)}</pre> + </div> + ) : null} + </article> + ); +} + +function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) { + const invocationEntries = getToolInvocationEntries(entries); + const invocationCount = invocationEntries.length; + const errorCount = entries.filter((entry) => entry.type === "tool_error").length; + const { visibleNames, overflowCount } = getToolNameSummary(entries); + const rows = getToolGroupRows(entries); + + return ( + <details className="task-chat-tool-group" data-testid="task-chat-tool-group"> + <summary className="task-chat-tool-group-summary"> + <span className="task-chat-tool-group-count">{formatToolCallCount(invocationCount)}</span> + {visibleNames.length > 0 ? ( + <span className="task-chat-tool-group-names" aria-label="Tool names"> + {visibleNames.join(", ")} + {overflowCount > 0 ? <span className="task-chat-tool-group-overflow">, +{overflowCount} more</span> : null} + </span> + ) : null} + {errorCount > 0 ? ( + <span className="task-chat-tool-group-error-count"> + {errorCount === 1 ? "1 error" : `${errorCount} errors`} + </span> + ) : null} + </summary> + <div className="task-chat-tool-group-entries"> + {rows.map((row) => ( + row.kind === "invocation" ? ( + <TaskChatToolInvocation key={getEntryKey(row.call, row.callIndex)} row={row} /> + ) : ( + <TaskChatToolEntry key={getEntryKey(row.entry, row.index)} entry={row.entry} /> + ) + ))} + </div> + </details> + ); +} + +function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) { + const combinedThinkingText = entries.map((entry) => entry.text).join(""); + + return ( + <details className="task-chat-thinking" data-testid="task-chat-thinking" open> + <summary className="task-chat-thinking-summary">Thinking</summary> + <div className="task-chat-thinking-body"> + <div + className="markdown-body task-chat-markdown task-chat-thinking-markdown" + data-testid="task-chat-entry-thinking" + > + <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> + {combinedThinkingText} + </ReactMarkdown> + </div> + </div> + </details> + ); +} + +function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) { + if (segment.kind === "tool") { + return <TaskChatToolGroup entries={segment.entries} />; + } + if (segment.kind === "thinking") { + return <TaskChatThinking entries={segment.entries} />; + } + return <TaskChatText entries={segment.entries} />; +} + +function TaskChatUserMessage({ message }: { message: UserChatMessage }) { + return ( + <section className="task-chat-user-group" aria-label="You message"> + <div className="task-chat-user-header"> + <div className="task-chat-role-label">You</div> + </div> + <article className="task-chat-entry task-chat-entry--user" data-testid="task-chat-entry-user"> + <div className="markdown-body task-chat-markdown"> + <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> + {message.text} + </ReactMarkdown> + </div> + </article> + </section> + ); +} + +export function TaskChatTab({ task, projectId, active, addToast, sessionLive, onTaskUpdated, expanded = false, onToggleExpanded }: TaskChatTabProps) { + const { entries, loading } = useAgentLogs(task.id, active, projectId); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]); + const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); + const transcriptRef = useRef<HTMLDivElement>(null); + const previousEntryCountRef = useRef(0); + const previousScrollHeightRef = useRef(0); + const previousActiveRef = useRef(false); + const anchorFrameRef = useRef<number | null>(null); + const textareaRef = useRef<HTMLTextAreaElement>(null); + + const userMessages = useMemo( + () => mergeUserMessages(task.steeringComments, optimisticMessages), + [optimisticMessages, task.steeringComments], + ); + const transcriptItems = useMemo(() => buildTranscriptItems(entries, userMessages), [entries, userMessages]); + const transcriptItemCount = entries.length + userMessages.length; + const activeSession = isActiveAgentSession(task, { sessionLive }); + const isDoneTask = task.column === "done"; + const sessionHint = isDoneTask + ? "Send a message to start a refinement task for this completed task." + : activeSession + ? "Message the active agent session. Guidance is delivered to the running session in real time." + : null; + const composerPlaceholder = isDoneTask + ? "Start a refinement task for this completed task" + : "Steer the currently executing agent"; + const canSend = draft.trim().length > 0 && !sending; + + const resizeComposer = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = "0"; + const maxHeight = typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches ? 200 : undefined; + const nextHeight = clampChatInputHeight(textarea.scrollHeight, maxHeight); + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, maxHeight); + }, []); + + useLayoutEffect(() => { + resizeComposer(); + }, [draft, resizeComposer]); + + const cancelAnchorTranscriptFrame = useCallback(() => { + if (anchorFrameRef.current === null) return; + window.cancelAnimationFrame(anchorFrameRef.current); + anchorFrameRef.current = null; + }, []); + + const anchorTranscriptToBottom = useCallback((container: HTMLElement) => { + cancelAnchorTranscriptFrame(); + if (!container.isConnected) return; + + let frame = 0; + let stableFrames = 0; + let lastScrollHeight = -1; + const maxFrames = 6; + + const writeBottom = () => { + anchorFrameRef.current = null; + if (!container.isConnected) return; + + container.scrollTop = container.scrollHeight; + previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(true); + if (container.scrollHeight === lastScrollHeight) { + stableFrames += 1; + } else { + stableFrames = 0; + lastScrollHeight = container.scrollHeight; + } + + frame += 1; + if (frame >= maxFrames || stableFrames >= 2) { + return; + } + + anchorFrameRef.current = window.requestAnimationFrame(writeBottom); + }; + + writeBottom(); + }, [cancelAnchorTranscriptFrame]); + + useLayoutEffect(() => () => { + cancelAnchorTranscriptFrame(); + }, [cancelAnchorTranscriptFrame]); + + useLayoutEffect(() => { + const container = transcriptRef.current; + const wasActive = previousActiveRef.current; + previousActiveRef.current = active; + if (!container || !active || transcriptItemCount === 0) return; + + const becameActive = !wasActive; + const receivedInitialItems = previousEntryCountRef.current === 0; + if (!becameActive && !receivedInitialItems) return; + + anchorTranscriptToBottom(container); + previousEntryCountRef.current = transcriptItemCount; + previousScrollHeightRef.current = container.scrollHeight; + + return () => { + cancelAnchorTranscriptFrame(); + }; + }, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, transcriptItemCount]); + + useLayoutEffect(() => { + const container = transcriptRef.current; + if (!container) return; + + if (!active) { + previousEntryCountRef.current = transcriptItemCount; + previousScrollHeightRef.current = container.scrollHeight; + return; + } + + if (transcriptItemCount === 0) { + previousEntryCountRef.current = transcriptItemCount; + previousScrollHeightRef.current = container.scrollHeight; + return; + } + + const previousCount = previousEntryCountRef.current; + const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; + if (transcriptItemCount > previousCount) { + const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; + if (shouldFollow) { + container.scrollTop = container.scrollHeight; + setIsTranscriptAtBottom(true); + } else { + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); + } + } else { + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); + } + + previousEntryCountRef.current = transcriptItemCount; + previousScrollHeightRef.current = container.scrollHeight; + }, [active, transcriptItemCount]); + + const handleTranscriptScroll = useCallback(() => { + const container = transcriptRef.current; + if (!container) return; + previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); + }, []); + + const scrollTranscriptToBottom = useCallback(() => { + const container = transcriptRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(true); + }, []); + + const handleSubmit = useCallback(async (event?: React.FormEvent) => { + event?.preventDefault(); + const text = draft.trim(); + if (!text || sending) return; + + const optimisticMessage: UserChatMessage = { + id: `optimistic-${task.id}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + text, + createdAt: new Date().toISOString(), + optimistic: true, + }; + setOptimisticMessages((current) => [...current, optimisticMessage]); + setSending(true); + try { + if (isDoneTask) { + const newTask = await refineTask(task.id, text, projectId); + addToast(`Refinement task created: ${newTask.id}`, "success"); + } else { + const updatedTask = await addSteeringComment(task.id, text, projectId); + const persistedComment = updatedTask.steeringComments + ?.filter((comment) => comment.author === "user" && comment.text === text) + .at(-1); + if (persistedComment) { + setOptimisticMessages((current) => current.map((message) => ( + message.id === optimisticMessage.id + ? { id: persistedComment.id, text: persistedComment.text, createdAt: persistedComment.createdAt, optimistic: true } + : message + ))); + } + onTaskUpdated?.(updatedTask); + } + setDraft(""); + } catch (error) { + setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id)); + addToast(`Unable to send message: ${getErrorMessage(error)}`, "error"); + } finally { + setSending(false); + } + }, [addToast, draft, isDoneTask, onTaskUpdated, projectId, sending, task.id]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + void handleSubmit(); + } + }, [handleSubmit]); + + return ( + <div className="task-chat-tab" data-testid="task-chat-tab"> + {onToggleExpanded ? ( + <div className="task-chat-toolbar"> + <button + type="button" + className="btn btn-sm task-chat-expand-toggle" + onClick={onToggleExpanded} + aria-label={expanded ? "Collapse chat" : "Expand chat to full modal"} + aria-pressed={expanded} + data-testid="task-chat-expand-toggle" + > + {expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />} + <span>{expanded ? "Collapse" : "Expand"}</span> + </button> + </div> + ) : null} + <div + className="task-chat-transcript" + ref={transcriptRef} + onScroll={handleTranscriptScroll} + aria-live="polite" + data-testid="task-chat-transcript" + > + {loading && transcriptItemCount === 0 ? ( + <div className="task-chat-empty" role="status"> + <Loader2 className="animate-spin" aria-hidden="true" /> + <span>Loading agent output…</span> + </div> + ) : transcriptItemCount === 0 ? ( + <div className="task-chat-empty">No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.</div> + ) : ( + transcriptItems.map((item, itemIndex) => { + if (item.kind === "user") { + return <TaskChatUserMessage key={`user-${item.message.id}-${itemIndex}`} message={item.message} />; + } + + const avatarAgent = { + id: item.role ?? "agent", + name: item.label, + icon: getRoleIcon(item.role), + }; + const segments = segmentGroupEntries(item.entries); + return ( + <section className="task-chat-group" key={`${item.role ?? "agent"}-${itemIndex}`} aria-label={`${item.label} messages`}> + <header className="task-chat-group-header"> + <AgentAvatar agent={avatarAgent} className="task-chat-avatar" /> + <div> + <div className="task-chat-role-label">{item.label}</div> + <div className="task-chat-group-meta">{item.entries.length === 1 ? "1 entry" : `${item.entries.length} entries`}</div> + </div> + </header> + <div className="task-chat-group-bubbles"> + {segments.map((segment) => { + const segmentKey = `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; + return <TaskChatSegmentView key={segmentKey} segment={segment} />; + })} + </div> + </section> + ); + }) + )} + {transcriptItemCount > 0 && !isTranscriptAtBottom ? ( + <button + type="button" + className="task-chat-jump-to-bottom" + onClick={scrollTranscriptToBottom} + aria-label="Jump to latest message" + data-testid="task-chat-jump-to-bottom" + > + <ChevronDown aria-hidden="true" /> + <span>Latest</span> + </button> + ) : null} + </div> + + <form className="task-chat-composer card" onSubmit={handleSubmit}> + {sessionHint ? ( + <div className="task-chat-session-hint" role="status"> + {sessionHint} + </div> + ) : null} + <div className="task-chat-composer-row"> + <textarea + ref={textareaRef} + className="input task-chat-input" + value={draft} + placeholder={composerPlaceholder} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={handleKeyDown} + disabled={sending} + aria-label="Message active agent session" + rows={1} + /> + <button + type="submit" + className="btn btn-primary btn-icon task-chat-send" + disabled={!canSend} + aria-label={sending ? "Sending" : "Send"} + title={sending ? "Sending" : "Send"} + > + {sending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />} + </button> + </div> + </form> + </div> + ); +} diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index a861877090..2a9a35a369 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -94,6 +94,15 @@ overflow-y: hidden; } +/* Chat mirrors the Agent Log fill-height layout: the modal body does not scroll; + the transcript owns internal scrolling while the composer stays visible. */ +.detail-body--chat { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: hidden; +} + .detail-title { font-size: 18px; font-weight: 600; @@ -711,6 +720,44 @@ margin-top: var(--space-lg); } +.detail-section--chat { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin-top: var(--space-lg); +} + +.task-detail-content--chat-expanded .detail-title-row { + display: none; +} + +.task-detail-content--chat-expanded .detail-tabs { + display: none; +} + +.task-detail-content--chat-expanded .modal-actions { + display: none; +} + +.task-detail-content--chat-expanded .modal-header { + flex: 0 0 auto; + justify-content: flex-end; + padding-block: var(--space-sm); +} + +.task-detail-content--chat-expanded .detail-body--chat { + flex: 1; + min-height: 0; + padding: var(--space-md); +} + +.task-detail-content--chat-expanded .detail-section--chat { + flex: 1; + min-height: 0; + margin-top: 0; +} + .detail-spec-edit-trigger { margin-bottom: var(--space-md); @@ -892,8 +939,10 @@ /* FN-5599: widen task detail modal on tablet viewports. */ @media (min-width: 769px) and (max-width: 1024px) { .modal.task-detail-modal { - width: min(92vw, 960px); - max-width: 92vw; + width: min(96vw, 1024px); + max-width: 96vw; + height: 92vh; + max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - 16px); } } @@ -923,6 +972,30 @@ border-radius: 0; resize: none; } + + .detail-body--chat { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: hidden; + } + + .detail-section--chat { + flex: 1; + min-height: 0; + } + + .task-detail-content--chat-expanded .detail-body--chat { + padding: var(--space-sm); + } + + .task-detail-content--chat-expanded .detail-tabs { + display: none; + } + + .task-detail-content--chat-expanded .modal-actions { + display: none; + } } .detail-actions-menu-item-danger { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index afa3271e08..e953fa3a46 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -34,6 +34,7 @@ import { ModelSelectorTab } from "./ModelSelectorTab"; import { PrPanel } from "./PrPanel"; import { PrCreateModal } from "./PrCreateModal"; import { TaskComments } from "./TaskComments"; +import { TaskChatTab } from "./TaskChatTab"; import { TaskReviewTab } from "./TaskReviewTab"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; @@ -283,7 +284,7 @@ function formatDurationCompact(ageMs: number): string { return `${minutes}m`; } -type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`; +type TabId = "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`; // Lazy-load the terminal so xterm + addons stay out of the main bundle (U11). const LazySessionTerminal = lazy(() => @@ -321,17 +322,19 @@ type CliTabVisibility = * - dead/needsAttention (PTY reaped) → replay "session ended" * - no recorded session → hidden */ +export function isCliSessionLive(session: CliSessionSummaryRecord | null): boolean { + return session?.agentState === "starting" + || session?.agentState === "ready" + || session?.agentState === "busy" + || session?.agentState === "waitingOnInput"; +} + export function deriveCliTabVisibility( session: CliSessionSummaryRecord | null, opts: { oneShot?: boolean; genericIdle?: boolean } = {}, ): CliTabVisibility { if (!session) return { kind: "hidden" }; - const live = - session.agentState === "starting" || - session.agentState === "ready" || - session.agentState === "busy" || - session.agentState === "waitingOnInput"; - if (live) { + if (isCliSessionLive(session)) { return { kind: "live", readOnly: Boolean(opts.oneShot), @@ -368,6 +371,7 @@ export interface TaskDetailModalProps { onTaskUpdated?: (task: Task) => void; addToast: (message: string, type?: ToastType) => void; prAuthAvailable?: boolean; + autoMergeEnabled?: boolean; onOpenWorkflowEditor?: () => void; /** Open the modal with this tab active instead of "definition" */ initialTab?: TabId; @@ -549,6 +553,7 @@ export function TaskDetailContent({ onTaskUpdated, addToast, prAuthAvailable, + autoMergeEnabled: autoMergeEnabledProp, onOpenWorkflowEditor, initialTab = "definition", mobileHeaderMode = "close", @@ -559,6 +564,7 @@ export function TaskDetailContent({ const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab); + const [chatExpanded, setChatExpanded] = useState(false); // ── CLI agent session (U11) ──────────────────────────────────────────────── const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null); @@ -619,6 +625,13 @@ export function TaskDetailContent({ prompt: fullDetail.prompt, log: fullDetail.log, githubTracking: task.githubTracking ?? fullDetail.githubTracking, + assignedAgentId: task.assignedAgentId === undefined ? fullDetail.assignedAgentId : task.assignedAgentId, + checkedOutBy: task.checkedOutBy === undefined ? fullDetail.checkedOutBy : task.checkedOutBy, + status: task.status === undefined ? fullDetail.status : task.status, + column: task.column === undefined ? fullDetail.column : task.column, + paused: task.paused === undefined ? fullDetail.paused : task.paused, + userPaused: task.userPaused === undefined ? fullDetail.userPaused : task.userPaused, + pausedReason: task.pausedReason === undefined ? fullDetail.pausedReason : task.pausedReason, } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); const canRetryTask = @@ -765,6 +778,13 @@ export function TaskDetailContent({ // Edit mode state const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (activeTab !== "chat" || isEditing) { + setChatExpanded(false); + } + }, [activeTab, isEditing]); + const [editTitle, setEditTitle] = useState(task.title || ""); const [editDescription, setEditDescription] = useState(task.description || ""); const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []); @@ -1760,7 +1780,7 @@ export function TaskDetailContent({ const handleDelete = useCallback(async () => { let allowResurrection = false; - if (task.column === "done" && onArchiveTask) { + if (task.column !== "archived" && onArchiveTask) { const deleteChoice = await confirmWithChoice({ title: t("taskDetail.delete.title", "Delete Task"), message: t("taskDetail.delete.message", "Delete {{id}}?", { id: task.id }), @@ -2511,6 +2531,11 @@ export function TaskDetailContent({ overlapBlockerTask && (overlapBlockerTask.column === "in-progress" || overlapBlockerTask.column === "in-review"), ); + const handleChatTaskUpdated = useCallback((updatedTask: Task) => { + setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : (updatedTask as TaskDetail)); + onTaskUpdated?.(updatedTask); + }, [onTaskUpdated]); + const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null; const detailProviders = useMemo(() => { const providers: string[] = []; @@ -2605,9 +2630,10 @@ export function TaskDetailContent({ }; const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined; const mergeStrategy = settings?.mergeStrategy ?? "direct"; - const autoMergeEnabled = settings?.autoMerge ?? false; + const autoMergeEnabled = autoMergeEnabledProp ?? (settings?.autoMerge ?? false); const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled }); const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled; + const isChatExpanded = chatExpanded && activeTab === "chat" && !isEditing; const isCheckPrStatusAction = isManualPrFlow && !prAutomationLabel && task.prInfo?.status === "open"; let manualReviewActionLabel = t("taskDetail.pr.mergeAndClose", "Merge & Close"); @@ -2623,7 +2649,7 @@ export function TaskDetailContent({ return ( <div - className={embedded ? "task-detail-content task-detail-content--embedded" : "task-detail-content"} + className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isChatExpanded ? " task-detail-content--chat-expanded" : ""}`} onDragOver={handleDragOver} onDrop={handleDrop} > @@ -2663,7 +2689,7 @@ export function TaskDetailContent({ )} </div> </div> - <div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}`}> + <div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && !isEditing ? " detail-body--chat" : ""}`}> {isEditing ? ( <div className="modal-edit-form"> <TaskForm @@ -2997,6 +3023,12 @@ export function TaskDetailContent({ > {t("taskDetail.tabs.definition", "Definition")} </button> + <button + className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`} + onClick={() => setActiveTab("chat")} + > + {t("taskDetail.tabs.chat", "Chat")} + </button> <button className={`detail-tab${activeTab === "logs" ? " detail-tab-active" : ""}`} onClick={() => setActiveTab("logs")} @@ -3112,6 +3144,19 @@ export function TaskDetailContent({ <div className="detail-section"> <ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} /> </div> + ) : activeTab === "chat" ? ( + <div className="detail-section detail-section--chat"> + <TaskChatTab + task={workingTask} + projectId={projectId} + active={activeTab === "chat"} + addToast={addToast} + sessionLive={isCliSessionLive(cliSession)} + onTaskUpdated={handleChatTaskUpdated} + expanded={chatExpanded} + onToggleExpanded={() => setChatExpanded((value) => !value)} + /> + </div> ) : activeTab === "logs" ? ( <div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}> <div className="log-subview-toggle"> diff --git a/packages/dashboard/app/components/UsageIndicator.css b/packages/dashboard/app/components/UsageIndicator.css index c8e10600e8..e8eb63b9bf 100644 --- a/packages/dashboard/app/components/UsageIndicator.css +++ b/packages/dashboard/app/components/UsageIndicator.css @@ -621,12 +621,20 @@ resize: both; } +.usage-modal-overlay { + --overlay-padding-top: var(--space-lg); +} + .usage-modal.modal { resize: both; overflow: hidden; } @media (max-width: 768px) { + .usage-modal-overlay { + --overlay-padding-top: 0; + } + .usage-modal--popover, .usage-modal.modal { resize: none; diff --git a/packages/dashboard/app/components/UsageIndicator.tsx b/packages/dashboard/app/components/UsageIndicator.tsx index 2d219780cc..f0d9d581df 100644 --- a/packages/dashboard/app/components/UsageIndicator.tsx +++ b/packages/dashboard/app/components/UsageIndicator.tsx @@ -85,6 +85,9 @@ function getUsageColorClass(percentUsed: number): string { const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows"; const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size"; const PROVIDER_ORDER_KEY = "kb-usage-provider-order"; +const DESKTOP_POPOVER_GAP = 8; +const DESKTOP_POPOVER_TOP_INSET = DESKTOP_POPOVER_GAP * 2; +const DESKTOP_POPOVER_MAX_TOP_VIEWPORT_RATIO = 0.25; interface ModalSize { width: number; @@ -890,12 +893,19 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage if (!isOpen) return null; const showDesktopPopover = Boolean(anchorRect && isDesktopViewport); - const desktopGap = 8; - const maxTopPadding = 12; const defaultPopoverWidth = 420; const popoverWidth = savedSize?.width ?? defaultPopoverWidth; const desktopTop = showDesktopPopover - ? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding) + ? Math.max( + DESKTOP_POPOVER_TOP_INSET, + Math.min( + (anchorRect?.bottom ?? 0) + DESKTOP_POPOVER_GAP, + Math.max( + DESKTOP_POPOVER_TOP_INSET, + window.innerHeight * DESKTOP_POPOVER_MAX_TOP_VIEWPORT_RATIO + ) + ) + ) : undefined; // Anchor popover so its right edge aligns with the anchor button's right edge, // but use `left` positioning so native resize (bottom-right handle) feels natural. @@ -1052,7 +1062,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage } return ( - <div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay"> + <div className="modal-overlay open usage-modal-overlay" onClick={handleOverlayClick} data-testid="usage-modal-overlay"> {usageContent} </div> ); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 5d37333a59..47d901dae4 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -1,4 +1,6 @@ .wf-editor-modal { + --wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs)); + display: flex; flex-direction: column; width: min(1200px, 95vw); @@ -12,6 +14,10 @@ border-radius: var(--radius-md); } +.wf-create-modal { + --wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs)); +} + .wf-editor-header { display: flex; align-items: center; @@ -82,8 +88,10 @@ flex-direction: column; gap: var(--space-xs); width: 300px; + min-width: 0; padding: var(--space-sm); border-right: 1px solid var(--border); + overflow-x: hidden; overflow-y: auto; } @@ -95,6 +103,7 @@ display: flex; flex-direction: column; gap: var(--space-xs); + min-width: 0; margin-top: var(--space-sm); padding-top: var(--space-sm); border-top: 1px solid var(--border); @@ -104,6 +113,7 @@ display: flex; flex-direction: column; gap: var(--space-xs); + min-width: 0; } .wf-sidebar-section-toggle { @@ -111,6 +121,7 @@ align-items: center; gap: var(--space-xs); width: 100%; + min-width: 0; padding: var(--space-xs) var(--space-sm); background: transparent; border: none; @@ -222,11 +233,20 @@ display: flex; flex-direction: column; gap: 2px; + min-width: 0; +} + +.wf-editor-list li { + min-width: 0; } .wf-editor-list-item { width: 100%; + min-width: 0; + overflow: hidden; text-align: left; + text-overflow: ellipsis; + white-space: nowrap; padding: var(--space-xs) var(--space-sm); background: transparent; border: 1px solid transparent; @@ -338,6 +358,7 @@ display: flex; gap: var(--space-xs); flex-wrap: wrap; + min-width: 0; } .wf-palette-btn, @@ -347,12 +368,14 @@ display: inline-flex; align-items: center; gap: var(--space-xs); + min-width: 0; padding: var(--space-xs) var(--space-sm); background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); cursor: pointer; + overflow-wrap: anywhere; transition: background var(--transition-fast); } @@ -591,6 +614,145 @@ overflow: hidden; } +.wf-mobile-tabs { + display: flex; + flex: 0 0 auto; + gap: var(--space-xs); + padding: var(--space-sm); + overflow-x: auto; + overflow-y: visible; + border-bottom: 1px solid var(--border); +} + +.wf-mobile-tab { + flex: 0 0 auto; + min-height: var(--wf-editor-touch-target); + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text); + cursor: pointer; + transition: + background var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.wf-mobile-tab--active { + border-color: var(--accent, var(--ws-info)); + background: var(--bg-tertiary); +} + +.wf-mobile-tab:hover { + background: var(--bg-tertiary); +} + +.wf-mobile-tab:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.wf-mobile-tab:active { + transform: scale(0.97); +} + +.wf-mobile-panel { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.wf-mobile-add, +.wf-mobile-actions, +.wf-mobile-destination { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-sm); +} + +.wf-mobile-add-section { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-mobile-add-section h3, +.wf-mobile-template-group h4 { + margin: 0; + color: var(--text); + font-size: 0.85rem; +} + +.wf-mobile-add-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-xs); +} + +.wf-mobile-add-option, +.wf-mobile-template-option { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-xs); + min-width: 0; + min-height: var(--wf-editor-touch-target); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text); + cursor: pointer; + text-align: left; + overflow-wrap: anywhere; + transition: + background var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.wf-mobile-add-option:hover, +.wf-mobile-template-option:hover { + background: var(--bg-tertiary); +} + +.wf-mobile-add-option:focus-visible, +.wf-mobile-template-option:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.wf-mobile-add-option:active, +.wf-mobile-template-option:active { + transform: scale(0.97); +} + +.wf-mobile-template-filter { + width: 100%; +} + +.wf-mobile-template-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-mobile-actions .wf-editor-action, +.wf-mobile-actions .wf-editor-delete, +.wf-mobile-actions .wf-editor-save { + justify-content: center; + min-height: var(--wf-editor-touch-target); +} + +.wf-mobile-ai-panel { + position: static; + inset: auto; + width: 100%; + box-shadow: none; +} + .wf-editor-inspector { display: flex; flex-direction: column; @@ -923,6 +1085,12 @@ overflow-x: auto; } +.wf-editor-sidebar .wf-code-source { + overflow-x: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + /* Header overflow priority (R1): icon fixed-width; label flex-shrinks first * with ellipsis; badges + error badge hold their width flush right. */ .wf-node-icon { @@ -1328,8 +1496,6 @@ .wf-editor-modal, .wf-create-modal { - --wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs)); - width: 100vw; min-width: 0; max-width: 100vw; @@ -1365,10 +1531,12 @@ .wf-editor-body--list-stage .wf-editor-sidebar { display: flex; width: 100%; + min-width: 0; flex: 1 1 auto; max-height: none; border-right: none; border-bottom: none; + overflow-x: hidden; overflow-y: auto; } @@ -1468,143 +1636,6 @@ overflow: hidden; } - .wf-mobile-tabs { - display: flex; - gap: var(--space-xs); - padding: var(--space-sm); - overflow-x: auto; - border-bottom: 1px solid var(--border); - } - - .wf-mobile-tab { - flex: 0 0 auto; - min-height: var(--wf-editor-touch-target); - padding: var(--space-sm) var(--space-md); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-secondary); - color: var(--text); - cursor: pointer; - transition: - background var(--transition-fast), - transform var(--transition-fast), - box-shadow var(--transition-fast); - } - - .wf-mobile-tab--active { - border-color: var(--accent, var(--ws-info)); - background: var(--bg-tertiary); - } - - .wf-mobile-tab:hover { - background: var(--bg-tertiary); - } - - .wf-mobile-tab:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); - } - - .wf-mobile-tab:active { - transform: scale(0.97); - } - - .wf-mobile-panel { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - } - - .wf-mobile-add, - .wf-mobile-actions, - .wf-mobile-destination { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-sm); - } - - .wf-mobile-add-section { - display: flex; - flex-direction: column; - gap: var(--space-sm); - } - - .wf-mobile-add-section h3, - .wf-mobile-template-group h4 { - margin: 0; - color: var(--text); - font-size: 0.85rem; - } - - .wf-mobile-add-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-xs); - } - - .wf-mobile-add-option, - .wf-mobile-template-option { - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: var(--space-xs); - min-width: 0; - min-height: var(--wf-editor-touch-target); - padding: var(--space-sm); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-secondary); - color: var(--text); - cursor: pointer; - text-align: left; - overflow-wrap: anywhere; - transition: - background var(--transition-fast), - transform var(--transition-fast), - box-shadow var(--transition-fast); - } - - .wf-mobile-add-option:hover, - .wf-mobile-template-option:hover { - background: var(--bg-tertiary); - } - - .wf-mobile-add-option:focus-visible, - .wf-mobile-template-option:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); - } - - .wf-mobile-add-option:active, - .wf-mobile-template-option:active { - transform: scale(0.97); - } - - .wf-mobile-template-filter { - width: 100%; - } - - .wf-mobile-template-group { - display: flex; - flex-direction: column; - gap: var(--space-xs); - } - - .wf-mobile-actions .wf-editor-action, - .wf-mobile-actions .wf-editor-delete, - .wf-mobile-actions .wf-editor-save { - justify-content: center; - min-height: var(--wf-editor-touch-target); - } - - .wf-mobile-ai-panel { - position: static; - inset: auto; - width: 100%; - box-shadow: none; - } - .wf-editor-canvas .react-flow, .wf-editor-canvas .react-flow__renderer, .wf-editor-canvas .react-flow__pane, @@ -1722,4 +1753,21 @@ border-top: none; overflow-y: auto; } + + .wf-editor-body--mobile-edge-detail .wf-editor-canvas-wrap { + display: none; + min-height: 0; + } + + .wf-editor-body--mobile-edge-detail .wf-editor-inspector { + display: flex; + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + max-height: none; + border-left: none; + border-top: none; + overflow-y: auto; + } } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 00f17cdbad..e10bb691f1 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -44,7 +44,7 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useConfirm } from "../hooks/useConfirm"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useAppSettings } from "../hooks/useAppSettings"; -import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode"; +import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import type { NodeSummaryCatalogs } from "./nodes/node-summary"; @@ -680,11 +680,8 @@ function InnerEditor({ const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]); const [activeId, setActiveId] = useState<string | null>(null); const viewportMode = useViewportMode(); - const isMobileViewport = viewportMode === "mobile"; - const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; - return window.matchMedia(MOBILE_MEDIA_QUERY).matches; - }); + const isMobileMode = viewportMode === "mobile"; + const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => isMobileViewport()); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [validationError, setValidationError] = useState<string | null>(null); @@ -701,7 +698,7 @@ function InnerEditor({ const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() => initialPanel === "settings" ? "settings" : "graph", ); - const simpleLayoutEnabled = isMobileViewport || compactLayoutEnabled; + const simpleLayoutEnabled = isMobileMode || compactLayoutEnabled; const { t } = useTranslation("app"); const { confirm } = useConfirm(); // Create-workflow dialog (KTD-7) open state + focus-return ref to the @@ -752,9 +749,7 @@ function InnerEditor({ try { const stored = localStorage.getItem(templatesCollapsedStorageKey); if (stored != null) return stored === "1"; - return typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia(MOBILE_MEDIA_QUERY).matches; + return isMobileViewport(); } catch { return false; } @@ -1014,26 +1009,26 @@ function InnerEditor({ if (initialAction !== "create" && initialWorkflowId && data.some((workflow) => workflow.id === initialWorkflowId)) { return initialWorkflowId; } - return isMobileViewport ? null : data[0]?.id ?? null; + return isMobileMode ? null : data[0]?.id ?? null; }); } catch (err) { addToast(getErrorMessage(err) || "Failed to load workflows", "error"); } finally { setLoading(false); } - }, [projectId, addToast, isMobileViewport, initialAction, initialWorkflowId]); + }, [projectId, addToast, isMobileMode, initialAction, initialWorkflowId]); useEffect(() => { void loadWorkflows(); }, [loadWorkflows]); useEffect(() => { - if (!initialWorkflowId || !isMobileViewport || !workflowListStageOpen) return; + if (!initialWorkflowId || !isMobileMode || !workflowListStageOpen) return; if (activeId !== initialWorkflowId) return; if (mobileInitialWorkflowDismissedRef.current === initialWorkflowId) return; mobileInitialWorkflowDismissedRef.current = initialWorkflowId; setWorkflowListStageOpen(false); - }, [activeId, initialWorkflowId, isMobileViewport, workflowListStageOpen]); + }, [activeId, initialWorkflowId, isMobileMode, workflowListStageOpen]); // U2/R5: fire the lazy legacy-step migration once on editor open, then reload // the workflow list so any newly created fragments / "Migrated steps" workflow @@ -1685,12 +1680,12 @@ function InnerEditor({ await deleteWorkflow(activeWorkflow.id, projectId); setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id)); setActiveId(null); - if (isMobileViewport) setWorkflowListStageOpen(true); + if (isMobileMode) setWorkflowListStageOpen(true); addToast(t("workflows.deleted", "Workflow deleted"), "success"); } catch (err) { addToast(getErrorMessage(err) || t("workflows.deleteFailed", "Failed to delete workflow"), "error"); } - }, [activeWorkflow, projectId, addToast, confirm, t, isMobileViewport]); + }, [activeWorkflow, projectId, addToast, confirm, t, isMobileMode]); const handleDuplicate = useCallback(async () => { if (!activeWorkflow) return; @@ -1883,8 +1878,9 @@ function InnerEditor({ selectedNode !== null && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end"; - const mobileNodeDetailStage = isMobileViewport && selectedNodeHasInspector && !inspectorCollapsed; const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null; + const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed; + const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null; const [isPromptExpanded, setIsPromptExpanded] = useState(false); const handleTogglePromptExpand = useCallback(() => { setIsPromptExpanded((prev) => !prev); @@ -2215,7 +2211,8 @@ function InnerEditor({ <div className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}${ simpleLayoutEnabled ? " wf-editor-body--simple-layout" : "" - }${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : "" + }${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : ""}${ + mobileEdgeDetailStage ? " wf-editor-body--mobile-edge-detail" : "" }`} > <aside className="wf-editor-sidebar"> @@ -2264,7 +2261,7 @@ function InnerEditor({ ))} </div> )} - {isMobileViewport && workflows.length > 0 && !activeWorkflow ? ( + {isMobileMode && workflows.length > 0 && !activeWorkflow ? ( <div className="wf-editor-select-note" data-testid="wf-mobile-select-note"> {t("workflows.mobileSelectNote", "Select a workflow to edit.")} </div> @@ -2464,7 +2461,7 @@ function InnerEditor({ {description || t("workflows.descriptionPlaceholder", "Add a description")} </button> )} - {!isMobileViewport && ( + {!isMobileMode && ( <button type="button" className="wf-layout-toggle" @@ -3068,7 +3065,7 @@ function InnerEditor({ )} <div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}> - {isMobileViewport && + {isMobileMode && inspectorCollapsed && selectedNode && selectedNode.data.kind !== "start" && @@ -3152,12 +3149,12 @@ function InnerEditor({ </section> {selectedNodeHasInspector && - !(isMobileViewport && inspectorCollapsed) && - !(compactLayoutEnabled && !isMobileViewport) && ( + !(isMobileMode && inspectorCollapsed) && + !(compactLayoutEnabled && !isMobileMode) && ( <aside className="wf-editor-inspector" data-testid="wf-node-inspector"> <div className="wf-inspector-heading"> <h3>Node</h3> - {isMobileViewport && ( + {isMobileMode && ( <button type="button" className="wf-inspector-toggle wf-inspector-toggle--expanded" @@ -4123,7 +4120,21 @@ function InnerEditor({ {selectedEdge && ( <aside className="wf-editor-inspector" data-testid="wf-edge-inspector"> - <h3>{t("workflowNodes.edgeInspector", "Edge")}</h3> + <div className="wf-inspector-heading"> + <h3>{t("workflowNodes.edgeInspector", "Edge")}</h3> + {isMobileMode && ( + <button + type="button" + className="wf-inspector-toggle wf-inspector-toggle--expanded" + data-testid="wf-edge-inspector-close" + aria-expanded="true" + onClick={() => setSelectedEdgeId(null)} + > + <ChevronDown size={13} /> + <span>{t("workflowNodes.collapseInspector", "Collapse")}</span> + </button> + )} + </div> <fieldset className="wf-inspector-fields" disabled={isBuiltin}> {selectedEdgeEditability === "verdicts" ? ( <> diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index f3dfac9c3e..66d215c592 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -6,9 +6,9 @@ import { Check, ChevronDown, ChevronRight, ChevronUp, Maximize2, Pencil, X } fro import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ReactFlow, ReactFlowProvider } from "@xyflow/react"; -import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core"; +import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core"; import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core"; -import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow, submitTaskWorkflowInput } from "../api"; +import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api"; import { WorkflowSelector } from "./WorkflowSelector"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { ProviderIcon } from "./ProviderIcon"; @@ -323,6 +323,7 @@ export function WorkflowResultsTab({ const [submitted, setSubmitted] = useState(false); const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null); const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]); + const [optionalWorkflowSteps, setOptionalWorkflowSteps] = useState<ResolvedWorkflowOptionalStep[]>([]); const [workflowDefinitions, setWorkflowDefinitions] = useState<WorkflowDefinition[]>([]); const [isEditing, setIsEditing] = useState(false); const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null); @@ -435,16 +436,48 @@ export function WorkflowResultsTab({ }; }, [projectId]); + const effectiveOptionalStepsWorkflowId = selectedWorkflowId || "builtin:coding"; + + useEffect(() => { + let cancelled = false; + fetchWorkflowOptionalSteps(effectiveOptionalStepsWorkflowId, projectId) + .then((steps) => { + if (!cancelled) setOptionalWorkflowSteps(steps); + }) + .catch(() => { + if (!cancelled) setOptionalWorkflowSteps([]); + }); + return () => { + cancelled = true; + }; + }, [effectiveOptionalStepsWorkflowId, projectId]); + const selectedWorkflowSteps = enabledWorkflowSteps ?? []; const workflowStepOptions = useMemo<WorkflowStepOption[]>(() => { - return allWorkflowSteps.map((step) => ({ + const options: WorkflowStepOption[] = allWorkflowSteps.map((step) => ({ id: step.id, name: step.name, description: step.description, phase: (step.phase || "pre-merge") as "pre-merge" | "post-merge", })); - }, [allWorkflowSteps]); + const seen = new Set<string>(); + for (const step of allWorkflowSteps) { + seen.add(step.id); + if (step.templateId) seen.add(step.templateId); + } + for (const step of optionalWorkflowSteps) { + if (seen.has(step.templateId)) continue; + seen.add(step.templateId); + options.push({ + id: step.templateId, + name: step.name, + description: step.description, + phase: step.phase, + }); + } + return options; + }, [allWorkflowSteps, optionalWorkflowSteps]); const workflowStepLookup = useMemo(() => { return new Map(workflowStepOptions.map((step) => [step.id, step])); @@ -840,7 +873,8 @@ export function WorkflowResultsTab({ </button> ) : null; - const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps; + const hasEditableStepOptions = workflowStepOptions.length > 0; + const showConfiguredStepsState = !loading && !hasResults && (hasConfiguredSteps || (canEdit && hasEditableStepOptions)); const showEditHeaderForResults = canEdit && hasResults; const isAwaitingInput = taskStatus === "awaiting-user-input"; diff --git a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx index d9951c59cc..ef08090dd7 100644 --- a/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/ActiveAgentsPanel.test.tsx @@ -1,8 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import i18next from "i18next"; import { ActiveAgentsPanel } from "../ActiveAgentsPanel"; import type { Agent } from "../../api"; import { useLiveTranscript } from "../../hooks/useLiveTranscript"; +import esApp from "../../../../i18n/locales/es/app.json"; +import frApp from "../../../../i18n/locales/fr/app.json"; +import koApp from "../../../../i18n/locales/ko/app.json"; +import zhCNApp from "../../../../i18n/locales/zh-CN/app.json"; +import zhTWApp from "../../../../i18n/locales/zh-TW/app.json"; // Mock useLiveTranscript vi.mock("../../hooks/useLiveTranscript", () => ({ @@ -14,6 +20,14 @@ vi.mock("../../hooks/useLiveTranscript", () => ({ const mockUseLiveTranscript = vi.mocked(useLiveTranscript); +const nonEnglishAppCatalogs = [ + ["es", esApp], + ["fr", frApp], + ["ko", koApp], + ["zh-CN", zhCNApp], + ["zh-TW", zhTWApp], +] as const; + describe("ActiveAgentsPanel", () => { beforeEach(() => { vi.clearAllMocks(); @@ -23,6 +37,16 @@ describe("ActiveAgentsPanel", () => { }); }); + afterEach(async () => { + await i18next.changeLanguage("en"); + for (const [locale] of nonEnglishAppCatalogs) { + if (i18next.hasResourceBundle(locale, "app")) { + i18next.removeResourceBundle(locale, "app"); + } + } + i18next.options.returnEmptyString = true; + }); + it("renders live transcript text from entries", async () => { mockUseLiveTranscript.mockReturnValue({ entries: [ @@ -79,6 +103,49 @@ describe("ActiveAgentsPanel", () => { expect(mockUseLiveTranscript).toHaveBeenCalledWith("FN-001", undefined); }); + it("renders next-heartbeat labels without raw placeholders across non-English locales", async () => { + i18next.options.returnEmptyString = false; + + for (const [locale, appCatalog] of nonEnglishAppCatalogs) { + i18next.addResourceBundle(locale, "app", appCatalog, true, true); + await i18next.changeLanguage(locale); + + const futureAgent: Agent = { + id: `agent-future-${locale}`, + name: `Future Agent ${locale}`, + role: "executor", + state: "running", + taskId: `FN-${locale}`, + lastHeartbeatAt: new Date().toISOString(), + } as Agent; + + const futureRender = render(<ActiveAgentsPanel agents={[futureAgent]} />); + const futureBadge = futureRender.container.querySelector(".live-agent-card-next-heartbeat"); + expect(futureBadge, `${locale} next-heartbeat badge`).toBeInTheDocument(); + expect(futureBadge?.textContent?.trim(), `${locale} next-heartbeat text`).not.toBe(""); + expect(futureBadge?.textContent, `${locale} next-heartbeat raw placeholder`).not.toContain("{{"); + futureRender.unmount(); + + const overdueAgent: Agent = { + id: `agent-overdue-${locale}`, + name: `Overdue Agent ${locale}`, + role: "executor", + state: "running", + taskId: `FN-overdue-${locale}`, + lastHeartbeatAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + } as Agent; + + const overdueRender = render(<ActiveAgentsPanel agents={[overdueAgent]} />); + const overdueBadge = overdueRender.container.querySelector(".live-agent-card-next-heartbeat"); + expect(overdueBadge, `${locale} heartbeat-overdue badge`).toBeInTheDocument(); + expect(overdueBadge?.textContent?.trim(), `${locale} heartbeat-overdue text`).not.toBe(""); + expect(overdueBadge?.textContent, `${locale} heartbeat-overdue raw placeholder`).not.toContain("{{"); + overdueRender.unmount(); + + i18next.removeResourceBundle(locale, "app"); + } + }); + it("renders empty state when no entries yet", async () => { mockUseLiveTranscript.mockReturnValue({ entries: [], diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx index 915257c549..fbc3d04904 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.advanced-settings.test.tsx @@ -709,6 +709,35 @@ describe("Advanced Settings", () => { }); }); + it.each([ + ["undefined", undefined, false], + ["false", false, false], + ["true", true, true], + ] as const)("renders engineer backlog auto-claim unchecked by default and reflects %s runtimeConfig", async (_label, engineerBacklogAutoClaim, expectedChecked) => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "engineer", + runtimeConfig: { + heartbeatIntervalMs: 30000, + ...(engineerBacklogAutoClaim === undefined ? {} : { engineerBacklogAutoClaim }), + }, + })); + + const user = userEvent.setup(); + render( + <AgentDetailView + agentId="agent-001" + onClose={vi.fn()} + addToast={vi.fn()} + /> + ); + + await navigateToSettings(user); + + await waitFor(() => { + expect((screen.getByLabelText("Engineer Backlog Auto-Claim") as HTMLInputElement).checked).toBe(expectedChecked); + }); + }); + it("shows Save Settings button disabled when no changes", async () => { mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} })); @@ -1018,6 +1047,81 @@ describe("Advanced Settings", () => { }); }); + it("persists engineer backlog auto-claim enabled override on save", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "engineer", + runtimeConfig: { + enabled: true, + heartbeatIntervalMs: 30000, + }, + })); + mockUpdateAgent.mockResolvedValue(createMockAgent() as any); + + const user = userEvent.setup(); + render( + <AgentDetailView + agentId="agent-001" + onClose={vi.fn()} + addToast={vi.fn()} + /> + ); + + await navigateToSettings(user); + + const toggle = await screen.findByLabelText("Engineer Backlog Auto-Claim"); + expect((toggle as HTMLInputElement).checked).toBe(false); + await user.click(toggle); + await user.click(screen.getByText("Save Settings")); + + await waitFor(() => { + expect(mockUpdateAgent).toHaveBeenCalledWith( + "agent-001", + expect.objectContaining({ + runtimeConfig: expect.objectContaining({ engineerBacklogAutoClaim: true }), + }), + undefined, + ); + }); + }); + + it("persists engineer backlog auto-claim disabled override on save", async () => { + mockFetchAgent.mockResolvedValue(createMockAgent({ + role: "engineer", + runtimeConfig: { + enabled: true, + engineerBacklogAutoClaim: true, + heartbeatIntervalMs: 30000, + }, + })); + mockUpdateAgent.mockResolvedValue(createMockAgent() as any); + + const user = userEvent.setup(); + render( + <AgentDetailView + agentId="agent-001" + onClose={vi.fn()} + addToast={vi.fn()} + /> + ); + + await navigateToSettings(user); + + const toggle = await screen.findByLabelText("Engineer Backlog Auto-Claim"); + expect((toggle as HTMLInputElement).checked).toBe(true); + await user.click(toggle); + await user.click(screen.getByText("Save Settings")); + + await waitFor(() => { + expect(mockUpdateAgent).toHaveBeenCalledWith( + "agent-001", + expect.objectContaining({ + runtimeConfig: expect.objectContaining({ engineerBacklogAutoClaim: false }), + }), + undefined, + ); + }); + }); + it("applies coordination-only preset and persists disabled auto-claim", async () => { mockFetchAgent.mockResolvedValue(createMockAgent({ runtimeConfig: { diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx index a840c5ac92..8045ba98ef 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { loadAllAppCss } from "../../test/cssFixture"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { setupAgentDetailMocks } from "./AgentDetailView.test-helpers"; import { AgentDetailView } from "../AgentDetailView"; @@ -46,4 +46,36 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => { expect(window.getComputedStyle(tabsEl).flexShrink).toBe("0"); expect(window.getComputedStyle(footerEl).flexShrink).toBe("0"); }); + + it("tabs are horizontally scrollable at tablet widths (FN-6209)", async () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + const style = document.head.querySelector("style[data-testid='fn-4231-css']") as HTMLStyleElement; + style.textContent = loadAllAppCssBaseOnly(); + + render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />); + + await waitFor(() => { + expect(document.querySelector(".agent-detail-tabs")).toBeTruthy(); + }); + + const tabsEl = document.querySelector(".agent-detail-tabs") as HTMLElement; + const tabEl = document.querySelector(".agent-detail-tab") as HTMLElement; + + expect(window.getComputedStyle(tabsEl).overflowX).toBe("auto"); + expect(window.getComputedStyle(tabEl).whiteSpace).toBe("nowrap"); + }); }); diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 3ba51cd0d0..bed6c69404 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import i18next from "i18next"; import { loadAllAppCss } from "../../test/cssFixture"; import { AgentsView } from "../AgentsView"; import * as apiModule from "../../api"; @@ -184,6 +185,10 @@ describe("AgentsView", () => { mockUpdateSettings.mockResolvedValue({}); }); + afterEach(() => { + i18next.removeResourceBundle("en", "app"); + }); + const openControlsPanel = async () => { const trigger = await screen.findByRole("button", { name: "Controls" }); fireEvent.click(trigger); @@ -817,7 +822,14 @@ describe("AgentsView", () => { expect(options).not.toContain("1m"); }); - it("renders Last/Next heartbeat timestamps without seconds", async () => { + it("renders Last/Next heartbeat timestamps without seconds when old catalog keys collide", async () => { + i18next.addResourceBundle( + "en", + "app", + { agents: { lastHeartbeat: "Last heartbeat", nextHeartbeat: "Next heartbeat in {{elapsed}}" } }, + true, + true, + ); const lastHeartbeatAt = "2026-05-04T14:23:45.000Z"; mockFetchAgents.mockResolvedValueOnce([ { @@ -828,7 +840,7 @@ describe("AgentsView", () => { ]); mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: { active: 1 }, byRole: { triage: 1 } }); - render(<AgentsView addToast={mockAddToast} />); + const { container } = render(<AgentsView addToast={mockAddToast} />); const lastAt = new Date(lastHeartbeatAt); const nextAt = new Date(lastAt.getTime() + 300000); @@ -840,6 +852,13 @@ describe("AgentsView", () => { expect(screen.getByText(expectedNext)).toBeTruthy(); }); + const lastBadge = container.querySelector(".agent-heartbeat-last"); + const nextBadge = container.querySelector(".agent-heartbeat-next"); + expect(lastBadge?.textContent).toMatch(/Last: .*\d/); + expect(lastBadge?.textContent).not.toBe("Last heartbeat"); + expect(nextBadge?.textContent).toMatch(/Next: .*\d/); + expect(nextBadge?.textContent).not.toContain("{{"); + expect(nextBadge?.textContent).not.toContain("{{elapsed}}"); expect(screen.queryByText(/Last: .*:\d{2}:\d{2}/)).toBeNull(); expect(screen.queryByText(/Next: .*:\d{2}:\d{2}/)).toBeNull(); }); diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index c21fa5359f..c23b908458 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -590,6 +590,7 @@ vi.mock("../../hooks/useViewportMode", () => ({ // Mock isIOS so FN-3290 keyboard-open behavior is testable in jsdom vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn(), + useMobileKeyboardViewportLock: vi.fn(), isIOS: () => true, _resetLockState: vi.fn(), })); diff --git a/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx index 41270d9196..3445bec171 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx @@ -33,9 +33,19 @@ describe("ChatView chat input autosize", () => { expect(stopRule?.[0]).toContain("min-height: var(--chat-input-control-size)"); }); + it("caps textarea max-height at 200px on tablet viewports", () => { + const tabletRule = chatViewCss.match( + /@media \(min-width: 769px\) and \(max-width: 1024px\)\s*\{\s*\.chat-input-textarea\s*\{[^}]*\}\s*\}/, + ); + + expect(tabletRule).not.toBeNull(); + expect(tabletRule?.[0]).toContain("max-height: 200px"); + }); + it("clamps oversized textarea growth to the new max height", () => { expect(clampChatInputHeight(600)).toBe(600); expect(clampChatInputHeight(800)).toBe(640); + expect(clampChatInputHeight(800, 200)).toBe(200); expect(clampChatInputHeight(600)).not.toBe(120); }); @@ -45,7 +55,11 @@ describe("ChatView chat input autosize", () => { it("keeps overflow hidden until content exceeds the max height cap", () => { expect(resolveChatInputOverflowY(80)).toBe("hidden"); + expect(resolveChatInputOverflowY(200)).toBe("hidden"); + expect(resolveChatInputOverflowY(201)).toBe("hidden"); expect(resolveChatInputOverflowY(640)).toBe("hidden"); expect(resolveChatInputOverflowY(641)).toBe("auto"); + expect(resolveChatInputOverflowY(200, 200)).toBe("hidden"); + expect(resolveChatInputOverflowY(201, 200)).toBe("auto"); }); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx index d5e60b10c7..638c9de5b7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx @@ -386,6 +386,59 @@ describe("FN-5997 mobile chat message pane rendering", () => { } }); + it("keeps sidebar width bounded even if viewport mode flickers to mobile during keyboard-open on tablet", async () => { + const restoreMatchMedia = mockViewportMode("tablet"); + const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); + const visualViewport = mockVisualViewport({ width: 900, height: 1112 }); + try { + setupChat({ + sessions: [activeSession], + filteredSessions: [activeSession], + activeSession, + }); + await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />); + + const sidebar = getSidebar(); + expect(sidebar).not.toHaveClass("chat-sidebar--hidden"); + expect(sidebar.style.width).toBe("280px"); + + const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await act(async () => { + input.focus(); + }); + await setVisualViewportHeight(visualViewport, 400); + + // Simulate the FN-6213 bug scenario where viewport mode transiently + // resolves to mobile on a tablet while the keyboard has shrunk height. + Object.defineProperty(window, "screen", { configurable: true, value: { width: 390, height: 844 } }); + restoreMatchMedia.mockImplementation((query: string) => ({ + matches: + query.includes("max-width: 768px") || + query.includes("max-height: 480px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + await act(async () => { + window.dispatchEvent(new Event("resize")); + }); + + await waitFor(() => expect(sidebar.style.width).toBe("")); + const maxWidth = parseInt(getComputedStyle(sidebar).maxWidth, 10); + expect(maxWidth).toBeLessThanOrEqual(500); + expect(sidebar.offsetWidth).toBeLessThanOrEqual(500); + } finally { + restoreMatchMedia.mockRestore(); + if (originalScreenDescriptor) { + Object.defineProperty(window, "screen", originalScreenDescriptor); + } + } + }); + it("keeps the desktop sidebar fixed even if visualViewport shrinks while the composer is focused", async () => { const restoreMatchMedia = mockViewportMode("desktop"); const visualViewport = mockVisualViewport({ width: 1280, height: 900 }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index bd2a9cf1f7..88dc9e37a7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -11,6 +11,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; vi.mock("../../hooks/useChat"); vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn(), + useMobileKeyboardViewportLock: vi.fn(), isIOS: () => true, _resetLockState: vi.fn(), })); @@ -605,20 +606,30 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />); const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; - const roomFocusSpy = vi.spyOn(roomInput, "focus"); + const roomTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const roomPreventDefaultSpy = vi.spyOn(roomTouchEvent, "preventDefault"); await act(async () => { - fireEvent.touchStart(roomInput); + fireEvent(roomInput, roomTouchEvent); + if (!roomTouchEvent.defaultPrevented) { + roomInput.focus(); + } }); - expect(roomFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(roomPreventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(roomInput); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; - const directFocusSpy = vi.spyOn(directInput, "focus"); + const directTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const directPreventDefaultSpy = vi.spyOn(directTouchEvent, "preventDefault"); await act(async () => { - fireEvent.touchStart(directInput); + fireEvent(directInput, directTouchEvent); + if (!directTouchEvent.defaultPrevented) { + directInput.focus(); + } }); - expect(directFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(directPreventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(directInput); mediaSpy.mockRestore(); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 2ac69b93f4..34d47fcbfa 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -22,6 +22,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; import type { UseChatRoomsResult } from "../../hooks/useChatRooms"; +import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; // Mock the hooks vi.mock("../../hooks/useChat"); @@ -2830,6 +2831,53 @@ describe("ChatView CSS — failure bubble contracts", () => { }); }); +describe("ChatView CSS — active state edge highlights", () => { + const css = loadAllAppCss(); + + function findRule(selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + expect(match).toBeTruthy(); + return match?.[1] ?? ""; + } + + function mobileRuleContains(selector: string, propertyPattern: RegExp): boolean { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mobileRegex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; + let match; + while ((match = mobileRegex.exec(css)) !== null) { + const ruleMatch = match[1].match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + if (ruleMatch && propertyPattern.test(ruleMatch[1])) { + return true; + } + } + return false; + } + + it("keeps scope-tab active tint without the removed bottom underline", async () => { + const activeScopeRule = findRule(".chat-sidebar-scope-btn--active"); + + expect(activeScopeRule).toContain("background: var(--card)"); + expect(activeScopeRule).toContain("color: var(--text)"); + expect(activeScopeRule).not.toContain("box-shadow"); + expect(activeScopeRule).not.toContain("inset"); + }); + + it("keeps active chat-row background without the removed left edge or offset", async () => { + const activeSessionRule = findRule(".chat-session-item--active"); + + expect(activeSessionRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent)"); + expect(activeSessionRule).not.toContain("border-left"); + expect(activeSessionRule).not.toContain("padding-left: calc(var(--space-md) - (var(--btn-border-width) * 3))"); + }); + + it("does not reintroduce either removed highlight in mobile rules", async () => { + expect(mobileRuleContains(".chat-sidebar-scope-btn--active", /box-shadow\s*:\s*inset/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /border-left\s*:/)).toBe(false); + expect(mobileRuleContains(".chat-session-item--active", /padding-left\s*:\s*calc\(var\(--space-md\)\s*-\s*\(var\(--btn-border-width\)\s*\*\s*3\)\)/)).toBe(false); + }); +}); + describe("FN-3911 chat session list layout", () => { const css = loadAllAppCss(); @@ -3869,6 +3917,50 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: iOS first tap focuses direct composer without blocking native focus, then sends", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const sendMessage = vi.fn(); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [], + sendMessage, + }); + + await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); + + const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + input.blur(); + expect(document.activeElement).not.toBe(input); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + // jsdom has no soft keyboard/native touch-focus default action; mirror + // the browser focus that iOS only performs when touchstart is not canceled. + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + + fireEvent.change(input, { target: { value: "Hello mobile" } }); + const sendButton = screen.getByTestId("chat-send-btn"); + fireEvent.touchStart(sendButton); + fireEvent.click(sendButton); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("Hello mobile", []); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: send button sends on first touch and keeps composer focused", async () => { const restoreMatchMedia = mockMobileViewport(); const sendMessage = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx index 236d11b34b..0d4330fe8a 100644 --- a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx @@ -145,6 +145,68 @@ describe("CustomProvidersSection", () => { }); }); + it("exposes Google Generative AI in the add provider API type dropdown", async () => { + mockFetchCustomProviders.mockResolvedValueOnce([]); + + render(<CustomProvidersSection embedded />); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i })); + + const apiTypeSelect = screen.getByLabelText("API type") as HTMLSelectElement; + expect(Array.from(apiTypeSelect.options).map((option) => option.value)).toContain("google-generative-ai"); + expect(screen.getByRole("option", { name: "Google Generative AI" })).toBeTruthy(); + }); + + it("exposes Google Generative AI in the edit provider API type dropdown", async () => { + mockFetchCustomProviders.mockResolvedValueOnce([ + { + id: "test-id", + name: "Editable Provider", + apiType: "anthropic-compatible", + baseUrl: "https://api.example.com", + }, + ]); + + render(<CustomProvidersSection embedded />); + + await waitFor(() => { + expect(screen.getByLabelText("Edit Editable Provider")).toBeTruthy(); + }); + + fireEvent.click(screen.getByLabelText("Edit Editable Provider")); + + const apiTypeSelect = screen.getByLabelText("API type") as HTMLSelectElement; + expect(Array.from(apiTypeSelect.options).map((option) => option.value)).toContain("google-generative-ai"); + expect(screen.getByRole("option", { name: "Google Generative AI" })).toBeTruthy(); + }); + + it("selects Google Generative AI when editing an existing Google provider", async () => { + mockFetchCustomProviders.mockResolvedValueOnce([ + { + id: "google-id", + name: "Google Provider", + apiType: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + }, + ]); + + render(<CustomProvidersSection embedded />); + + await waitFor(() => { + expect(screen.getByLabelText("Edit Google Provider")).toBeTruthy(); + }); + + fireEvent.click(screen.getByLabelText("Edit Google Provider")); + + const apiTypeSelect = screen.getByLabelText("API type") as HTMLSelectElement; + expect(apiTypeSelect.value).toBe("google-generative-ai"); + expect(apiTypeSelect.selectedOptions[0]?.value).toBe("google-generative-ai"); + }); + it("shows validation errors for empty name and invalid baseUrl", async () => { render(<CustomProvidersSection embedded />); @@ -244,6 +306,49 @@ describe("CustomProvidersSection", () => { }); }); + it("does not echo the masked key back when editing without retyping", async () => { + mockFetchCustomProviders + .mockResolvedValueOnce([ + { + id: "test-id", + name: "Keyed Provider", + apiType: "openai-compatible", + baseUrl: "https://api.example.com", + // Server returns the key masked for display. + apiKey: "abc•••••wxyz", + }, + ]) + .mockResolvedValueOnce([ + { + id: "test-id", + name: "Keyed Provider", + apiType: "openai-compatible", + baseUrl: "https://api.example.com", + }, + ]); + + render(<CustomProvidersSection embedded />); + + await waitFor(() => { + expect(screen.getByLabelText("Edit Keyed Provider")).toBeTruthy(); + }); + + fireEvent.click(screen.getByLabelText("Edit Keyed Provider")); + + // The API key field must start empty, never seeded with the mask. + const apiKeyInput = screen.getByLabelText("API key") as HTMLInputElement; + expect(apiKeyInput.value).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(mockUpdateCustomProvider).toHaveBeenCalledTimes(1); + }); + // apiKey is omitted entirely so the stored credential is preserved. + const [, payload] = mockUpdateCustomProvider.mock.calls[0]; + expect(payload).not.toHaveProperty("apiKey"); + }); + it("deletes provider after confirmation", async () => { mockFetchCustomProviders .mockResolvedValueOnce([ diff --git a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx index ad88ab8b02..d2d823997a 100644 --- a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { InlineCreateCard } from "../InlineCreateCard"; import type { Task, Column } from "@fusion/core"; -import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks, fetchWorkflows } from "../../api"; +import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks, fetchWorkflows, fetchWorkflowOptionalSteps } from "../../api"; import { useNodes } from "../../hooks/useNodes"; import type { ModelInfo } from "../../api"; import { scopedKey } from "../../utils/projectStorage"; @@ -127,6 +127,16 @@ vi.mock("../../api", () => ({ updateGlobalSettings: vi.fn(), fetchAgents: vi.fn().mockResolvedValue([]), selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([ + { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge", + defaultOn: false, + }, + ]), fetchWorkflows: vi.fn().mockResolvedValue([]), fetchProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }), setProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }), @@ -239,6 +249,16 @@ beforeEach(() => { { id: "wf-a", name: "Workflow A" }, { id: "wf-b", name: "Workflow B" }, ]); + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([ + { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge", + defaultOn: false, + }, + ]); }); describe("InlineCreateCard textarea width (FN-1608)", () => { @@ -1020,6 +1040,18 @@ describe("InlineCreateCard button visibility when collapsed", () => { expect(document.getElementById("inline-create-controls")).toBeTruthy(); }); + it("renders no optional-step shell when workflow has no optional steps", async () => { + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValueOnce([]); + renderCard([]); + expandCard(); + + await waitFor(() => { + expect(fetchWorkflowOptionalSteps).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("inline-create-browser-verification-toggle")).not.toBeInTheDocument(); + expect(document.querySelector(".inline-create-optional-steps")).toBeNull(); + }); + it("submits with enabledWorkflowSteps undefined", async () => { const mockOnSubmit = vi.fn().mockResolvedValue(createMockTask()); renderCard([], { onSubmit: mockOnSubmit }); @@ -1047,7 +1079,12 @@ describe("InlineCreateCard button visibility when collapsed", () => { target: { value: "Verify login flow in browser" }, }); - fireEvent.click(screen.getByTestId("inline-create-browser-verification-toggle")); + const toggle = await screen.findByTestId("inline-create-browser-verification-toggle"); + expect(toggle).toHaveTextContent("Browser Verification"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByTestId("save-button")); await waitFor(() => { diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index a9c797bdbb..76567e6fd6 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -291,9 +291,7 @@ describe("NewTaskModal", () => { expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - await waitFor(() => { - expect(props.onCreateTask).not.toHaveBeenCalled(); - }); + expect(props.onCreateTask).not.toHaveBeenCalled(); }); it("submits custom-new branch selection when branch name exists", async () => { @@ -328,9 +326,7 @@ describe("NewTaskModal", () => { expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); - await waitFor(() => { - expect(props.onCreateTask).not.toHaveBeenCalled(); - }); + expect(props.onCreateTask).not.toHaveBeenCalled(); }); it("submits shared-group branch selection when shared branch exists", async () => { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 0b0ac4364e..1a0b91497a 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -9,6 +9,7 @@ import { useViewportMode } from "../../hooks/useViewportMode"; import { useMobileKeyboard } from "../../hooks/useMobileKeyboard"; import { useAppSettings } from "../../hooks/useAppSettings"; import { useChatRooms } from "../../hooks/useChatRooms"; +import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; import { QuickChatFAB } from "../QuickChatFAB"; import { FileBrowserProvider } from "../../context/FileBrowserContext"; @@ -799,6 +800,82 @@ describe("QuickChatFAB session-first UX", () => { expect(screen.getByTestId("quick-chat-session-option-session-model")).toBeInTheDocument(); }); + it("FN-6301: iOS first tap focuses composer without canceling native focus, then sends", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ + close: vi.fn(), + isConnected: () => true, + })); + + try { + render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + input.blur(); + expect(document.activeElement).not.toBe(input); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + // jsdom has no soft keyboard/native touch-focus default action; mirror + // the browser focus that iOS only performs when touchstart is not canceled. + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + expect(screen.getByTestId("quick-chat-send")).toBeDisabled(); + + fireEvent.change(input, { target: { value: "Hello quick mobile" } }); + const sendButton = screen.getByTestId("quick-chat-send"); + fireEvent.touchStart(sendButton); + fireEvent.click(sendButton); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + }); + expect(mockStreamChatResponse).toHaveBeenCalledWith("session-model", "Hello quick mobile", expect.any(Object), [], "proj-1"); + expect(await screen.findByTestId("quick-chat-stop")).toBeInTheDocument(); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + } + }); + + it("FN-6301: Android mobile composer touchstart leaves native focus uncanceled", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(false); + + try { + render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + input.blur(); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + } + }); + it("uses icon-only model tag without pill styling when mobile header fallback is active", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); window.dispatchEvent(new Event("resize")); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 936e19a791..003fb30b92 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -238,6 +238,12 @@ function clickSave() { fireEvent.click(screen.getByTestId("quick-entry-save")); } +async function flushPendingTimers() { + await act(async () => { + vi.runOnlyPendingTimers(); + }); +} + function openPriorityMenu() { fireEvent.click(screen.getByTestId("quick-entry-priority-button")); } @@ -347,26 +353,163 @@ describe("QuickEntryBox", () => { expect((textarea as HTMLTextAreaElement).rows).toBe(2); }); - it("focuses the quick-entry textarea on mount at desktop width", async () => { - mockDesktopViewport(); - renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input"); + describe("post-submission focus restoration (FN-6217)", () => { + it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => { + mockDesktopViewport(); + renderQuickEntryBox({}); + const textarea = screen.getByTestId("quick-entry-input"); - await waitFor(() => { - expect(document.activeElement).toBe(textarea); - }); - }); + await flushPendingTimers(); - it("does not focus the quick-entry textarea on mount at mobile width", async () => { - const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(375); - renderQuickEntryBox({}); - const textarea = screen.getByTestId("quick-entry-input"); - - await waitFor(() => { expect(document.activeElement).not.toBe(textarea); }); - innerWidthSpy.mockRestore(); + it("does not auto-focus the quick-entry textarea when restoring a non-empty draft on desktop mount", async () => { + mockDesktopViewport(); + localStorage.setItem(QUICK_ENTRY_STORAGE_KEY, "restored draft"); + renderQuickEntryBox({}); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + + await flushPendingTimers(); + + expect(textarea.value).toBe("restored draft"); + expect(document.activeElement).not.toBe(textarea); + }); + + it("does not auto-focus the quick-entry textarea on desktop remount or visibility restoration", async () => { + mockDesktopViewport(); + const { unmount } = renderQuickEntryBox({}); + let textarea = screen.getByTestId("quick-entry-input"); + + await flushPendingTimers(); + expect(document.activeElement).not.toBe(textarea); + + unmount(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + document.dispatchEvent(new Event("visibilitychange")); + renderQuickEntryBox({}); + textarea = screen.getByTestId("quick-entry-input"); + + await flushPendingTimers(); + + expect(document.activeElement).not.toBe(textarea); + }); + + it("focuses the quick-entry textarea after a successful Enter submission on desktop", async () => { + mockDesktopViewport(); + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + renderQuickEntryBox({ onCreate }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + const focusSpy = vi.spyOn(textarea, "focus"); + + fireEvent.change(textarea, { target: { value: "Create from Enter" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await flushPendingTimers(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(textarea); + }); + + it("focuses the quick-entry textarea after a successful Save-button submission on desktop", async () => { + mockDesktopViewport(); + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + renderQuickEntryBox({ onCreate }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + const focusSpy = vi.spyOn(textarea, "focus"); + + fireEvent.change(textarea, { target: { value: "Create from Save" } }); + clickSave(); + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await flushPendingTimers(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(textarea); + }); + + it("focuses the quick-entry textarea only after duplicate-confirmed creation completes on desktop", async () => { + mockDesktopViewport(); + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([ + { id: "FN-456", title: "Duplicate", description: "desc", column: "todo", score: 0.7 }, + ]); + renderQuickEntryBox({ onCreate }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + const focusSpy = vi.spyOn(textarea, "focus"); + + fireEvent.change(textarea, { target: { value: "maybe duplicate" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + expect(await screen.findByText("Possible duplicates")).toBeInTheDocument(); + await flushPendingTimers(); + expect(focusSpy).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Create anyway" })); + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(textarea.value).toBe("")); + await flushPendingTimers(); + + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(textarea); + }); + + it("never auto-focuses the quick-entry textarea on mobile, including after a successful submission", async () => { + mockMobileViewport(); + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + renderQuickEntryBox({ onCreate }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + const focusSpy = vi.spyOn(textarea, "focus"); + + await flushPendingTimers(); + expect(document.activeElement).not.toBe(textarea); + + fireEvent.change(textarea, { target: { value: "Mobile submission" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await flushPendingTimers(); + + expect(focusSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(textarea); + }); + + it("does not auto-focus after Escape clears a non-empty draft", async () => { + mockDesktopViewport(); + renderQuickEntryBox({}); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + + textarea.focus(); + fireEvent.focus(textarea); + fireEvent.change(textarea, { target: { value: "Clear me" } }); + fireEvent.keyDown(textarea, { key: "Escape" }); + await flushPendingTimers(); + + expect(textarea.value).toBe(""); + expect(document.activeElement).not.toBe(textarea); + }); + + it("does not auto-focus after Plan or Subtask handoff reset the form", async () => { + mockDesktopViewport(); + const onPlanningMode = vi.fn(); + const onSubtaskBreakdown = vi.fn(); + renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown }); + let textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: "Plan this" } }); + fireEvent.click(screen.getByTestId("plan-button")); + await flushPendingTimers(); + expect(onPlanningMode).toHaveBeenCalledWith("Plan this"); + expect(document.activeElement).not.toBe(textarea); + + fireEvent.change(textarea, { target: { value: "Break this down" } }); + fireEvent.click(screen.getByTestId("subtask-button")); + await flushPendingTimers(); + textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down"); + expect(document.activeElement).not.toBe(textarea); + }); }); describe("button focus preservation (FN-6122)", () => { @@ -480,6 +623,10 @@ describe("QuickEntryBox", () => { await waitFor(() => { expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled(); }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + textarea.focus(); + fireEvent.focus(textarea); + expect(document.activeElement).toBe(textarea); return result; } @@ -708,6 +855,150 @@ describe("QuickEntryBox", () => { }); }); + describe("button focus — no refocus when textarea is blurred (FN-6211)", () => { + async function renderBlurredMobileQuickEntry() { + mockMobileViewport(); + vi.mocked(fetchSettings).mockResolvedValueOnce({ + githubTrackingEnabledByDefault: true, + } as any); + const onPlanningMode = vi.fn(); + const onSubtaskBreakdown = vi.fn(); + const result = renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown }); + expandQuickEntry(); + await waitFor(() => { + expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled(); + }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + textarea.focus(); + fireEvent.focus(textarea); + fireEvent.change(textarea, { target: { value: "Adjust options without keyboard" } }); + textarea.blur(); + fireEvent.blur(textarea); + expect(document.activeElement).not.toBe(textarea); + return { ...result, textarea, onPlanningMode, onSubtaskBreakdown }; + } + + function fireCancelableTouchStart(target: Element) { + const event = new Event("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + fireEvent(target, event); + return { preventDefaultSpy }; + } + + async function touchActionButtonWithoutRefocus(button: Element, textarea: HTMLTextAreaElement) { + const { preventDefaultSpy } = fireCancelableTouchStart(button); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(textarea); + await act(async () => { + fireEvent(button, new Event("touchend", { bubbles: true, cancelable: true })); + fireEvent.click(button); + vi.runOnlyPendingTimers(); + vi.runOnlyPendingTimers(); + }); + expect(document.activeElement).not.toBe(textarea); + } + + async function assertBlurredButtonActionStillWorks( + testId: string, + helpers: Awaited<ReturnType<typeof renderBlurredMobileQuickEntry>>, + attachClickSpy?: ReturnType<typeof vi.spyOn>, + ) { + switch (testId) { + case "quick-entry-fast-toggle": + expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "true"); + break; + case "quick-entry-github-toggle": + expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "false"); + break; + case "quick-entry-priority-button": + expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy(); + break; + case "quick-entry-deps": + expect(document.querySelector(".dep-dropdown")).toBeTruthy(); + break; + case "quick-entry-models": + expect(await screen.findByTestId("model-nested-menu")).toBeTruthy(); + break; + case "quick-entry-node-button": + expect(document.querySelector(".node-picker-dropdown")).toBeTruthy(); + break; + case "quick-entry-agent-button": + expect(document.querySelector(".agent-picker-dropdown")).toBeTruthy(); + break; + case "quick-entry-attach": + expect(attachClickSpy).toHaveBeenCalled(); + break; + case "refine-button": + expect(await screen.findByTestId("refine-clarify")).toBeTruthy(); + break; + case "plan-button": + expect(helpers.onPlanningMode).toHaveBeenCalledWith("Adjust options without keyboard"); + break; + case "subtask-button": + expect(helpers.onSubtaskBreakdown).toHaveBeenCalledWith("Adjust options without keyboard"); + break; + case "quick-entry-save": + await waitFor(() => { + expect(helpers.props.onCreate).toHaveBeenCalled(); + }); + break; + default: + throw new Error(`Unhandled QuickEntry action test id: ${testId}`); + } + } + + it.each(QUICK_ENTRY_ACTION_BUTTONS)( + "does not refocus textarea when tapping %s with textarea blurred", + async (_label, testId) => { + const helpers = await renderBlurredMobileQuickEntry(); + const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement; + const attachClickSpy = vi.spyOn(fileInput, "click"); + const button = screen.getByTestId(testId); + + await touchActionButtonWithoutRefocus(button, helpers.textarea); + await assertBlurredButtonActionStillWorks(testId, helpers, attachClickSpy); + expect(document.activeElement).not.toBe(helpers.textarea); + }, + ); + + it("does not refocus textarea when selecting a priority option after blurred touch open", async () => { + const { textarea } = await renderBlurredMobileQuickEntry(); + const priorityButton = screen.getByTestId("quick-entry-priority-button"); + + await touchActionButtonWithoutRefocus(priorityButton, textarea); + const highOption = await screen.findByTestId("quick-entry-priority-option-high"); + await act(async () => { + fireEvent.touchStart(highOption); + fireEvent.touchEnd(highOption); + fireEvent.click(highOption); + vi.runOnlyPendingTimers(); + vi.runOnlyPendingTimers(); + }); + + expect(priorityButton.textContent).toContain("High"); + expect(document.activeElement).not.toBe(textarea); + }); + + it("does not refocus textarea when selecting a dependency after blurred touch open", async () => { + const { textarea } = await renderBlurredMobileQuickEntry(); + const depsButton = screen.getByTestId("quick-entry-deps"); + + await touchActionButtonWithoutRefocus(depsButton, textarea); + const depItem = document.querySelector(".dep-dropdown-item"); + expect(depItem).toBeTruthy(); + await act(async () => { + fireEvent.touchStart(depItem!); + fireEvent.touchEnd(depItem!); + fireEvent.click(depItem!); + vi.runOnlyPendingTimers(); + vi.runOnlyPendingTimers(); + }); + + expect(depsButton.textContent).toContain("1 dep"); + expect(document.activeElement).not.toBe(textarea); + }); + }); + it("textarea spans full container width (FN-1608)", () => { mockDesktopViewport(); renderQuickEntryBox({}); diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index fe73748f4b..2d97e6ef8d 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; +import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode"; // ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ────────── const mockTerm = { @@ -51,25 +52,44 @@ let originalWebSocket: typeof WebSocket | undefined; }; // ── matchMedia mock: drive the mobile breakpoint convention ───────────────── -let matchMediaMatches = true; -function installMatchMedia(matches: boolean) { - matchMediaMatches = matches; +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; +const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); +type MatchMediaState = boolean | { width: boolean; height: boolean }; +let matchMediaState: MatchMediaState = true; +function installMatchMedia(state: MatchMediaState) { + matchMediaState = state; Object.defineProperty(window, "matchMedia", { writable: true, configurable: true, - value: vi.fn((query: string) => ({ - matches: matchMediaMatches, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn((query: string) => { + const matches = typeof matchMediaState === "boolean" + ? matchMediaState + : query === MOBILE_MEDIA_QUERY + ? matchMediaState.width || matchMediaState.height + : query === MOBILE_WIDTH_MEDIA_QUERY + ? matchMediaState.width + : query === MOBILE_HEIGHT_MEDIA_QUERY + ? matchMediaState.height + : false; + return { + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); } +function stubScreen(width: number, height: number) { + Object.defineProperty(window, "screen", { configurable: true, value: { width, height } }); +} + import { SessionTerminal } from "../SessionTerminal"; /** Pull the parsed input frames a WS has sent. */ @@ -96,11 +116,15 @@ beforeEach(() => { apiMock.mockReset(); apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); installMatchMedia(true); // mobile by default + stubScreen(390, 844); _resetInitialViewportHeight(); }); afterEach(() => { (globalThis as typeof globalThis & { WebSocket?: typeof WebSocket }).WebSocket = originalWebSocket; + if (originalScreenDescriptor) { + Object.defineProperty(window, "screen", originalScreenDescriptor); + } vi.clearAllMocks(); }); @@ -118,6 +142,13 @@ describe("SessionTerminal (mobile)", () => { expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull(); }); + it("does not render the mobile bar when a tablet-class screen only matches the short-height clause", async () => { + stubScreen(1024, 768); + installMatchMedia({ width: false, height: true }); + await renderMobile(); + expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull(); + }); + it("does not render the mobile bar when read-only", async () => { apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true }); await renderMobile({ readOnly: true }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 68798a41ab..8a01d84a5a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -1568,15 +1568,17 @@ describe("SettingsModal", () => { }); } - it("renders the workflow model save actions inside the default workflow lane section", async () => { + it("renders only advanced workflow actions inside the default workflow lane section", async () => { const onOpenWorkflowSettings = vi.fn(); await setupWorkflowModelLaneTest({ renderProps: { onOpenWorkflowSettings } }); const workflowHeading = screen.getByRole("heading", { name: "Default workflow model lanes" }); - const saveButton = screen.getByTestId("save-workflow-model-lanes"); - const actionRow = saveButton.closest(".settings-model-lane-actions"); + const advancedButton = screen.getByRole("button", { name: "Advanced workflow policy" }); + const actionRow = advancedButton.closest(".settings-model-lane-actions"); const presetsHeading = screen.getByRole("heading", { name: "Model Presets" }); + expect(screen.queryByTestId("save-workflow-model-lanes")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save workflow models" })).not.toBeInTheDocument(); expect(actionRow).toBeInTheDocument(); expect(actionRow).toHaveAttribute("aria-label", "Default workflow model lane actions"); expect(within(actionRow as HTMLElement).getByRole("button", { name: "Advanced workflow policy" })).toBeInTheDocument(); @@ -1588,17 +1590,18 @@ describe("SettingsModal", () => { ["Plan/Triage Model", { planningProvider: "openai", planningModelId: "gpt-4o" }], ["Executor Model", { executionProvider: "openai", executionModelId: "gpt-4o" }], ["Reviewer Model", { validatorProvider: "openai", validatorModelId: "gpt-4o" }], - ])("proxy-edits %s through workflow setting values for the default workflow", async (laneLabel, expectedPatch) => { + ])("persists %s edits through the primary Settings Save", async (laneLabel, expectedPatch) => { mockUpdateWorkflowSettingValues.mockResolvedValue({ stored: expectedPatch, effective: expectedPatch, orphaned: [], }); - await setupWorkflowModelLaneTest(); + const onClose = vi.fn(); + await setupWorkflowModelLaneTest({ renderProps: { onClose } }); await userEvent.click(screen.getByLabelText(laneLabel)); await userEvent.click(await screen.findByText("GPT-4o")); - await userEvent.click(screen.getByTestId("save-workflow-model-lanes")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith( @@ -1607,10 +1610,22 @@ describe("SettingsModal", () => { "proj-1", ); }); - expect(mockUpdateSettings).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); }); - it("resets workflow model lanes by sending null patches", async () => { + it("does not write workflow settings when the primary Save has no pending workflow edits", async () => { + const onClose = vi.fn(); + await setupWorkflowModelLaneTest({ renderProps: { onClose } }); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + expect(mockUpdateWorkflowSettingValues).not.toHaveBeenCalled(); + }); + + it("resets workflow model lanes by sending null patches from the primary Settings Save", async () => { await setupWorkflowModelLaneTest({ stored: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" }, effective: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" }, @@ -1618,7 +1633,7 @@ describe("SettingsModal", () => { const lane = screen.getByTestId("workflow-model-lane-execution"); await userEvent.click(within(lane).getByRole("button", { name: "Reset" })); - await userEvent.click(screen.getByTestId("save-workflow-model-lanes")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith( @@ -1646,7 +1661,7 @@ describe("SettingsModal", () => { await userEvent.click(screen.getByLabelText("Plan/Triage Model")); await userEvent.click(await screen.findByText("GPT-4o")); - await userEvent.click(screen.getByTestId("save-workflow-model-lanes")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith( @@ -1657,22 +1672,26 @@ describe("SettingsModal", () => { }); }); - it("shows typed workflow model lane rejections without clearing pending edits", async () => { + it("shows typed workflow model lane rejections without closing or clearing pending edits", async () => { + const addToast = vi.fn(); + const onClose = vi.fn(); mockUpdateWorkflowSettingValues.mockRejectedValueOnce( new ApiRequestError("rejected", 400, { rejections: [{ code: "unknown-setting", settingId: "planningProvider", message: "planningProvider is not declared" }], }), ); - await setupWorkflowModelLaneTest(); + await setupWorkflowModelLaneTest({ renderProps: { addToast, onClose } }); await userEvent.click(screen.getByLabelText("Plan/Triage Model")); await userEvent.click(await screen.findByText("GPT-4o")); - await userEvent.click(screen.getByTestId("save-workflow-model-lanes")); + await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { expect(screen.getByTestId("workflow-model-lane-error-planning")).toHaveTextContent("planningProvider is not declared"); }); - expect(screen.getByTestId("save-workflow-model-lanes")).not.toBeDisabled(); + expect(onClose).not.toHaveBeenCalled(); + expect(addToast).not.toHaveBeenCalledWith("Settings saved", "success"); + expect(within(screen.getByTestId("workflow-model-lane-planning")).getByText("GPT-4o")).toBeInTheDocument(); }); it("does not fetch or write workflow model lanes without an active project", async () => { @@ -1688,6 +1707,9 @@ describe("SettingsModal", () => { expect(screen.getByText(/Open a project to edit workflow model lanes/i)).toBeInTheDocument(); expect(mockFetchWorkflowSettingValues).not.toHaveBeenCalled(); expect(screen.queryByTestId("save-workflow-model-lanes")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(mockUpdateWorkflowSettingValues).not.toHaveBeenCalled(); }); }); @@ -2626,6 +2648,72 @@ describe("SettingsModal", () => { const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>; expect(payload.heartbeatScopeDiscipline).toBe("off"); }); + + it.each([ + ["undefined", undefined, false], + ["false", false, false], + ["true", true, true], + ] as const)("renders engineer backlog auto-claim from %s project setting", async (_label, engineerBacklogAutoClaim, expectedChecked) => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + ...(engineerBacklogAutoClaim === undefined ? {} : { engineerBacklogAutoClaim }), + }); + + renderModal(); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + fireEvent.click(screen.getByText("Scheduling & Capacity")); + + expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked); + }); + + it("routes enabled engineer backlog auto-claim through the project settings save payload", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + engineerBacklogAutoClaim: false, + }); + + renderModal(); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + fireEvent.click(screen.getByText("Scheduling & Capacity")); + + const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; + expect(toggle.checked).toBe(false); + await userEvent.click(toggle); + await userEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + }); + + const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>; + expect(payload.engineerBacklogAutoClaim).toBe(true); + }); + + it("routes disabled engineer backlog auto-claim through the project settings save payload", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + engineerBacklogAutoClaim: true, + }); + + renderModal(); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + fireEvent.click(screen.getByText("Scheduling & Capacity")); + + const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; + expect(toggle.checked).toBe(true); + await userEvent.click(toggle); + await userEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + }); + + const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>; + expect(payload.engineerBacklogAutoClaim).toBe(false); + }); }); describe("Number input clearing", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 2922370fb5..dbe0780c0a 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -462,6 +462,50 @@ describe("TaskCard", () => { expect(screen.getByLabelText("Archive task")).toBeDefined(); }); + it.each(["triage", "todo", "in-progress", "in-review"] as const)( + "hides archive action for %s tasks", + (column) => { + render( + <TaskCard + task={makeTask({ column })} + onOpenDetail={noop} + addToast={noop} + onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))} + />, + ); + + expect(screen.queryByLabelText("Archive task")).toBeNull(); + }, + ); + + it("renders archive action for done tasks", () => { + render( + <TaskCard + task={makeTask({ column: "done" })} + onOpenDetail={noop} + addToast={noop} + onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))} + />, + ); + + expect(screen.getByLabelText("Archive task")).toBeDefined(); + }); + + it("does not render archive action for archived tasks", () => { + render( + <TaskCard + task={makeTask({ column: "archived" })} + onOpenDetail={noop} + addToast={noop} + onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))} + onUnarchiveTask={vi.fn(async () => makeTask({ column: "done" }))} + />, + ); + + expect(screen.queryByLabelText("Archive task")).toBeNull(); + expect(screen.getByLabelText("Unarchive task")).toBeDefined(); + }); + it("keeps two-button delete flow for non-done task", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(false); @@ -1963,7 +2007,7 @@ describe("TaskCard", () => { expect(actionsContainer?.contains(editBtn)).toBe(true); }); - it("renders archive button inside card-header-actions for done column", () => { + it("renders archive button inside card-header-actions for done columns", () => { const { container } = render( <TaskCard task={makeTask({ column: "done", size: "L" })} diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx new file mode 100644 index 0000000000..40b37c9251 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -0,0 +1,1525 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { AgentLogEntry, Task } from "@fusion/core"; +import { TaskChatTab } from "../TaskChatTab"; +import { isCliSessionLive, type CliSessionSummaryRecord } from "../TaskDetailModal"; +import { useAgentLogs } from "../../hooks/useAgentLogs"; +import { addSteeringComment, refineTask } from "../../api"; + +vi.mock("../../hooks/useAgentLogs", () => ({ + useAgentLogs: vi.fn(), +})); + +vi.mock("../../api", () => ({ + addSteeringComment: vi.fn(), + refineTask: vi.fn(), +})); + +const mockedUseAgentLogs = vi.mocked(useAgentLogs); +const mockedAddSteeringComment = vi.mocked(addSteeringComment); +const mockedRefineTask = vi.mocked(refineTask); +const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop"); +const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); +const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); +const originalRequestAnimationFrame = window.requestAnimationFrame; +const originalCancelAnimationFrame = window.cancelAnimationFrame; + +function makeTask(overrides: Partial<Task> = {}): Task { + return { + id: "FN-001", + title: "Task", + description: "Task description", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + assignedAgentId: "agent-1", + status: undefined, + ...overrides, + } as Task; +} + +function makeCliSession(agentState: CliSessionSummaryRecord["agentState"]): CliSessionSummaryRecord { + return { + id: "session-1", + taskId: "FN-001", + projectId: "project-1", + adapterId: "claude", + agentState, + terminationReason: null, + }; +} + +function makeEntry(overrides: Partial<AgentLogEntry>): AgentLogEntry { + return { + timestamp: "2026-06-12T00:00:00.000Z", + taskId: "FN-001", + type: "text", + text: "message", + ...overrides, + } as AgentLogEntry; +} + +function makeSteeringComment(overrides: Partial<NonNullable<Task["steeringComments"]>[number]> = {}): NonNullable<Task["steeringComments"]>[number] { + return { + id: "steer-1", + text: "Persisted user guidance", + createdAt: "2026-06-12T00:00:01.000Z", + author: "user", + ...overrides, + }; +} + +function deferred<T>() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<T>((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function getCssRuleBlock(css: string, selector: string): string { + const selectorIndex = css.indexOf(selector); + if (selectorIndex < 0) return ""; + const ruleStart = css.indexOf("{", selectorIndex); + const ruleEnd = css.indexOf("}", ruleStart); + return ruleStart >= 0 && ruleEnd >= 0 ? css.slice(ruleStart + 1, ruleEnd) : ""; +} + +function getCssAfter(css: string, marker: string): string { + const markerIndex = css.indexOf(marker); + return markerIndex >= 0 ? css.slice(markerIndex) : ""; +} + +function mockLogs(entries: AgentLogEntry[] = [], loading = false) { + mockedUseAgentLogs.mockReturnValue({ + entries, + loading, + clear: vi.fn(), + loadMore: vi.fn(async () => {}), + hasMore: false, + total: entries.length, + loadingMore: false, + }); +} + +function expectComposerSendableAfterDraft(message = "Please continue") { + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: message } }); + expect(sendButton).not.toBeDisabled(); +} + +function expectNoInactiveSessionHint() { + expect(screen.queryByText(/picked up by the next session/i)).not.toBeInTheDocument(); + expect(document.querySelector(".task-chat-session-hint")).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument(); +} + +function expectActiveSessionCopy() { + expect(screen.getByText(/active agent session/i)).toBeInTheDocument(); + expect(screen.getByText(/delivered to the running session in real time/i)).toBeInTheDocument(); +} + +function expectDoneRefinementCopy() { + expect(screen.getByText(/start a refinement task for this completed task/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument(); +} + +function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, name, descriptor); + return; + } + delete (HTMLElement.prototype as Record<string, unknown>)[name]; +} + +function mockTranscriptMetrics({ + scrollHeight = 1200, + clientHeight = 240, + initialScrollTop = 0, +}: { + scrollHeight?: number; + clientHeight?: number; + initialScrollTop?: number; +} = {}) { + let scrollTopValue = initialScrollTop; + let scrollHeightValue = scrollHeight; + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollHeightValue : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? clientHeight : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "scrollTop", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollTopValue : 0; + }, + set(value) { + if (this instanceof HTMLElement && this.classList.contains("task-chat-transcript")) { + scrollTopValue = Number(value); + } + }, + }); + return { + get scrollTop() { + return scrollTopValue; + }, + set scrollTop(value: number) { + scrollTopValue = value; + }, + get scrollHeight() { + return scrollHeightValue; + }, + set scrollHeight(value: number) { + scrollHeightValue = value; + }, + }; +} + +function mockMatchMedia(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function mockRequestAnimationFrame() { + let nextId = 1; + const callbacks = new Map<number, FrameRequestCallback>(); + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + const id = nextId; + nextId += 1; + callbacks.set(id, callback); + return id; + }); + const cancelAnimationFrame = vi.fn((id: number) => { + callbacks.delete(id); + }); + + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + writable: true, + value: requestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + writable: true, + value: cancelAnimationFrame, + }); + + return { + requestAnimationFrame, + cancelAnimationFrame, + flushNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [id, callback] = next.value; + callbacks.delete(id); + callback(performance.now()); + return true; + }, + get pendingCount() { + return callbacks.size; + }, + }; +} + +describe("TaskChatTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLogs(); + }); + + afterEach(() => { + restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor); + restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor); + restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + writable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + writable: true, + value: originalCancelAnimationFrame, + }); + }); + + it("subscribes to live agent logs only when active", () => { + render(<TaskChatTab task={makeTask()} active={false} projectId="project-1" addToast={vi.fn()} />); + expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1"); + }); + + it("renders empty state when no agent output exists", () => { + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByText(/No agent output yet/)).toBeTruthy(); + }); + + it("renders the collapsed expand toggle and calls the toggle handler", () => { + const onToggleExpanded = vi.fn(); + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={onToggleExpanded} />); + + const toggle = screen.getByTestId("task-chat-expand-toggle"); + expect(toggle).toHaveAttribute("aria-label", "Expand chat to full modal"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + expect(toggle).toHaveTextContent("Expand"); + + fireEvent.click(toggle); + expect(onToggleExpanded).toHaveBeenCalledTimes(1); + }); + + it("renders the expanded collapse toggle", () => { + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded onToggleExpanded={vi.fn()} />); + + const toggle = screen.getByTestId("task-chat-expand-toggle"); + expect(toggle).toHaveAttribute("aria-label", "Collapse chat"); + expect(toggle).toHaveAttribute("aria-pressed", "true"); + expect(toggle).toHaveTextContent("Collapse"); + }); + + it("renders the expand toggle while the transcript is loading", () => { + mockLogs([], true); + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />); + + expect(screen.getByTestId("task-chat-expand-toggle")).toBeInTheDocument(); + expect(screen.getByText("Loading agent output…")).toBeInTheDocument(); + }); + + it("renders the expand toggle in the empty transcript state", () => { + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />); + + expect(screen.getByTestId("task-chat-expand-toggle")).toBeInTheDocument(); + expect(screen.getByText(/No agent output yet/)).toBeInTheDocument(); + }); + + it("labels every agent role and the legacy undefined-agent fallback", () => { + mockLogs([ + makeEntry({ agent: "triage", text: "planning output" }), + makeEntry({ agent: "executor", text: "executor output" }), + makeEntry({ agent: "reviewer", text: "reviewer output" }), + makeEntry({ agent: "merger", text: "merger output" }), + makeEntry({ text: "legacy output" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(screen.getByText("Planner")).toBeTruthy(); + expect(screen.getByText("Executor")).toBeTruthy(); + expect(screen.getByText("Reviewer")).toBeTruthy(); + expect(screen.getByText("Merger")).toBeTruthy(); + expect(screen.getByText("Agent")).toBeTruthy(); + expect(screen.getByText("legacy output")).toBeTruthy(); + }); + + it("groups consecutive entries by agent role", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "first" }), + makeEntry({ agent: "executor", text: "second" }), + makeEntry({ agent: "reviewer", text: "third" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(screen.getByText("2 entries")).toBeTruthy(); + expect(screen.getByLabelText("Executor messages")).toBeTruthy(); + expect(screen.getByLabelText("Reviewer messages")).toBeTruthy(); + }); + + it("renders a single text entry as one text bubble", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "single response" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(1); + expect(within(textBubbles[0]).getByText("single response")).toBeVisible(); + }); + + it("combines consecutive text entries into one continuous text bubble", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "first chunk " }), + makeEntry({ agent: "executor", text: "second chunk" }), + makeEntry({ agent: "executor", text: " third chunk" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(1); + expect(textBubbles[0]).toHaveClass("task-chat-entry", "task-chat-entry--text"); + expect(textBubbles[0]).toHaveTextContent("first chunk second chunk third chunk"); + expect(within(textBubbles[0]).queryByRole("separator")).not.toBeInTheDocument(); + }); + + it("keeps text entries on different agent-role runs in separate bubbles", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "executor first" }), + makeEntry({ agent: "executor", text: " executor second" }), + makeEntry({ agent: "reviewer", text: "reviewer first" }), + makeEntry({ agent: "reviewer", text: " reviewer second" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(2); + expect(within(screen.getByLabelText("Executor messages")).getByTestId("task-chat-entry-text")) + .toHaveTextContent("executor first executor second"); + expect(within(screen.getByLabelText("Reviewer messages")).getByTestId("task-chat-entry-text")) + .toHaveTextContent("reviewer first reviewer second"); + }); + + it("counts a tool call plus result as one collapsed invocation and shows the tool name", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test" }), + makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + const summary = toolGroup.querySelector("summary"); + expect(summary).toBeTruthy(); + expect(toolGroup).toHaveClass("task-chat-tool-group"); + expect(summary).toHaveClass("task-chat-tool-group-summary"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible(); + expect(within(summary as HTMLElement).getByText("bash")).toBeVisible(); + expect(screen.queryByText("2 tool calls")).not.toBeInTheDocument(); + expect(screen.getByText("pnpm test")).not.toBeVisible(); + expect(screen.getByText("ok")).not.toBeVisible(); + + await user.click(within(summary as HTMLElement).getByText("1 tool call")); + + expect(toolGroup).toHaveAttribute("open"); + const invocation = screen.getByTestId("task-chat-tool-invocation"); + const kicker = screen.getByText("Tool call → result"); + expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation"); + expect(kicker).toHaveClass("task-chat-entry-kicker"); + expect(kicker).toBeVisible(); + expect(screen.getByText("Arguments")).toBeVisible(); + expect(screen.getByText("Result")).toBeVisible(); + expect(screen.getByText("pnpm test")).toBeVisible(); + expect(screen.getByText("ok")).toBeVisible(); + }); + + it("summarizes multiple invocations with deduped names and overflow", () => { + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "run tests" }), + makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }), + makeEntry({ agent: "executor", type: "tool", text: "read", detail: "open file" }), + makeEntry({ agent: "executor", type: "tool_result", text: "read", detail: "contents" }), + makeEntry({ agent: "executor", type: "tool", text: "edit", detail: "patch" }), + makeEntry({ agent: "executor", type: "tool_result", text: "edit", detail: "done" }), + makeEntry({ agent: "executor", type: "tool", text: "grep", detail: "search" }), + makeEntry({ agent: "executor", type: "tool_result", text: "grep", detail: "matches" }), + makeEntry({ agent: "executor", type: "tool", text: "find", detail: "glob" }), + makeEntry({ agent: "executor", type: "tool_result", text: "find", detail: "paths" }), + makeEntry({ agent: "executor", type: "tool", text: "write", detail: "file" }), + makeEntry({ agent: "executor", type: "tool_result", text: "write", detail: "saved" }), + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "rerun" }), + makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok again" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const summary = screen.getByTestId("task-chat-tool-group").querySelector("summary"); + expect(summary).toBeTruthy(); + expect(within(summary as HTMLElement).getByText("7 tool calls")).toBeVisible(); + const names = within(summary as HTMLElement).getByLabelText("Tool names"); + expect(names).toHaveTextContent("bash, read, edit, grep, find, +1 more"); + expect(within(summary as HTMLElement).getByText(", +1 more")).toBeVisible(); + }); + + it("surfaces tool errors in the summary and paired expanded body", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test" }), + makeEntry({ agent: "executor", type: "tool_error", text: "bash", detail: "stderr" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + const summary = toolGroup.querySelector("summary"); + expect(summary).toBeTruthy(); + const errorCount = within(summary as HTMLElement).getByText("1 error"); + expect(errorCount).toBeVisible(); + expect(errorCount).toHaveClass("task-chat-tool-group-error-count"); + expect(screen.getByText("stderr")).not.toBeVisible(); + + await user.click(within(summary as HTMLElement).getByText("1 tool call")); + + expect(screen.getByText("Tool call → error")).toBeVisible(); + expect(screen.getByText("Error")).toBeVisible(); + expect(screen.getByText("stderr")).toBeVisible(); + }); + + it("renders a single tool entry as one collapsed group and tolerates missing detail", () => { + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: undefined }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + const summary = toolGroup.querySelector("summary"); + expect(summary).toBeTruthy(); + expect(toolGroup).not.toHaveAttribute("open"); + expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible(); + expect(within(summary as HTMLElement).getByText("bash")).toBeVisible(); + expect(screen.queryByText("Arguments")).not.toBeInTheDocument(); + }); + + it("falls back to result entries when a tool completion has no preceding call", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + const summary = toolGroup.querySelector("summary"); + expect(summary).toBeTruthy(); + expect(toolGroup).not.toHaveAttribute("open"); + expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible(); + expect(within(summary as HTMLElement).getByText("bash")).toBeVisible(); + expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument(); + + await user.click(within(summary as HTMLElement).getByText("1 tool call")); + + const standaloneEntry = screen.getByTestId("task-chat-entry-tool_result"); + const standaloneKicker = screen.getByText("Tool result"); + expect(standaloneEntry).toHaveClass("task-chat-tool-entry"); + expect(standaloneKicker).toHaveClass("task-chat-entry-kicker"); + }); + + it("renders thinking in an expanded-by-default collapsible block", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const thinking = screen.getByTestId("task-chat-thinking"); + expect(thinking).toHaveAttribute("open"); + expect(within(thinking).getByText("Thinking")).toBeVisible(); + expect(screen.getByText("I am considering options")).toBeVisible(); + expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1); + + await user.click(within(thinking).getByText("Thinking")); + + expect(thinking).not.toHaveAttribute("open"); + expect(screen.getByText("I am considering options")).not.toBeVisible(); + }); + + it("renders consecutive thinking entries as one continuous section", () => { + mockLogs([ + makeEntry({ agent: "triage", type: "thinking", text: "First" }), + makeEntry({ agent: "triage", type: "thinking", text: "Second", timestamp: "2026-06-12T00:00:01.000Z" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const thinking = screen.getByTestId("task-chat-thinking"); + const summary = thinking.querySelector("summary"); + expect(summary).toBeTruthy(); + expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible(); + expect(screen.queryByText("2 thinking entries")).not.toBeInTheDocument(); + const thinkingBlocks = within(thinking).getAllByTestId("task-chat-entry-thinking"); + expect(thinkingBlocks).toHaveLength(1); + expect(thinkingBlocks[0]).toHaveTextContent("FirstSecond"); + expect(thinkingBlocks[0].nextElementSibling).toBeNull(); + }); + + it("creates distinct tool segments when text or thinking entries are interleaved", () => { + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "first tool", detail: "first detail" }), + makeEntry({ agent: "executor", text: "plain response" }), + makeEntry({ agent: "executor", type: "thinking", text: "thinking between tools" }), + makeEntry({ agent: "executor", type: "tool_result", text: "second tool", detail: "second detail" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroups = screen.getAllByTestId("task-chat-tool-group"); + expect(toolGroups).toHaveLength(2); + expect(toolGroups[0]).not.toHaveAttribute("open"); + expect(toolGroups[1]).not.toHaveAttribute("open"); + expect(screen.getAllByText("1 tool call")).toHaveLength(2); + expect(within(toolGroups[0]).getByLabelText("Tool names")).toHaveTextContent("first tool"); + expect(within(toolGroups[1]).getByLabelText("Tool names")).toHaveTextContent("second tool"); + expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1); + expect(screen.getByText("plain response")).toBeVisible(); + expect(screen.getByText("thinking between tools")).toBeVisible(); + }); + + it("appends newly streamed entries from the hook without auto-opening tool groups", () => { + const firstEntries = [makeEntry({ agent: "executor", text: "first live chunk" })]; + const secondEntries = [ + ...firstEntries, + makeEntry({ agent: "executor", type: "tool", text: "streamed tool", detail: "streamed detail", timestamp: "2026-06-12T00:00:01.000Z" }), + makeEntry({ agent: "executor", text: "second live chunk", timestamp: "2026-06-12T00:00:02.000Z" }), + ]; + mockedUseAgentLogs.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }); + mockedUseAgentLogs.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 3, loadingMore: false }); + + const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByText("first live chunk")).toBeVisible(); + + rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(screen.getByText("1 tool call")).toBeVisible(); + expect(screen.getByText("streamed detail")).not.toBeVisible(); + expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(2); + expect(screen.getByText("second live chunk")).toBeVisible(); + }); + + it.each([ + ["desktop", false], + ["mobile", true], + ])("snaps populated transcripts to the bottom on initial %s render", (_label, matchesMobile) => { + mockMatchMedia(matchesMobile); + const metrics = mockTranscriptMetrics({ scrollHeight: 1400, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([ + makeEntry({ agent: "executor", text: "older output" }), + makeEntry({ agent: "executor", text: "latest output", timestamp: "2026-06-12T00:00:01.000Z" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(screen.getByTestId("task-chat-transcript")).toBeTruthy(); + expect(metrics.scrollTop).toBe(metrics.scrollHeight); + }); + + it("snaps to the bottom when the tab reactivates with unchanged cached entries", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + const cachedEntries = [ + makeEntry({ agent: "executor", text: "cached first" }), + makeEntry({ agent: "executor", text: "cached latest", timestamp: "2026-06-12T00:00:01.000Z" }), + ]; + mockLogs(cachedEntries); + + const { rerender } = render(<TaskChatTab task={makeTask()} active={false} addToast={vi.fn()} />); + expect(metrics.scrollTop).toBe(0); + + rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(metrics.scrollTop).toBe(metrics.scrollHeight); + }); + + it("snaps when entries first become populated after an active empty render", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1100, clientHeight: 240, initialScrollTop: 0 }); + const loadedEntries = [makeEntry({ agent: "executor", text: "loaded output" })]; + mockedUseAgentLogs + .mockReturnValueOnce({ entries: [], loading: true, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 0, loadingMore: false }) + .mockReturnValueOnce({ entries: loadedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }); + + const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(metrics.scrollTop).toBe(0); + + rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(metrics.scrollTop).toBe(metrics.scrollHeight); + }); + + it("FN-6337: re-pins populated transcripts to the bottom after async height growth", () => { + const raf = mockRequestAnimationFrame(); + const metrics = mockTranscriptMetrics({ scrollHeight: 600, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([ + makeEntry({ agent: "executor", text: "older output" }), + makeEntry({ agent: "executor", type: "thinking", text: "expanded thinking", timestamp: "2026-06-12T00:00:01.000Z" }), + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:02.000Z" }), + ]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(metrics.scrollTop).toBe(600); + metrics.scrollHeight = 900; + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(900); + + metrics.scrollHeight = 1200; + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(1200); + + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(1200); + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(metrics.scrollHeight); + expect(raf.pendingCount).toBe(0); + }); + + it("FN-6337: bounds and cleans up the settle loop", () => { + const raf = mockRequestAnimationFrame(); + const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "output" })]); + + const { unmount } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + for (let frame = 0; frame < 5; frame += 1) { + metrics.scrollHeight += 100; + expect(raf.flushNext()).toBe(true); + } + expect(metrics.scrollTop).toBe(1000); + expect(raf.pendingCount).toBe(0); + + metrics.scrollHeight = 1300; + mockLogs([makeEntry({ agent: "executor", text: "output after remount" })]); + const mountedAgain = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(raf.pendingCount).toBe(1); + mountedAgain.unmount(); + expect(raf.cancelAnimationFrame).toHaveBeenCalled(); + expect(raf.pendingCount).toBe(0); + + metrics.scrollHeight = 1600; + expect(raf.flushNext()).toBe(false); + expect(metrics.scrollTop).toBe(1300); + unmount(); + }); + + it("does not mutate scroll position for an empty transcript", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 }); + mockLogs([]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(screen.getByText(/No agent output yet/)).toBeTruthy(); + expect(metrics.scrollTop).toBe(25); + }); + + it("continues following new entries when the user is near the bottom", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 }); + const firstEntries = [makeEntry({ agent: "executor", text: "first output" })]; + const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })]; + mockedUseAgentLogs + .mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }) + .mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false }); + + const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(metrics.scrollTop).toBe(1000); + + metrics.scrollTop = 720; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + metrics.scrollHeight = 1400; + rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(metrics.scrollTop).toBe(1400); + }); + + it("does not yank a scrolled-up user when a new entry arrives", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 }); + const firstEntries = [makeEntry({ agent: "executor", text: "first output" })]; + const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })]; + mockedUseAgentLogs + .mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }) + .mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false }); + + const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(metrics.scrollTop).toBe(1000); + + metrics.scrollTop = 120; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + metrics.scrollHeight = 1400; + rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(metrics.scrollTop).toBe(120); + }); + + it("does not render the jump-to-bottom button for loading or empty transcripts", () => { + mockLogs([], true); + const loading = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByText(/Loading agent output/)).toBeVisible(); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + loading.unmount(); + + mockLogs([]); + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByText(/No agent output yet/)).toBeVisible(); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + }); + + it("renders the jump-to-bottom button only after a populated transcript is scrolled up", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "latest output" })]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(metrics.scrollTop).toBe(1200); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + + metrics.scrollTop = 920; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + + metrics.scrollTop = 600; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + const jumpButton = screen.getByTestId("task-chat-jump-to-bottom"); + expect(jumpButton).toBeVisible(); + expect(jumpButton).toHaveAccessibleName("Jump to latest message"); + expect(screen.getByRole("button", { name: "Jump to latest message" })).toBe(jumpButton); + }); + + it("clicking the jump-to-bottom button snaps to the latest message and removes the control", async () => { + const user = userEvent.setup(); + const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "latest output" })]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + metrics.scrollTop = 120; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + + await user.click(screen.getByTestId("task-chat-jump-to-bottom")); + + expect(metrics.scrollTop).toBe(1200); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + }); + + it("keeps the jump-to-bottom affordance available at the mobile breakpoint", () => { + mockMatchMedia(true); + mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "mobile output" })]); + + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + const transcript = screen.getByTestId("task-chat-transcript"); + transcript.scrollTop = 120; + fireEvent.scroll(transcript); + + expect(screen.getByTestId("task-chat-jump-to-bottom")).toBeVisible(); + expect(screen.getByRole("button", { name: "Jump to latest message" })).toHaveClass("task-chat-jump-to-bottom"); + }); + + it("renders an icon-only send button with preserved accessible name and new placeholder", () => { + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + + expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toHaveClass("task-chat-send"); + expect(sendButton).toHaveTextContent(""); + }); + + it("posts composer text through addSteeringComment and clears on success", async () => { + const user = userEvent.setup(); + const onTaskUpdated = vi.fn(); + const updatedTask = makeTask(); + mockedAddSteeringComment.mockResolvedValue(updatedTask); + render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />); + + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, "Please inspect the failing test"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1"); + }); + expect(mockedRefineTask).not.toHaveBeenCalled(); + expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); + expect(input).toHaveValue(""); + }); + + it("routes done-task composer sends to refineTask without replacing the current task", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + const onTaskUpdated = vi.fn(); + const refinementTask = makeTask({ id: "FN-222", column: "todo" }); + mockedRefineTask.mockResolvedValue(refinementTask); + render( + <TaskChatTab + task={makeTask({ column: "done", status: undefined })} + projectId="project-1" + active + addToast={addToast} + onTaskUpdated={onTaskUpdated} + />, + ); + + expectDoneRefinementCopy(); + const input = screen.getByLabelText("Message active agent session"); + await user.type(input, "Please add a follow-up report"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please add a follow-up report", "project-1"); + }); + expect(mockedAddSteeringComment).not.toHaveBeenCalled(); + expect(within(screen.getByTestId("task-chat-transcript")).getByText("You")).toBeVisible(); + expect(within(screen.getByTestId("task-chat-transcript")).getByText("Please add a follow-up report")).toBeVisible(); + expect(input).toHaveValue(""); + expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success"); + expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask); + expect(onTaskUpdated).not.toHaveBeenCalled(); + }); + + it.each([undefined, null, "failed", "done"])("routes done-task sends to refineTask regardless of %s status", async (status) => { + const user = userEvent.setup(); + mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-333", column: "todo" })); + render(<TaskChatTab task={makeTask({ column: "done", status })} projectId="project-1" active addToast={vi.fn()} />); + + await user.type(screen.getByLabelText("Message active agent session"), `Refine from ${String(status)}`); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", `Refine from ${String(status)}`, "project-1"); + }); + expect(mockedAddSteeringComment).not.toHaveBeenCalled(); + }); + + it.each([ + ["in-progress", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" })], + ["in-review", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" })], + ["todo", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined })], + ["triage", makeTask({ column: "triage", assignedAgentId: undefined, checkedOutBy: undefined })], + ["archived", makeTask({ column: "archived", assignedAgentId: undefined, checkedOutBy: undefined })], + ])("keeps %s sends routed to addSteeringComment", async (_label, task) => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(task); + render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} sessionLive={false} />); + + await user.type(screen.getByLabelText("Message active agent session"), "Keep steering"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Keep steering", "project-1"); + }); + expect(mockedRefineTask).not.toHaveBeenCalled(); + }); + + it("renders a sent user message in the chat transcript", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "executor", text: "I am checking the failure", timestamp: "2026-06-12T00:00:00.000Z" }), + ]); + const send = deferred<Task>(); + mockedAddSteeringComment.mockReturnValue(send.promise); + render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1" })} projectId="project-1" active addToast={vi.fn()} />); + + const input = screen.getByLabelText("Message active agent session"); + await user.type(input, "Please inspect the failing test"); + await user.click(screen.getByRole("button", { name: "Send" })); + + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByText("You")).toBeVisible(); + expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible(); + expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible(); + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1"); + + await act(async () => { + send.resolve(makeTask({ steeringComments: [makeSteeringComment({ id: "steer-sent", text: "Please inspect the failing test" })] })); + await send.promise; + }); + + expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible(); + expect(input).toHaveValue(""); + }); + + it("renders persisted user steering comments but not agent-authored steering comments", () => { + render( + <TaskChatTab + task={makeTask({ + steeringComments: [ + makeSteeringComment({ id: "user-steer", text: "Persisted user guidance", author: "user" }), + makeSteeringComment({ id: "agent-steer", text: "Internal agent note", author: "agent" }), + ], + })} + active + addToast={vi.fn()} + />, + ); + + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByText("You")).toBeVisible(); + expect(within(transcript).getByText("Persisted user guidance")).toBeVisible(); + expect(within(transcript).queryByText("Internal agent note")).not.toBeInTheDocument(); + }); + + it("deduplicates optimistic messages when matching persisted comments arrive", async () => { + const user = userEvent.setup(); + const persistedComment = makeSteeringComment({ id: "steer-dedup", text: "Do not duplicate me" }); + mockedAddSteeringComment.mockResolvedValue(makeTask({ steeringComments: [persistedComment] })); + const { rerender } = render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />); + + await user.type(screen.getByLabelText("Message active agent session"), "Do not duplicate me"); + await user.click(screen.getByRole("button", { name: "Send" })); + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Do not duplicate me", "project-1"); + }); + + rerender(<TaskChatTab task={makeTask({ steeringComments: [persistedComment] })} projectId="project-1" active addToast={vi.fn()} />); + + expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Do not duplicate me")).toHaveLength(1); + }); + + it("deduplicates persisted user comments by fallback text and timestamp", () => { + render( + <TaskChatTab + task={makeTask({ + steeringComments: [ + makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }), + makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }), + ], + })} + active + addToast={vi.fn()} + />, + ); + + expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Fallback duplicate")).toHaveLength(1); + }); + + it("interleaves user messages chronologically with agent output", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "first agent output", timestamp: "2026-06-12T00:00:00.000Z" }), + makeEntry({ agent: "executor", text: "second agent output", timestamp: "2026-06-12T00:00:02.000Z" }), + ]); + + render( + <TaskChatTab + task={makeTask({ steeringComments: [makeSteeringComment({ text: "middle user guidance", createdAt: "2026-06-12T00:00:01.000Z" })] })} + active + addToast={vi.fn()} + />, + ); + + const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? ""; + expect(transcriptText.indexOf("first agent output")).toBeLessThan(transcriptText.indexOf("middle user guidance")); + expect(transcriptText.indexOf("middle user guidance")).toBeLessThan(transcriptText.indexOf("second agent output")); + }); + + it.each([undefined, []])("does not render a phantom user bubble for %s steering comments", (steeringComments) => { + render(<TaskChatTab task={makeTask({ steeringComments })} active addToast={vi.fn()} />); + + expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument(); + expect(screen.getByText(/No agent output yet/)).toBeVisible(); + }); + + it.each([ + ["queued", "Please continue after dispatch"], + [undefined, "Please continue with a cleared status"], + ])("enables in-progress steering for realistic %s status and posts guidance", async (status, message) => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ status })); + render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} projectId="project-1" active addToast={vi.fn()} />); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, message); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", message, "project-1"); + }); + }); + + it.each([ + ["idle todo task without an attached agent", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })], + ["paused task", makeTask({ status: "paused" })], + ])("FN-6354 keeps the composer sendable for %s", async (_label, task) => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ + ...task, + steeringComments: [makeSteeringComment({ id: "steer-new", text: "Queue this for later" })], + })); + render( + <TaskChatTab + task={task} + projectId="project-1" + active + addToast={vi.fn()} + sessionLive={false} + />, + ); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expectNoInactiveSessionHint(); + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); + + await user.type(input, "Queue this for later"); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Queue this for later", "project-1"); + }); + expect(within(screen.getByTestId("task-chat-transcript")).getByText("Queue this for later")).toBeVisible(); + }); + + it.each(["starting", "ready", "busy", "waitingOnInput"] as const)( + "enables steering for a live %s CLI session when static task fields are not steerable", + async (agentState) => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status: "queued" })); + render( + <TaskChatTab + task={makeTask({ column: "in-review", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })} + projectId="project-1" + active + addToast={vi.fn()} + sessionLive={isCliSessionLive(makeCliSession(agentState))} + />, + ); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, `Please continue ${agentState}`); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", `Please continue ${agentState}`, "project-1"); + }); + }, + ); + + it("enables steering for a live CLI session in a terminal column that static task fields reject", () => { + render( + <TaskChatTab + task={makeTask({ column: "todo", status: undefined, assignedAgentId: undefined, checkedOutBy: undefined })} + active + addToast={vi.fn()} + sessionLive={true} + />, + ); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled(); + }); + + it.each(["done", "dead", "needsAttention", null] as const)("shows queued copy but stays sendable when the CLI session is not live: %s", (agentState) => { + const sessionLive = agentState === null ? isCliSessionLive(null) : isCliSessionLive(makeCliSession(agentState)); + render( + <TaskChatTab + task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })} + active + addToast={vi.fn()} + sessionLive={sessionLive} + />, + ); + + expectNoInactiveSessionHint(); + expectComposerSendableAfterDraft(); + }); + + it.each(["busy", "ready", "starting", "waitingOnInput"] as const)("treats %s CLI sessions as live", (agentState) => { + expect(isCliSessionLive(makeCliSession(agentState))).toBe(true); + }); + + it.each(["done", "dead", "needsAttention"] as const)("treats %s CLI sessions as not live", (agentState) => { + expect(isCliSessionLive(makeCliSession(agentState))).toBe(false); + }); + + it("treats a missing CLI session as not live", () => { + expect(isCliSessionLive(null)).toBe(false); + }); + + it.each([undefined, null, "queued", "planning", "merging", "merging-fix"])( + "enables in-progress steering for assigned agents with %s status", + (status) => { + render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} sessionLive={false} />); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled(); + }, + ); + + it("enables a non-CLI engine agent when the forwarded task carries full-detail agent fields", async () => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-progress", assignedAgentId: "agent-full", checkedOutBy: "agent-full", status: "queued" })); + render( + <TaskChatTab + task={makeTask({ column: "in-progress", assignedAgentId: "agent-full", checkedOutBy: "agent-full", status: "queued" })} + projectId="project-1" + active + addToast={vi.fn()} + sessionLive={false} + />, + ); + + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, "Continue from the worktree"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Continue from the worktree", "project-1"); + }); + }); + + it("enables in-progress steering with checkedOutBy when no assignedAgentId exists", async () => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ status: "queued" })); + render( + <TaskChatTab + task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: "agent-1" })} + projectId="project-1" + active + addToast={vi.fn()} + />, + ); + + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, "Please keep going"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please keep going", "project-1"); + }); + }); + + it.each(["reviewing", "merging", "merging-fix", "fixing"])( + "enables in-review steering while %s with an assigned agent", + async (status) => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status })); + render(<TaskChatTab task={makeTask({ column: "in-review", status })} projectId="project-1" active addToast={vi.fn()} />); + + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, `Please continue ${status}`); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", `Please continue ${status}`, "project-1"); + }); + }, + ); + + it("enables in-review steering with checkedOutBy when no assignedAgentId exists", async () => { + const user = userEvent.setup(); + mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status: "reviewing" })); + render( + <TaskChatTab + task={makeTask({ column: "in-review", status: "reviewing", assignedAgentId: undefined, checkedOutBy: "agent-1" })} + projectId="project-1" + active + addToast={vi.fn()} + />, + ); + + const input = screen.getByLabelText("Message active agent session"); + expect(input).not.toBeDisabled(); + await user.type(input, "Please review this follow-up"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please review this follow-up", "project-1"); + }); + }); + + it.each([ + ["in-progress task", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" }), true], + ["in-review task", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" }), true], + ["todo task", makeTask({ column: "todo", assignedAgentId: "agent-1", status: undefined }), false], + ["triage task", makeTask({ column: "triage", assignedAgentId: "agent-1", status: undefined }), false], + ["done task", makeTask({ column: "done", assignedAgentId: "agent-1", status: undefined }), false], + ["archived task", makeTask({ column: "archived", assignedAgentId: "agent-1", status: undefined }), false], + ])("keeps the composer sendable for %s column", (_label, task, showsActiveCopy) => { + render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={false} />); + + if (task.column === "done") { + expectDoneRefinementCopy(); + } else if (showsActiveCopy) { + expectActiveSessionCopy(); + } else { + expectNoInactiveSessionHint(); + } + expectComposerSendableAfterDraft(); + }); + + it.each([ + ["in-progress task without an assigned or checked-out agent", makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })], + ["paused in-progress task", makeTask({ column: "in-progress", status: "queued", paused: true })], + ["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })], + ["in-review task without an assigned or checked-out agent", makeTask({ column: "in-review", status: "reviewing", assignedAgentId: undefined, checkedOutBy: undefined })], + ["paused in-review task", makeTask({ column: "in-review", status: "reviewing", paused: true })], + ["user-paused in-review task", makeTask({ column: "in-review", status: "reviewing", userPaused: true })], + ])("keeps the composer sendable with queued copy for %s", (_label, task) => { + render(<TaskChatTab task={task} active addToast={vi.fn()} />); + + expectNoInactiveSessionHint(); + expectComposerSendableAfterDraft(); + }); + + it.each([ + ["paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", paused: true })], + ["user-paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", userPaused: true })], + ["paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", paused: true })], + ["user-paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", userPaused: true })], + ])("keeps the composer sendable with queued copy for %s", (_label, task) => { + render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={true} />); + + expectNoInactiveSessionHint(); + expectComposerSendableAfterDraft(); + }); + + it.each(["paused", "awaiting-user-input", "awaiting-cli-approval", "awaiting-user-review", "failed", "needs-replan"])( + "keeps in-progress steering sendable with queued copy for %s status", + (status) => { + render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} />); + + expectNoInactiveSessionHint(); + expectComposerSendableAfterDraft(); + }, + ); + + it("disables the composer only while a send is in flight", async () => { + const user = userEvent.setup(); + const send = deferred<Task>(); + mockedAddSteeringComment.mockReturnValue(send.promise); + render(<TaskChatTab task={makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined })} active addToast={vi.fn()} sessionLive={false} />); + + const input = screen.getByLabelText("Message active agent session"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(input).not.toBeDisabled(); + expect(sendButton).toBeDisabled(); + + await user.type(input, "Please queue this while idle"); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + const sendingButton = screen.getByRole("button", { name: "Sending" }); + expect(sendingButton).toBeDisabled(); + expect(sendingButton).toHaveTextContent(""); + expect(input).toBeDisabled(); + + await act(async () => { + send.resolve(makeTask({ steeringComments: [makeSteeringComment({ text: "Please queue this while idle" })] })); + await send.promise; + }); + + expect(input).not.toBeDisabled(); + expect(input).toHaveValue(""); + }); + + it("uses the same send lifecycle while creating a done-task refinement", async () => { + const user = userEvent.setup(); + const send = deferred<Task>(); + mockedRefineTask.mockReturnValue(send.promise); + render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} sessionLive={false} />); + + const input = screen.getByLabelText("Message active agent session"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(input).not.toBeDisabled(); + expect(sendButton).toBeDisabled(); + + await user.type(input, " "); + expect(sendButton).toBeDisabled(); + await user.clear(input); + await user.type(input, "Create follow-up"); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + const sendingButton = screen.getByRole("button", { name: "Sending" }); + expect(sendingButton).toBeDisabled(); + expect(sendingButton).toHaveTextContent(""); + expect(input).toBeDisabled(); + + await act(async () => { + send.resolve(makeTask({ id: "FN-444", column: "todo" })); + await send.promise; + }); + + expect(input).not.toBeDisabled(); + expect(input).toHaveValue(""); + }); + + it("rolls back optimistic messages and surfaces send failures through addToast", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + const send = deferred<Task>(); + mockedAddSteeringComment.mockReturnValue(send.promise); + render(<TaskChatTab task={makeTask()} active addToast={addToast} />); + + await user.type(screen.getByLabelText("Message active agent session"), "hello"); + await user.click(screen.getByRole("button", { name: "Send" })); + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible(); + expect(within(transcript).getByText("hello")).toBeVisible(); + + await act(async () => { + send.reject(new Error("network down")); + try { + await send.promise; + } catch { + // Expected rejection drives the component rollback path. + } + }); + + await waitFor(() => { + expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith("Unable to send message: network down", "error"); + }); + }); + + it("rolls back done-task optimistic messages when refinement creation fails", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + const onTaskUpdated = vi.fn(); + const send = deferred<Task>(); + mockedRefineTask.mockReturnValue(send.promise); + render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={addToast} onTaskUpdated={onTaskUpdated} />); + + const input = screen.getByLabelText("Message active agent session"); + await user.type(input, "make a follow-up"); + await user.click(screen.getByRole("button", { name: "Send" })); + const transcript = screen.getByTestId("task-chat-transcript"); + expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible(); + expect(within(transcript).getByText("make a follow-up")).toBeVisible(); + + await act(async () => { + send.reject(new Error("refine failed")); + try { + await send.promise; + } catch { + // Expected rejection drives the component rollback path. + } + }); + + await waitFor(() => { + expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith("Unable to send message: refine failed", "error"); + }); + expect(input).toHaveValue("make a follow-up"); + expect(onTaskUpdated).not.toHaveBeenCalled(); + }); + + it("renders the same composer affordance shell on desktop and mobile breakpoints", () => { + mockMatchMedia(false); + const desktop = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument(); + expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); + expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input"); + expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send"); + desktop.unmount(); + + mockMatchMedia(true); + render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); + expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument(); + expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); + expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input"); + expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send"); + }); + + it("FN-6347 pins the composer while the transcript flex-fills without fixed viewport caps", () => { + const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); + const tabRule = getCssRuleBlock(css, ".task-chat-tab"); + const transcriptRule = getCssRuleBlock(css, ".task-chat-transcript"); + const composerRule = getCssRuleBlock(css, ".task-chat-composer"); + const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); + const mobileTranscriptRule = getCssRuleBlock(mobileCss, ".task-chat-transcript"); + + expect(tabRule).toContain("display: flex"); + expect(tabRule).toContain("flex: 1"); + expect(tabRule).toContain("min-height: 0"); + expect(transcriptRule).toContain("flex: 1 1 auto"); + expect(transcriptRule).toContain("min-height: 0"); + expect(transcriptRule).toContain("overflow-y: auto"); + expect(transcriptRule).not.toContain("max-height"); + expect(composerRule).toContain("flex: 0 0 auto"); + expect(mobileTranscriptRule).toContain("flex: 1 1 auto"); + expect(mobileTranscriptRule).toContain("min-height: 0"); + expect(mobileTranscriptRule).not.toContain("max-height"); + expect(css).not.toContain("70vh"); + expect(css).not.toContain("62vh"); + }); + + it("keeps tokenized sticky styling for the jump-to-bottom control on desktop and mobile", () => { + const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); + const jumpRule = getCssRuleBlock(css, ".task-chat-jump-to-bottom"); + const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); + const mobileJumpRule = getCssRuleBlock(mobileCss, ".task-chat-jump-to-bottom"); + + expect(jumpRule).toContain("position: sticky"); + expect(jumpRule).toContain("bottom: var(--space-md)"); + expect(jumpRule).toContain("right: var(--space-md)"); + expect(jumpRule).toContain("background: var(--surface)"); + expect(jumpRule).toContain("border: var(--btn-border-width) solid var(--border)"); + expect(jumpRule).toContain("box-shadow: var(--shadow-md)"); + expect(jumpRule).toContain("border-radius: var(--radius-md)"); + expect(mobileJumpRule).toContain("bottom: var(--space-sm)"); + expect(mobileJumpRule).toContain("right: var(--space-sm)"); + expect(mobileJumpRule).toContain("min-inline-size"); + expect(mobileJumpRule).toContain("min-block-size"); + }); + + it("keeps mobile breakpoint scaffolding for the transcript, composer, and collapsible groups", () => { + const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); + const sendRule = getCssRuleBlock(css, ".task-chat-send"); + const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); + const mobileComposerRule = getCssRuleBlock(mobileCss, ".task-chat-composer-row"); + const mobileSendRule = getCssRuleBlock(mobileCss, ".task-chat-send"); + + expect(css).toContain("@media (max-width: 768px)"); + expect(css).toContain(".task-chat-transcript"); + expect(css).toContain(".task-chat-jump-to-bottom"); + expect(css).toContain(".task-chat-composer-row"); + expect(sendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))"); + expect(sendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-sm))"); + expect(sendRule).not.toContain("gap"); + expect(mobileComposerRule).toContain("align-items: flex-end"); + expect(mobileComposerRule).not.toContain("flex-direction: column"); + expect(mobileComposerRule).not.toContain("align-items: stretch"); + expect(mobileSendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))"); + expect(css).toContain(".task-chat-tool-group-summary"); + expect(css).toContain(".task-chat-tool-group-names"); + expect(css).toContain(".task-chat-tool-group-error-count"); + expect(css).toContain(".task-chat-thinking-summary"); + expect(css).not.toContain(".task-chat-thinking-markdown + .task-chat-thinking-markdown"); + expect(css).toContain(".task-chat-user-group"); + expect(css).toContain(".task-chat-entry--user"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx index 60a5ccf47c..d536eb38ee 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx @@ -744,26 +744,19 @@ describe("TaskDetailModal", () => { ); // For an in-progress task (no workflow steps, no merge commit), the - // top-level tabs are: Definition, Logs, Changes, Review, Comments, + // top-level tabs are: Definition, Chat, Logs, Changes, Review, Comments, // Documents, Model, Workflow, Stats, Routing. - const tabTexts = ["Definition", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"]; + const tabTexts = ["Definition", "Chat", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"]; const tabs = screen.getAllByRole("button").filter((b) => tabTexts.includes(b.textContent || "") ); - expect(tabs.length).toBe(10); + expect(tabs.map((tab) => tab.textContent)).toEqual(tabTexts); expect(tabs[0].textContent).toBe("Definition"); - expect(tabs[1].textContent).toBe("Logs"); - expect(tabs[2].textContent).toBe("Changes"); - expect(tabs[3].textContent).toBe("Review"); - expect(tabs[4].textContent).toBe("Comments"); - expect(tabs[5].textContent).toBe("Documents"); - expect(tabs[6].textContent).toBe("Model"); - expect(tabs[7].textContent).toBe("Workflow"); - expect(tabs[8].textContent).toBe("Stats"); - expect(tabs[9].textContent).toBe("Routing"); + expect(tabs[1].textContent).toBe("Chat"); + expect(tabs[2].textContent).toBe("Logs"); // Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs) - expect(container.querySelectorAll(".detail-tab").length).toBe(10); + expect(container.querySelectorAll(".detail-tab").length).toBe(11); // Workflow tab should always appear even when no workflow steps are configured expect(screen.getByText("Workflow")).toBeInTheDocument(); // Commits tab should NOT appear for non-done tasks @@ -771,6 +764,182 @@ describe("TaskDetailModal", () => { }); }); + describe("Chat full-height layout", () => { + it("FN-6347 defines chat modal-body and section fill-height CSS for desktop and mobile", () => { + const css = readDashboardStylesSource(); + const bodyRule = getCssRuleBlock(css, ".detail-body--chat"); + const sectionRule = getCssRuleBlock(css, ".detail-section--chat"); + const mobileCss = css.slice(css.indexOf("@media (max-width: 768px)")); + const mobileBodyRule = getCssRuleBlock(mobileCss, ".detail-body--chat"); + const mobileSectionRule = getCssRuleBlock(mobileCss, ".detail-section--chat"); + + expect(bodyRule).toContain("display: flex"); + expect(bodyRule).toContain("flex-direction: column"); + expect(bodyRule).toContain("min-height: 0"); + expect(bodyRule).toContain("overflow-y: hidden"); + expect(sectionRule).toContain("display: flex"); + expect(sectionRule).toContain("flex-direction: column"); + expect(sectionRule).toContain("flex: 1"); + expect(sectionRule).toContain("min-height: 0"); + expect(mobileBodyRule).toContain("overflow-y: hidden"); + expect(mobileBodyRule).toContain("min-height: 0"); + expect(mobileSectionRule).toContain("flex: 1"); + expect(mobileSectionRule).toContain("min-height: 0"); + }); + + it("FN-6370 defines expanded chat chrome-hiding CSS for desktop and mobile", () => { + const css = readDashboardStylesSource(); + const expandedChromeRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-title-row"); + const expandedBodyRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-body--chat"); + const expandedSectionRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-section--chat"); + const mobileCss = css.slice(css.indexOf("@media (max-width: 768px)")); + const mobileTabsRule = getCssRuleBlock(mobileCss, ".task-detail-content--chat-expanded .detail-tabs"); + + expect(expandedChromeRule).toContain("display: none"); + expect(expandedBodyRule).toContain("flex: 1"); + expect(expandedBodyRule).toContain("min-height: 0"); + expect(expandedSectionRule).toContain("margin-top: 0"); + expect(mobileTabsRule).toContain("display: none"); + }); + + it("FN-6370 expands and collapses chat without leaving chrome hidden", () => { + const { container } = render( + <TaskDetailModal + task={makeTask({ prompt: "# Hello\n\nContent" })} + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Chat" })); + const content = container.querySelector(".task-detail-content"); + expect(content).not.toHaveClass("task-detail-content--chat-expanded"); + expect(container.querySelector(".detail-tabs")).toBeTruthy(); + expect(container.querySelector(".modal-actions")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("task-chat-expand-toggle")); + expect(content).toHaveClass("task-detail-content--chat-expanded"); + expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Collapse chat"); + expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByTestId("task-chat-expand-toggle")); + expect(content).not.toHaveClass("task-detail-content--chat-expanded"); + expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand chat to full modal"); + expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-pressed", "false"); + }); + + it("FN-6370 resets expanded chat when the active tab changes", () => { + const { container, rerender } = render( + <TaskDetailContent + task={makeTask({ prompt: "# Hello\n\nContent" })} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + initialTab="chat" + />, + ); + + const content = container.querySelector(".task-detail-content"); + fireEvent.click(screen.getByTestId("task-chat-expand-toggle")); + expect(content).toHaveClass("task-detail-content--chat-expanded"); + + rerender( + <TaskDetailContent + task={makeTask({ prompt: "# Hello\n\nContent" })} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + initialTab="logs" + />, + ); + + expect(container.querySelector(".task-detail-content--chat-expanded")).toBeNull(); + expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull(); + }); + + it("FN-6370 resets expanded chat when entering edit mode", () => { + const { container } = render( + <TaskDetailModal + task={makeTask({ column: "triage", prompt: "# Hello\n\nContent" })} + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Chat" })); + fireEvent.click(screen.getByTestId("task-chat-expand-toggle")); + expect(container.querySelector(".task-detail-content")).toHaveClass("task-detail-content--chat-expanded"); + + fireEvent.click(screen.getByLabelText("Edit task")); + expect(container.querySelector(".task-detail-content--chat-expanded")).toBeNull(); + expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull(); + }); + + it("FN-6347 applies chat modifiers only while the Chat tab is active", () => { + const { container } = render( + <TaskDetailModal + task={makeTask({ prompt: "# Hello\n\nContent" })} + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + expect(container.querySelector(".detail-body--chat")).toBeNull(); + expect(container.querySelector(".detail-section--chat")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Chat" })); + const chatBody = container.querySelector(".detail-body--chat"); + const chatSection = container.querySelector(".detail-section--chat"); + expect(chatBody).toBeTruthy(); + expect(chatBody).not.toHaveClass("detail-body--agent-log"); + expect(chatSection).toBeTruthy(); + expect(chatSection!.querySelector("[data-testid='task-chat-tab']")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Logs" })); + fireEvent.click(screen.getByText("Agent Log")); + expect(container.querySelector(".detail-body--chat")).toBeNull(); + expect(container.querySelector(".detail-section--chat")).toBeNull(); + expect(container.querySelector(".detail-body--agent-log")).toBeTruthy(); + }); + + it("FN-6347 removes the chat body modifier while editing", () => { + const { container } = render( + <TaskDetailModal + task={makeTask({ column: "triage", prompt: "# Hello\n\nContent" })} + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Chat" })); + expect(container.querySelector(".detail-body--chat")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText("Edit task")); + expect(container.querySelector(".detail-body--chat")).toBeNull(); + expect(container.querySelector(".detail-section--chat")).toBeNull(); + }); + }); + describe("Agent Log full-height layout", () => { it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => { const { container } = render( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.create-pr.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.create-pr.test.tsx index e5db10884d..068029e6b3 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.create-pr.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.create-pr.test.tsx @@ -5,6 +5,7 @@ import type { PrInfo } from "@fusion/core"; const prPanelState = vi.hoisted(() => ({ latestPrInfo: undefined as PrInfo | undefined, latestAutoMerge: undefined as boolean | undefined, + latestIsManualPrFlow: undefined as boolean | undefined, })); const prCreateModalState = vi.hoisted(() => ({ @@ -19,11 +20,16 @@ vi.mock("../PrPanel", () => ({ PrPanel: (props: any) => { prPanelState.latestPrInfo = props.prInfo; prPanelState.latestAutoMerge = props.autoMerge; + prPanelState.latestIsManualPrFlow = props.isManualPrFlow; return ( <div> - <button type="button" onClick={() => props.onRequestCreatePr?.()}> - Create PR - </button> + {props.autoMerge ? ( + <div>Auto-merge will handle this task automatically.</div> + ) : ( + <button type="button" onClick={() => props.onRequestCreatePr?.()}> + Create PR + </button> + )} <div data-testid="pr-panel-pr-number">{props.prInfo?.number ?? "none"}</div> </div> ); @@ -65,11 +71,18 @@ vi.mock("../PrCreateModal", () => ({ vi.mock("../TaskReviewTab", () => ({ TaskReviewTab: (props: any) => { taskReviewTabState.latestProps = props; - return ( + const effectiveAutoMerge = props.task.autoMerge ?? props.autoMergeEnabled; + const showCreatePr = + props.task.column === "in-review" && + !props.task.prInfo && + props.prAuthAvailable === true && + !effectiveAutoMerge && + typeof props.onRequestCreatePr === "function"; + return showCreatePr ? ( <button type="button" data-testid="task-review-create-pr" onClick={() => props.onRequestCreatePr?.()}> Review create PR </button> - ); + ) : null; }, })); @@ -92,6 +105,7 @@ describe("TaskDetailModal create-PR wiring", () => { vi.clearAllMocks(); prPanelState.latestPrInfo = undefined; prPanelState.latestAutoMerge = undefined; + prPanelState.latestIsManualPrFlow = undefined; prCreateModalState.latestProps = null; taskReviewTabState.latestProps = null; }); @@ -234,4 +248,151 @@ describe("TaskDetailModal create-PR wiring", () => { expect(screen.queryByTestId("pr-create-modal-stub")).toBeNull(); expect(prCreateModalState.latestProps?.open).toBe(false); }); + + it("prefers live auto-merge off over a stale fetched snapshot for PR surfaces", async () => { + (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + autoMerge: true, + }); + + render( + <TaskDetailModal + task={makeTask({ id: "FN-6247-OFF", prInfo: undefined, column: "in-review", autoMerge: undefined })} + projectId="project-123" + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={vi.fn()} + prAuthAvailable + autoMergeEnabled={false} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Pull Request" })); + await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(false)); + expect(screen.getByRole("button", { name: "Create PR" })).toBeInTheDocument(); + expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Review" })); + await waitFor(() => expect(taskReviewTabState.latestProps?.autoMergeEnabled).toBe(false)); + expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument(); + }); + + it("prefers live auto-merge on over a stale fetched snapshot for PR surfaces", async () => { + (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + autoMerge: false, + }); + + render( + <TaskDetailModal + task={makeTask({ id: "FN-6247-ON", prInfo: undefined, column: "in-review", autoMerge: undefined })} + projectId="project-123" + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={vi.fn()} + prAuthAvailable + autoMergeEnabled + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Pull Request" })); + await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(true)); + expect(screen.getByText("Auto-merge will handle this task automatically.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Review" })); + await waitFor(() => expect(taskReviewTabState.latestProps?.autoMergeEnabled).toBe(true)); + expect(screen.queryByTestId("task-review-create-pr")).toBeNull(); + }); + + it.each([ + { taskAutoMerge: undefined, liveAutoMerge: false, expectedEffective: false }, + { taskAutoMerge: undefined, liveAutoMerge: true, expectedEffective: true }, + { taskAutoMerge: true, liveAutoMerge: false, expectedEffective: true }, + { taskAutoMerge: true, liveAutoMerge: true, expectedEffective: true }, + { taskAutoMerge: false, liveAutoMerge: false, expectedEffective: false }, + { taskAutoMerge: false, liveAutoMerge: true, expectedEffective: false }, + ])( + "resolves effective auto-merge for task override $taskAutoMerge with live global $liveAutoMerge", + async ({ taskAutoMerge, liveAutoMerge, expectedEffective }) => { + (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + autoMerge: !liveAutoMerge, + }); + + render( + <TaskDetailModal + task={makeTask({ id: `FN-6247-${String(taskAutoMerge)}-${String(liveAutoMerge)}`, prInfo: undefined, column: "in-review", autoMerge: taskAutoMerge })} + projectId="project-123" + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={vi.fn()} + prAuthAvailable + autoMergeEnabled={liveAutoMerge} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Pull Request" })); + await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(expectedEffective)); + if (expectedEffective) { + expect(screen.getByText("Auto-merge will handle this task automatically.")).toBeInTheDocument(); + } else { + expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull(); + expect(screen.getByRole("button", { name: "Create PR" })).toBeInTheDocument(); + } + + fireEvent.click(screen.getByRole("button", { name: "Review" })); + await waitFor(() => expect(taskReviewTabState.latestProps?.autoMergeEnabled).toBe(liveAutoMerge)); + if (expectedEffective) { + expect(screen.queryByTestId("task-review-create-pr")).toBeNull(); + } else { + expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument(); + } + }, + ); + + it("keeps manual PR flow driven by live global auto-merge rather than effective override", async () => { + (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + autoMerge: true, + mergeStrategy: "pull-request", + }); + + render( + <TaskDetailModal + task={makeTask({ id: "FN-6247-MANUAL", prInfo: undefined, column: "in-review", autoMerge: true })} + projectId="project-123" + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={vi.fn()} + prAuthAvailable + autoMergeEnabled={false} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Pull Request" })); + await waitFor(() => expect(prPanelState.latestAutoMerge).toBe(true)); + expect(prPanelState.latestIsManualPrFlow).toBe(true); + expect(screen.getByText("Auto-merge will handle this task automatically.")).toBeInTheDocument(); + }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index 2b49b7b264..04c15203fd 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -182,20 +182,21 @@ describe("TaskDetailModal", () => { />, ); - // In-progress tasks show exactly 10 tabs: - // Definition, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing + // In-progress tasks show exactly 11 tabs: + // Definition, Chat, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing const tabs = container.querySelectorAll(".detail-tab"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); expect(tabs[0].textContent).toBe("Definition"); - expect(tabs[1].textContent).toBe("Logs"); - expect(tabs[2].textContent).toBe("Changes"); - expect(tabs[3].textContent).toBe("Review"); - expect(tabs[4].textContent).toBe("Comments"); - expect(tabs[5].textContent).toBe("Documents"); - expect(tabs[6].textContent).toBe("Model"); - expect(tabs[7].textContent).toBe("Workflow"); - expect(tabs[8].textContent).toBe("Stats"); - expect(tabs[9].textContent).toBe("Routing"); + expect(tabs[1].textContent).toBe("Chat"); + expect(tabs[2].textContent).toBe("Logs"); + expect(tabs[3].textContent).toBe("Changes"); + expect(tabs[4].textContent).toBe("Review"); + expect(tabs[5].textContent).toBe("Comments"); + expect(tabs[6].textContent).toBe("Documents"); + expect(tabs[7].textContent).toBe("Model"); + expect(tabs[8].textContent).toBe("Workflow"); + expect(tabs[9].textContent).toBe("Stats"); + expect(tabs[10].textContent).toBe("Routing"); // Commits tab should NOT be present for non-done tasks expect(screen.queryByText("Commits")).toBeNull(); }); @@ -213,19 +214,20 @@ describe("TaskDetailModal", () => { />, ); - // In-progress task with workflow steps: 10 tabs (Review after Changes, Workflow after Model) + // In-progress task with workflow steps: 11 tabs (Review after Changes, Workflow after Model) const tabs = container.querySelectorAll(".detail-tab"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); expect(tabs[0].textContent).toBe("Definition"); - expect(tabs[1].textContent).toBe("Logs"); - expect(tabs[2].textContent).toBe("Changes"); - expect(tabs[3].textContent).toBe("Review"); - expect(tabs[4].textContent).toBe("Comments"); - expect(tabs[5].textContent).toBe("Documents"); - expect(tabs[6].textContent).toBe("Model"); - expect(tabs[7].textContent).toBe("Workflow"); - expect(tabs[8].textContent).toBe("Stats"); - expect(tabs[9].textContent).toBe("Routing"); + expect(tabs[1].textContent).toBe("Chat"); + expect(tabs[2].textContent).toBe("Logs"); + expect(tabs[3].textContent).toBe("Changes"); + expect(tabs[4].textContent).toBe("Review"); + expect(tabs[5].textContent).toBe("Comments"); + expect(tabs[6].textContent).toBe("Documents"); + expect(tabs[7].textContent).toBe("Model"); + expect(tabs[8].textContent).toBe("Workflow"); + expect(tabs[9].textContent).toBe("Stats"); + expect(tabs[10].textContent).toBe("Routing"); }); it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => { @@ -244,24 +246,25 @@ describe("TaskDetailModal", () => { />, ); - // Done task with commit SHA: Definition, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing (10 tabs, no Commits) + // Done task with commit SHA: Definition, Chat, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing (11 tabs, no Commits) const tabs = container.querySelectorAll(".detail-tab"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); expect(tabs[0].textContent).toBe("Definition"); - expect(tabs[1].textContent).toBe("Logs"); - expect(tabs[2].textContent).toBe("Changes"); - expect(tabs[3].textContent).toBe("Review"); - expect(tabs[4].textContent).toBe("Comments"); - expect(tabs[5].textContent).toBe("Documents"); - expect(tabs[6].textContent).toBe("Model"); - expect(tabs[7].textContent).toBe("Workflow"); - expect(tabs[8].textContent).toBe("Stats"); - expect(tabs[9].textContent).toBe("Routing"); + expect(tabs[1].textContent).toBe("Chat"); + expect(tabs[2].textContent).toBe("Logs"); + expect(tabs[3].textContent).toBe("Changes"); + expect(tabs[4].textContent).toBe("Review"); + expect(tabs[5].textContent).toBe("Comments"); + expect(tabs[6].textContent).toBe("Documents"); + expect(tabs[7].textContent).toBe("Model"); + expect(tabs[8].textContent).toBe("Workflow"); + expect(tabs[9].textContent).toBe("Stats"); + expect(tabs[10].textContent).toBe("Routing"); // Commits tab should NOT be present expect(screen.queryByText("Commits")).toBeNull(); }); - it("shows 10 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => { + it("shows 11 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => { const { container } = render( <TaskDetailModal task={makeTask({ @@ -278,19 +281,20 @@ describe("TaskDetailModal", () => { />, ); - // Done task with workflow steps and commit SHA: 10 tabs including Review (no Commits) + // Done task with workflow steps and commit SHA: 11 tabs including Review (no Commits) const tabs = container.querySelectorAll(".detail-tab"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); expect(tabs[0].textContent).toBe("Definition"); - expect(tabs[1].textContent).toBe("Logs"); - expect(tabs[2].textContent).toBe("Changes"); - expect(tabs[3].textContent).toBe("Review"); - expect(tabs[4].textContent).toBe("Comments"); - expect(tabs[5].textContent).toBe("Documents"); - expect(tabs[6].textContent).toBe("Model"); - expect(tabs[7].textContent).toBe("Workflow"); - expect(tabs[8].textContent).toBe("Stats"); - expect(tabs[9].textContent).toBe("Routing"); + expect(tabs[1].textContent).toBe("Chat"); + expect(tabs[2].textContent).toBe("Logs"); + expect(tabs[3].textContent).toBe("Changes"); + expect(tabs[4].textContent).toBe("Review"); + expect(tabs[5].textContent).toBe("Comments"); + expect(tabs[6].textContent).toBe("Documents"); + expect(tabs[7].textContent).toBe("Model"); + expect(tabs[8].textContent).toBe("Workflow"); + expect(tabs[9].textContent).toBe("Stats"); + expect(tabs[10].textContent).toBe("Routing"); // Commits tab should NOT be present expect(screen.queryByText("Commits")).toBeNull(); }); @@ -309,9 +313,9 @@ describe("TaskDetailModal", () => { ); const triageTabs = triageContainer.querySelectorAll(".detail-tab"); - expect(triageTabs.length).toBe(9); // Definition, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing + expect(triageTabs.length).toBe(10); // Definition, Chat, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([ - "Definition", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", + "Definition", "Chat", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", ]); const { container: todoContainer } = render( @@ -327,9 +331,9 @@ describe("TaskDetailModal", () => { ); const todoTabs = todoContainer.querySelectorAll(".detail-tab"); - expect(todoTabs.length).toBe(9); // Definition, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing + expect(todoTabs.length).toBe(10); // Definition, Chat, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([ - "Definition", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", + "Definition", "Chat", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", ]); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index 04d46f7f7f..11e1f8ecad 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -61,7 +61,7 @@ describe("TaskDetailModal", () => { expect(container.querySelector(".detail-timestamps")).toBeTruthy(); expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2); const tabs = container.querySelectorAll(".detail-tab"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true); // Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks @@ -424,6 +424,32 @@ describe("TaskDetailModal", () => { }); }); + it("offers archive instead when deleting a non-done live task", async () => { + const onArchiveTask = vi.fn().mockResolvedValue({} as Task); + mockConfirmWithChoice.mockResolvedValueOnce("tertiary"); + + render( + <TaskDetailModal + task={makeTask({ column: "todo" as any })} + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onArchiveTask={onArchiveTask} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: /actions/i })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + + await waitFor(() => { + expect(onArchiveTask).toHaveBeenCalledWith("FN-099"); + }); + expect(noopDelete).not.toHaveBeenCalled(); + }); + it("retries archive after lineage-conflict confirmation", async () => { const onArchiveTask = vi.fn(); const conflict = new Error("Cannot archive task FN-099: still referenced as a lineage parent by FN-201.") as Error & { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 6dca5a2669..d7cc3ce404 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -65,6 +65,7 @@ vi.mock("lucide-react", () => ({ Maximize2: () => null, Minimize2: () => null, Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }), + Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }), Bot: () => null, CircleDot: () => null, XCircle: () => null, diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index 8da00850d6..7530361423 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -344,6 +344,63 @@ describe("TaskDetailModal Logs activity loading", () => { }); }); +describe("TaskDetailModal Chat task merge", () => { + it("forwards full-detail agent fields to Chat when a sparse parent task has undefined live fields", async () => { + const user = userEvent.setup(); + const { fetchTaskDetail, addSteeringComment } = await import("../../api"); + const fullDetail = makeTask({ + id: "FN-6346", + column: "in-progress" as any, + status: "queued", + assignedAgentId: "agent-full", + checkedOutBy: "agent-full", + prompt: "# Loaded detail", + }); + const sparseParent = makeTask({ + id: "FN-6346", + column: undefined as any, + status: undefined, + assignedAgentId: undefined, + checkedOutBy: undefined, + }); + delete (sparseParent as any).prompt; + delete (sparseParent as any).log; + vi.mocked(fetchTaskDetail).mockReset(); + vi.mocked(fetchTaskDetail).mockResolvedValueOnce(fullDetail); + vi.mocked(addSteeringComment).mockReset(); + vi.mocked(addSteeringComment).mockResolvedValueOnce(fullDetail); + + render( + <TaskDetailModal + task={sparseParent as any} + initialTab="chat" + projectId="project-1" + onClose={noop} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + />, + ); + + await waitFor(() => expect(fetchTaskDetail).toHaveBeenCalledWith("FN-6346", "project-1")); + const input = await screen.findByLabelText("Message active agent session"); + await waitFor(() => { + expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expect(input).not.toBeDisabled(); + }); + await user.type(input, "Continue from the attached worktree agent"); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).not.toBeDisabled(); + await user.click(sendButton); + + await waitFor(() => { + expect(addSteeringComment).toHaveBeenCalledWith("FN-6346", "Continue from the attached worktree agent", "project-1"); + }); + }); +}); + describe("TaskDetailModal Logs agent loading", () => { it("shows the Agent Log loading indicator when entering the subview", async () => { const user = userEvent.setup(); diff --git a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx index a6cd1992fb..c918acd394 100644 --- a/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx +++ b/packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx @@ -44,6 +44,21 @@ function createAnchorRect(partial: Partial<DOMRect> = {}): DOMRect { const USAGE_VIEW_MODE_KEY = scopedKey("kb-usage-view-mode", TEST_PROJECT_ID); const USAGE_HIDDEN_WINDOWS_KEY = scopedKey("kb-usage-hidden-windows", TEST_PROJECT_ID); const USAGE_PROVIDER_ORDER_KEY = scopedKey("kb-usage-provider-order", TEST_PROJECT_ID); +const USAGE_MODAL_SIZE_KEY = scopedKey("kb-usage-modal-size", TEST_PROJECT_ID); + +function setViewportSize({ width, height }: { width: number; height: number }) { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: width, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: height, + }); + window.dispatchEvent(new Event("resize")); +} function getWindowIdentity(label: string, index: number): string { return `${index}::${label}`; @@ -105,6 +120,8 @@ describe("UsageIndicator", () => { localStorage.removeItem(USAGE_VIEW_MODE_KEY); localStorage.removeItem(USAGE_HIDDEN_WINDOWS_KEY); localStorage.removeItem(USAGE_PROVIDER_ORDER_KEY); + localStorage.removeItem(USAGE_MODAL_SIZE_KEY); + setViewportSize({ width: 1024, height: 768 }); }); it("renders nothing when isOpen is false", () => { @@ -438,7 +455,7 @@ describe("UsageIndicator", () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); - it("renders as popover below anchor on desktop when anchorRect provided", () => { + it("renders as top-biased popover below a high desktop anchor", () => { mockUseUsageData.mockReturnValue(createUsageDataState({ providers: mockProviders, loading: false, @@ -459,10 +476,65 @@ describe("UsageIndicator", () => { const modal = screen.getByTestId("usage-modal") as HTMLElement; expect(modal).toHaveClass("usage-modal--popover"); expect(modal.style.top).toBe("88px"); + expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2); expect(modal.style.left).toBe("520px"); }); - it("renders as full-screen modal when anchorRect is null", () => { + it("keeps the desktop popover near the top on a short viewport", () => { + setViewportSize({ width: 1024, height: 240 }); + mockUseUsageData.mockReturnValue(createUsageDataState({ + providers: mockProviders, + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + })); + + render( + <UsageIndicator + isOpen={true} + onClose={mockOnClose} + projectId={TEST_PROJECT_ID} + anchorRect={createAnchorRect()} + /> + ); + + const modal = screen.getByTestId("usage-modal") as HTMLElement; + expect(modal).toHaveClass("usage-modal--popover"); + expect(modal.style.top).toBe("60px"); + expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2); + }); + + it("clamps the desktop popover near the top for a low anchor while preserving saved size", () => { + setViewportSize({ width: 1024, height: 800 }); + localStorage.setItem(USAGE_MODAL_SIZE_KEY, JSON.stringify({ width: 600, height: 500 })); + mockUseUsageData.mockReturnValue(createUsageDataState({ + providers: mockProviders, + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + })); + + render( + <UsageIndicator + isOpen={true} + onClose={mockOnClose} + projectId={TEST_PROJECT_ID} + anchorRect={createAnchorRect({ top: 620, bottom: 650 })} + /> + ); + + const modal = screen.getByTestId("usage-modal") as HTMLElement; + expect(modal).toHaveClass("usage-modal--popover"); + expect(modal.style.top).toBe("200px"); + expect(Number.parseFloat(modal.style.top)).toBeLessThan(window.innerHeight / 2); + expect(modal.style.left).toBe("340px"); + expect(modal.style.width).toBe("600px"); + expect(modal.style.height).toBe("500px"); + }); + + it("renders as top-aligned full-screen modal when anchorRect is null", () => { mockUseUsageData.mockReturnValue(createUsageDataState({ providers: mockProviders, loading: false, @@ -473,8 +545,67 @@ describe("UsageIndicator", () => { render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />); - expect(screen.getByTestId("usage-modal")).toHaveClass("modal"); - expect(screen.getByTestId("usage-modal")).not.toHaveClass("usage-modal--popover"); + const overlay = screen.getByTestId("usage-modal-overlay"); + const modal = screen.getByTestId("usage-modal"); + expect(overlay).toHaveClass("modal-overlay", "open", "usage-modal-overlay"); + expect(modal).toHaveClass("modal"); + expect(modal).not.toHaveClass("usage-modal--popover"); + expect(modal.parentElement).toBe(overlay); + }); + + it("uses the top-aligned mobile sheet surface instead of the desktop popover", () => { + setViewportSize({ width: 768, height: 600 }); + mockUseUsageData.mockReturnValue(createUsageDataState({ + providers: mockProviders, + loading: false, + error: null, + lastUpdated: new Date(), + refresh: mockRefresh, + })); + + render( + <UsageIndicator + isOpen={true} + onClose={mockOnClose} + projectId={TEST_PROJECT_ID} + anchorRect={createAnchorRect()} + /> + ); + + const overlay = screen.getByTestId("usage-modal-overlay"); + const modal = screen.getByTestId("usage-modal") as HTMLElement; + expect(overlay).toHaveClass("usage-modal-overlay"); + expect(modal).toHaveClass("usage-modal", "modal"); + expect(modal).not.toHaveClass("usage-modal--popover"); + expect(modal.style.top).toBe(""); + }); + + it.each([ + ["populated", createUsageDataState({ providers: mockProviders, loading: false, error: null, lastUpdated: new Date(), refresh: mockRefresh }), "Anthropic"], + ["empty", createUsageDataState({ providers: [], loading: false, error: null, lastUpdated: null, refresh: mockRefresh }), "No AI providers configured"], + ["loading", createUsageDataState({ providers: [], loading: true, error: null, lastUpdated: null, hasFetched: false, refresh: mockRefresh }), null], + ["error", createUsageDataState({ providers: [], loading: false, error: "Failed to fetch usage data", lastUpdated: null, refresh: mockRefresh }), "Failed to load usage data"], + ])("keeps top-biased popover positioning for %s content", (_stateName, usageState, expectedText) => { + setViewportSize({ width: 1024, height: 240 }); + mockUseUsageData.mockReturnValue(usageState); + + render( + <UsageIndicator + isOpen={true} + onClose={mockOnClose} + projectId={TEST_PROJECT_ID} + anchorRect={createAnchorRect({ top: 180, bottom: 210 })} + /> + ); + + const modal = screen.getByTestId("usage-modal") as HTMLElement; + expect(modal).toHaveClass("usage-modal--popover"); + expect(modal.style.top).toBe("60px"); + if (expectedText) { + expect(screen.getByText(expectedText)).toBeInTheDocument(); + } else { + expect(document.querySelector(".usage-skeleton")).toBeInTheDocument(); + } }); it("calls onClose when overlay is clicked", () => { diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts index 7d70559253..51c7b28783 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts @@ -54,6 +54,53 @@ describe("WorkflowNodeEditor edge visibility CSS contract", () => { }); }); +describe("WorkflowNodeEditor sidebar overflow CSS contract", () => { + it("FN-6379 clamps horizontal overflow on desktop and list-stage sidebars", () => { + const editorCss = readComponentCss("WorkflowNodeEditor.css"); + const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)"); + + const desktopSidebarRule = findRule([editorCss], /\.wf-editor-sidebar\s*\{(?=[^}]*width\s*:\s*300px)[^}]*\}/); + expect(desktopSidebarRule).toMatch(/width\s*:\s*300px\s*;/); + expect(desktopSidebarRule).toMatch(/min-width\s*:\s*0\s*;/); + expect(desktopSidebarRule).toMatch(/overflow-x\s*:\s*hidden\s*;/); + expect(desktopSidebarRule).toMatch(/overflow-y\s*:\s*auto\s*;/); + + const listStageSidebarRule = findRule(mobileBlocks, /\.wf-editor-body--list-stage \.wf-editor-sidebar\s*\{[^}]*\}/); + expect(listStageSidebarRule).toMatch(/width\s*:\s*100%\s*;/); + expect(listStageSidebarRule).toMatch(/min-width\s*:\s*0\s*;/); + expect(listStageSidebarRule).toMatch(/overflow-x\s*:\s*hidden\s*;/); + expect(listStageSidebarRule).toMatch(/overflow-y\s*:\s*auto\s*;/); + }); + + it("FN-6379 keeps sidebar children from forcing horizontal scroll", () => { + const editorCss = readComponentCss("WorkflowNodeEditor.css"); + + const listRule = findRule([editorCss], /\.wf-editor-list\s*\{[^}]*\}/); + expect(listRule).toMatch(/min-width\s*:\s*0\s*;/); + + const listItemRule = findRule([editorCss], /\.wf-editor-list-item\s*\{[^}]*\}/); + expect(listItemRule).toMatch(/min-width\s*:\s*0\s*;/); + expect(listItemRule).toMatch(/overflow\s*:\s*hidden\s*;/); + expect(listItemRule).toMatch(/text-overflow\s*:\s*ellipsis\s*;/); + expect(listItemRule).toMatch(/white-space\s*:\s*nowrap\s*;/); + + const paletteRule = findRule([editorCss], /\.wf-editor-palette\s*\{[^}]*\}/); + expect(paletteRule).toMatch(/min-width\s*:\s*0\s*;/); + + const paletteButtonRule = findRule( + [editorCss], + /\.wf-palette-btn,\s*\.wf-editor-action,\s*\.wf-editor-delete,\s*\.wf-editor-save\s*\{[^}]*\}/, + ); + expect(paletteButtonRule).toMatch(/min-width\s*:\s*0\s*;/); + expect(paletteButtonRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/); + + const sidebarCodeRule = findRule([editorCss], /\.wf-editor-sidebar \.wf-code-source\s*\{[^}]*\}/); + expect(sidebarCodeRule).toMatch(/overflow-x\s*:\s*hidden\s*;/); + expect(sidebarCodeRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/); + expect(sidebarCodeRule).toMatch(/white-space\s*:\s*pre-wrap\s*;/); + }); +}); + describe("WorkflowNodeEditor mobile CSS contract", () => { it("FN-5992 preserves desktop editor min-width while adding full-screen mobile overrides", () => { const baseCss = loadAllAppCssBaseOnly(); @@ -109,6 +156,17 @@ describe("WorkflowNodeEditor mobile CSS contract", () => { expect(mobileDetailInspectorRule).toMatch(/min-height\s*:\s*0\s*;/); expect(mobileDetailInspectorRule).toMatch(/max-height\s*:\s*none\s*;/); + const mobileEdgeDetailCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-edge-detail \.wf-editor-canvas-wrap\s*\{[^}]*\}/); + expect(mobileEdgeDetailCanvasRule).toMatch(/display\s*:\s*none\s*;/); + + const mobileEdgeDetailInspectorRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-edge-detail \.wf-editor-inspector\s*\{[^}]*\}/); + expect(mobileEdgeDetailInspectorRule).toMatch(/display\s*:\s*flex\s*;/); + expect(mobileEdgeDetailInspectorRule).toMatch(/flex\s*:\s*1 1 auto\s*;/); + expect(mobileEdgeDetailInspectorRule).toMatch(/max-height\s*:\s*none\s*;/); + + const mobileTabsRule = findRule(mobileBlocks, /\.wf-mobile-tabs\s*\{[^}]*\}/); + expect(mobileTabsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/); + const collapsedToggleRule = findRule([editorCss], /\.wf-inspector-toggle--collapsed\s*\{[^}]*\}/); expect(collapsedToggleRule).toMatch(/position\s*:\s*absolute\s*;/); expect(collapsedToggleRule).toMatch(/bottom\s*:\s*var\(--space-sm\)\s*;/); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 8a1f8017d3..d09aa4a150 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, cleanup, within } from "@testing-library/react"; import type { WorkflowDefinition, Settings } from "@fusion/core"; @@ -344,7 +345,7 @@ describe("workflow-flow-mapping", () => { const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure"); expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([ "execute", - "merge", + "merge-attempt", "planning", "review", "workflow-step", @@ -409,6 +410,58 @@ describe("WorkflowNodeEditor", () => { expect(screen.getByTestId("wf-layout-toggle")).toHaveTextContent("Show simple editor"); }); + it("surfaces the full styled simple-editor affordance set at desktop width", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + + render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); + + expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); + fireEvent.click(screen.getByTestId("wf-layout-toggle")); + + const shell = await screen.findByTestId("wf-mobile-shell"); + for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) { + expect(within(shell).getByTestId(`wf-mobile-tab-${panel}`)).toBeInTheDocument(); + } + + fireEvent.click(screen.getByTestId("wf-mobile-tab-actions")); + expect(screen.getByTestId("wf-mobile-save")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-ai-edit")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-auto-layout")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-export")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-delete")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("wf-mobile-tab-add")); + expect(screen.getByTestId("wf-mobile-add-prompt-prompt")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-add-script-script")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-add-gate-gate")).toBeInTheDocument(); + }); + + it("surfaces built-in simple-editor actions at desktop width", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + + render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); + + expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Default coding workflow"); + fireEvent.click(screen.getByTestId("wf-layout-toggle")); + await screen.findByTestId("wf-mobile-shell"); + + fireEvent.click(screen.getByTestId("wf-mobile-tab-actions")); + expect(screen.getByTestId("wf-mobile-export")).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-duplicate")).toBeInTheDocument(); + expect(screen.queryByTestId("wf-mobile-save")).not.toBeInTheDocument(); + expect(screen.queryByTestId("wf-mobile-delete")).not.toBeInTheDocument(); + }); + + it("keeps simple-editor shell styling outside the mobile media query", () => { + const css = readFileSync("app/components/WorkflowNodeEditor.css", "utf8"); + const mobileMediaIndex = css.indexOf("@media (max-width: 768px)"); + + expect(css.indexOf("--wf-editor-touch-target")).toBeGreaterThanOrEqual(0); + expect(css.indexOf("--wf-editor-touch-target")).toBeLessThan(mobileMediaIndex); + expect(css.indexOf(".wf-mobile-tab {")).toBeLessThan(mobileMediaIndex); + expect(css.indexOf(".wf-mobile-actions .wf-editor-action")).toBeLessThan(mobileMediaIndex); + }); + it("lets tablet users switch to the simple graph layout", async () => { mockWorkflowEditorViewport("tablet"); vi.mocked(fetchWorkflows).mockResolvedValue([def()]); @@ -518,6 +571,37 @@ describe("WorkflowNodeEditor", () => { expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true"); }); + it("opens selected edge details as a dismissible full-screen mobile stage", async () => { + mockWorkflowEditorViewport("mobile"); + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + + render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "Custom" })); + await screen.findByText("Save"); + await screen.findByTestId("mobile-wf-graph"); + + const mobileEdgeChip = await screen.findByTestId("mobile-wf-edge-e-step-end-1"); + fireEvent.click(mobileEdgeChip); + + const edgeInspector = await screen.findByTestId("wf-edge-inspector"); + const editorBody = edgeInspector.closest(".wf-editor-body"); + expect(editorBody).toHaveClass("wf-editor-body--mobile-edge-detail"); + expect(editorBody).not.toHaveClass("wf-editor-body--mobile-node-detail"); + expect(within(edgeInspector).getByRole("button", { name: /delete edge/i })).toBeInTheDocument(); + expect(screen.getByTestId("wf-mobile-shell").closest(".wf-editor-canvas-wrap")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("wf-edge-inspector-close")); + await waitFor(() => expect(screen.queryByTestId("wf-edge-inspector")).not.toBeInTheDocument()); + expect(await screen.findByTestId("mobile-wf-graph")).toBeVisible(); + + fireEvent.click(within(await screen.findByTestId("mobile-wf-node-step")).getByRole("button")); + + const nodeInspector = await screen.findByTestId("wf-node-inspector"); + expect(nodeInspector.closest(".wf-editor-body")).toHaveClass("wf-editor-body--mobile-node-detail"); + expect(nodeInspector.closest(".wf-editor-body")).not.toHaveClass("wf-editor-body--mobile-edge-detail"); + }); + it("auto-expands the mobile inspector when selecting another node", async () => { mockWorkflowEditorViewport("mobile"); vi.mocked(fetchWorkflows).mockResolvedValue([def()]); diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 0829d52a72..9a074c6aaf 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { WorkflowResultsTab } from "../WorkflowResultsTab"; -import { fetchWorkflow, fetchWorkflows, fetchWorkflowSteps } from "../../api"; +import { fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchWorkflowOptionalSteps } from "../../api"; import { useAgentLogs } from "../../hooks/useAgentLogs"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import type { AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core"; @@ -12,6 +12,7 @@ vi.mock("../../api", () => ({ selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: "WF-001", enabledWorkflowSteps: [] }), fetchWorkflows: vi.fn().mockResolvedValue([]), fetchWorkflow: vi.fn(), + fetchWorkflowOptionalSteps: vi.fn(), submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }), approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }), })); @@ -32,6 +33,7 @@ vi.mock("../../hooks/useAgentLogs", () => ({ const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps); const mockedFetchWorkflow = vi.mocked(fetchWorkflow); const mockedFetchWorkflows = vi.mocked(fetchWorkflows); +const mockedFetchWorkflowOptionalSteps = vi.mocked(fetchWorkflowOptionalSteps); const mockedUseAgentLogs = vi.mocked(useAgentLogs); describe("WorkflowResultsTab", () => { @@ -133,6 +135,17 @@ describe("WorkflowResultsTab", () => { mockedFetchWorkflow.mockResolvedValue(selectedWorkflow); mockedFetchWorkflows.mockReset(); mockedFetchWorkflows.mockResolvedValue([selectedWorkflow]); + mockedFetchWorkflowOptionalSteps.mockReset(); + mockedFetchWorkflowOptionalSteps.mockResolvedValue([ + { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge", + defaultOn: false, + }, + ]); mockedUseAgentLogs.mockReset(); mockedUseAgentLogs.mockReturnValue({ entries: [], @@ -1019,6 +1032,49 @@ describe("WorkflowResultsTab", () => { expect(within(editor).getAllByText("Browser Verification")).toHaveLength(1); }); + it("renders workflow-declared optional steps when not materialized", async () => { + mockedFetchWorkflowSteps.mockResolvedValueOnce(mockWorkflowSteps.filter((step) => step.id !== "WS-103")); + const onWorkflowStepsChange = vi.fn(); + + render( + <WorkflowResultsTab + taskId="FN-001" + results={[]} + canEdit + enabledWorkflowSteps={[]} + onWorkflowStepsChange={onWorkflowStepsChange} + />, + ); + + fireEvent.click(await screen.findByTestId("workflow-steps-edit-toggle")); + const checkbox = await screen.findByTestId("workflow-step-checkbox-browser-verification"); + + expect(within(checkbox).getByText("Browser Verification")).toBeInTheDocument(); + fireEvent.click(within(checkbox).getByRole("checkbox")); + expect(onWorkflowStepsChange).toHaveBeenCalledWith(["browser-verification"]); + }); + + it("disabling a workflow-declared optional step removes its template id", async () => { + mockedFetchWorkflowSteps.mockResolvedValueOnce(mockWorkflowSteps.filter((step) => step.id !== "WS-103")); + const onWorkflowStepsChange = vi.fn(); + + render( + <WorkflowResultsTab + taskId="FN-001" + results={[]} + canEdit + enabledWorkflowSteps={["WS-101", "browser-verification"]} + onWorkflowStepsChange={onWorkflowStepsChange} + />, + ); + + fireEvent.click(await screen.findByTestId("workflow-steps-edit-toggle")); + const checkbox = await screen.findByTestId("workflow-step-checkbox-browser-verification"); + fireEvent.click(within(checkbox).getByRole("checkbox")); + + expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-101"]); + }); + it("fetches workflow step definitions when canEdit and projectId are provided", async () => { render( <WorkflowResultsTab diff --git a/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx b/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx index 34c6307bb7..f09b201c51 100644 --- a/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import i18next from "i18next"; import { AgentsView } from "../AgentsView"; import { loadAllAppCss } from "../../test/cssFixture"; import type { Agent, AgentCapability, AgentState } from "../../api"; @@ -149,6 +150,10 @@ describe("AgentsView mobile adaptations", () => { vi.mocked(fetchOrgTree).mockResolvedValue([]); }); + afterEach(() => { + i18next.removeResourceBundle("en", "app"); + }); + it("renders board view grid and board cards", async () => { const { container } = render(<AgentsView addToast={vi.fn()} />); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); @@ -161,7 +166,14 @@ describe("AgentsView mobile adaptations", () => { }); }); - it("renders list view cards", async () => { + it("renders list view cards with interpolated heartbeat badges", async () => { + i18next.addResourceBundle( + "en", + "app", + { agents: { lastHeartbeat: "Last heartbeat", nextHeartbeat: "Next heartbeat in {{elapsed}}" } }, + true, + true, + ); const { container } = render(<AgentsView addToast={vi.fn()} />); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); @@ -172,6 +184,13 @@ describe("AgentsView mobile adaptations", () => { expect(container.querySelectorAll(".agent-card").length).toBeGreaterThan(0); }); + const lastBadge = container.querySelector(".agent-heartbeat-last"); + const nextBadge = container.querySelector(".agent-heartbeat-next"); + expect(lastBadge?.textContent).toMatch(/Last: .*\d/); + expect(lastBadge?.textContent).not.toBe("Last heartbeat"); + expect(nextBadge?.textContent).toMatch(/Next: .*\d/); + expect(nextBadge?.textContent).not.toContain("{{"); + // Token-stats panel now lives in the controls popup; open it before // asserting on the panel content. fireEvent.click(screen.getByRole("button", { name: "Controls" })); diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx index 3c5d84583a..c5f8dd392e 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx @@ -92,7 +92,10 @@ function ensureMatchMedia() { } } +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; +const originalScreen = window.screen; type ViewportSpy = ReturnType<typeof vi.spyOn> & { setViewport: (width: number, height?: number) => void; @@ -106,6 +109,8 @@ function mockViewport(width: number, height = 812): ViewportSpy { const listeners = new Map<string, Set<() => void>>(); const matchesQuery = (query: string) => { + if (query === MOBILE_WIDTH_MEDIA_QUERY) return viewportWidth <= 768; + if (query === MOBILE_HEIGHT_MEDIA_QUERY) return viewportHeight <= 480; if (query === MOBILE_MEDIA_QUERY) return viewportWidth <= 768 || viewportHeight <= 480; if (query === TABLET_MEDIA_QUERY) return viewportWidth >= 769 && viewportWidth <= 1024; return false; @@ -116,7 +121,21 @@ function mockViewport(width: number, height = 812): ViewportSpy { Object.defineProperty(window, "innerHeight", { value: viewportHeight, configurable: true }); }; + const setScreenSize = () => { + Object.defineProperty(window, "screen", { + configurable: true, + value: { + ...originalScreen, + width, + height, + availWidth: width, + availHeight: height, + } as Screen, + }); + }; + setWindowSize(); + setScreenSize(); const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => { const queryListeners = listeners.get(query) ?? new Set<() => void>(); @@ -423,10 +442,63 @@ describe("auto-merge toggle mobile integration regression", () => { afterEach(() => { _resetInitialViewportHeight(); + Object.defineProperty(window, "screen", { + configurable: true, + value: originalScreen, + }); vi.useRealTimers(); vi.unstubAllGlobals(); }); + it.each([ + { name: "mobile portrait", width: 375, height: 812 }, + { name: "mobile landscape", width: 844, height: 390 }, + ])("realigns mobile document horizontal scroll after toggling an offscreen auto-merge control on $name", async ({ width, height }) => { + const { viewportSpy, visualViewport } = renderBoardHarness({ + width, + height, + tasks: createInReviewAndWorktreeTasks(), + autoMerge: true, + }); + + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(1); + }); + + const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation((xOrOptions?: number | ScrollToOptions, y?: number) => { + const left = typeof xOrOptions === "object" ? (xOrOptions.left ?? window.scrollX) : (xOrOptions ?? window.scrollX); + const top = typeof xOrOptions === "object" ? (xOrOptions.top ?? window.scrollY) : (y ?? window.scrollY); + Object.defineProperty(window, "scrollX", { configurable: true, value: left }); + Object.defineProperty(window, "scrollY", { configurable: true, value: top }); + }); + Object.defineProperty(window, "scrollX", { configurable: true, value: 911 }); + Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); + document.documentElement.scrollLeft = 911; + document.body.scrollLeft = 911; + + await act(async () => { + fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); + await Promise.resolve(); + }); + act(() => { + visualViewport.dispatchResize(); + vi.advanceTimersByTime(1); + }); + + expect(updateSettings).toHaveBeenCalledWith({ autoMerge: false }, "proj_123"); + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + expect(window.scrollX).toBe(0); + expect(document.documentElement.scrollLeft).toBe(0); + expect(document.body.scrollLeft).toBe(0); + expectBoardVisible(["FN-5972", "Worktree child task"]); + + scrollToSpy.mockRestore(); + viewportSpy.mockRestore(); + }); + it("keeps the real board/task-card and worktree-group composition visible on mobile portrait after toggling auto-merge on and back off", async () => { const { viewportSpy, visualViewport } = renderBoardHarness({ width: 375, diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index 287780c841..48f621b55f 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -1,12 +1,18 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, cleanup, act } from "@testing-library/react"; +import { render, cleanup, act, waitFor } from "@testing-library/react"; import { Board } from "../Board"; import { loadAllAppCss } from "../../test/cssFixture"; +const apiMocks = vi.hoisted(() => ({ + fetchBoardWorkflows: vi.fn(), + fetchWorkflowSteps: vi.fn(), +})); + vi.mock("../../api", () => ({ - fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), - fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + fetchBoardWorkflows: apiMocks.fetchBoardWorkflows, + fetchWorkflowSteps: apiMocks.fetchWorkflowSteps, + promoteTask: vi.fn().mockResolvedValue({}), })); vi.mock("../../hooks/useBlockerFanout", () => ({ @@ -15,7 +21,7 @@ vi.mock("../../hooks/useBlockerFanout", () => ({ vi.mock("../Column", () => ({ Column: React.memo(({ column, tasks }: { column: string; tasks?: unknown[] }) => ( - <div data-task-count={tasks?.length ?? 0} data-testid={`column-${column}`} /> + <div className="column" data-task-count={tasks?.length ?? 0} data-testid={`column-${column}`} /> )), })); @@ -68,6 +74,26 @@ function extractRule(content: string, selector: string): string { return content.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0] ?? ""; } +const workflowPayload = { + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding (built-in)", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: {} }, + { id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } }, + { id: "in-review", name: "In Review", flags: { humanReview: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], + }, + ], + taskWorkflowIds: {}, +}; + const boardProps = { tasks: [], maxConcurrent: 2, @@ -84,6 +110,8 @@ const boardProps = { describe("Board mobile initial render stabilization (FN-4574)", () => { beforeEach(() => { vi.clearAllMocks(); + apiMocks.fetchBoardWorkflows.mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }); + apiMocks.fetchWorkflowSteps.mockResolvedValue([]); vi.useFakeTimers(); }); @@ -223,9 +251,15 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { } }); - it("keeps the board fill-height invariant across base, tablet, and mobile CSS tiers", () => { + it("keeps the board fill-height invariant across workflow, base, tablet, and mobile CSS tiers", () => { const cssContent = loadAllAppCss(); const baseBoardRule = extractRule(cssContent, ".board"); + const workflowViewRule = extractRule(cssContent, ".board-workflow-view"); + const workflowColumnsRule = extractRule(cssContent, ".board.board-workflow-columns"); + const workflowColumnRule = extractRule(cssContent, ".board.board-workflow-columns > .column"); + const sharedColumnRule = extractRule(cssContent, ".column"); + const workflowTabletCss = extractMediaBlocks(cssContent, /\(max-width: 1024px\)/); + const workflowTabletColumnsRule = extractRule(workflowTabletCss, ".board.board-workflow-columns"); const tabletCss = extractMediaBlocks(cssContent, /\(min-width: 769px\) and \(max-width: 1024px\)/); const mobileCss = extractMediaBlocks(cssContent, /\(max-width: 768px\)/); const tabletBoardRule = extractRule(tabletCss, ".board"); @@ -239,9 +273,40 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { expect(baseBoardRule).toContain("box-sizing: border-box"); expect(baseBoardRule).toContain("flex: 1 1 auto"); + expect(baseBoardRule).toContain("height: 100%"); expect(baseBoardRule).toContain("min-height: 0"); expect(baseBoardRule).toContain("min-width: 0"); + expect(workflowViewRule).toContain("display: flex"); + expect(workflowViewRule).toContain("flex-direction: column"); + expect(workflowViewRule).toContain("flex: 1 1 auto"); + expect(workflowViewRule).toContain("height: 100%"); + expect(workflowViewRule).toContain("max-height: 100%"); + expect(workflowViewRule).toContain("min-height: 0"); + + expect(workflowColumnsRule).toContain("flex: 1 1 auto"); + expect(workflowColumnsRule).toContain("display: flex"); + expect(workflowColumnsRule).toContain("align-items: stretch"); + expect(workflowColumnsRule).toContain("height: 100%"); + expect(workflowColumnsRule).toContain("max-height: 100%"); + expect(workflowColumnsRule).toContain("min-height: 0"); + expect(workflowColumnsRule).toContain("scroll-snap-type: x proximity"); + expect(workflowColumnsRule).not.toContain("scroll-snap-type: x mandatory"); + + expect(workflowTabletColumnsRule).toContain("flex: 1 1 auto"); + expect(workflowTabletColumnsRule).toContain("align-items: stretch"); + expect(workflowTabletColumnsRule).toContain("height: 100%"); + expect(workflowTabletColumnsRule).toContain("max-height: 100%"); + expect(workflowTabletColumnsRule).toContain("min-height: 0"); + expect(workflowTabletColumnsRule).toContain("scroll-snap-type: x proximity"); + expect(workflowTabletColumnsRule).not.toContain("scroll-snap-type: x mandatory"); + + expect(workflowColumnRule).toContain("flex: 1 0 300px"); + expect(workflowColumnRule).toContain("min-width: 300px"); + expect(workflowColumnRule).toContain("height: 100%"); + expect(workflowColumnRule).toContain("min-height: 0"); + expect(sharedColumnRule).toContain("min-height: 0"); + expect(tabletBoardRule).toContain("grid-template-columns: repeat(6, minmax(260px, 1fr))"); expect(tabletBoardRule).toContain("overflow-x: auto"); @@ -286,4 +351,50 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { viewportSpy.mockRestore(); }); + + it("renders workflow-mode columns for empty and populated states at tablet width", async () => { + vi.useRealTimers(); + const viewportSpy = mockViewport(900); + apiMocks.fetchBoardWorkflows.mockResolvedValue(workflowPayload); + + const { rerender } = render(<Board {...boardProps} />); + + await waitFor(() => { + expect(document.querySelector(".board-workflow-view")).not.toBeNull(); + }); + + let board = document.querySelector("main.board.board-workflow-columns"); + expect(board).not.toBeNull(); + + let columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']"); + expect(columns).toHaveLength(6); + for (const column of columns) { + expect(column).toHaveClass("column"); + expect(column).toHaveAttribute("data-task-count", "0"); + } + + rerender( + <Board + {...boardProps} + tasks={[ + { id: "FN-1", title: "Workflow planning task", column: "triage" }, + { id: "FN-2", title: "Workflow todo task", column: "todo" }, + ] as any} + />, + ); + + await waitFor(() => { + expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull(); + }); + + board = document.querySelector("main.board.board-workflow-columns"); + expect(board).not.toBeNull(); + + columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']"); + expect(columns).toHaveLength(6); + expect(document.querySelector(".board-workflow-columns [data-testid='column-triage']")).toHaveAttribute("data-task-count", "1"); + expect(document.querySelector(".board-workflow-columns [data-testid='column-todo']")).toHaveAttribute("data-task-count", "1"); + + viewportSpy.mockRestore(); + }); }); diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index f6d4c2caa4..614627a987 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -28,6 +28,7 @@ vi.mock("../../api", () => ({ fetchAgents: vi.fn().mockResolvedValue([]), // InlineCreateCard renders WorkflowSelector, which loads these on mount. fetchWorkflows: vi.fn().mockResolvedValue([]), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), fetchProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }), setProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }), selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }), diff --git a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx index de06e1a193..e6ceeda4ab 100644 --- a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx @@ -4,11 +4,8 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -function getMainMobileBlock(css: string): string { - // Mobile rules now live both in styles.css (cross-cutting) and in - // co-located @media (max-width: 768px) blocks at the bottom of each - // component CSS file. Aggregate all such media-query blocks. - const matches = [...css.matchAll(/@media[^{]*\(max-width:\s*768px\)[^{]*\{/g)]; +function getMediaBlocks(css: string, pattern: RegExp): string { + const matches = [...css.matchAll(pattern)]; expect(matches.length).toBeGreaterThan(0); const parts: string[] = []; @@ -24,13 +21,78 @@ function getMainMobileBlock(css: string): string { } parts.push(css.slice(start, i)); } - const block = parts.join("\n"); + return parts.join("\n"); +} + +function getMainMobileBlock(css: string): string { + // Mobile rules now live both in styles.css (cross-cutting) and in + // co-located @media (max-width: 768px) blocks at the bottom of each + // component CSS file. Aggregate all such media-query blocks. + const block = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g); expect(block).toContain(".modal-overlay"); expect(block).toContain(".detail-tabs"); return block; } +function getTabletBlock(css: string): string { + const block = getMediaBlocks( + css, + /@media[^{]*\(min-width:\s*769px\)[^{]*\(max-width:\s*1024px\)[^{]*\{/g, + ); + expect(block).toContain(".modal.task-detail-modal"); + return block; +} + +function getRuleBlocks(css: string, selector: string): string[] { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))] + .map((match) => match[1]); +} + +function getFirstRuleBlock(css: string, selector: string): string { + const block = getRuleBlocks(css, selector).at(0); + expect(block).toBeTruthy(); + return block!; +} + +function getLastRuleBlock(css: string, selector: string): string { + const block = getRuleBlocks(css, selector).at(-1); + expect(block).toBeTruthy(); + return block!; +} + +function extractVhHeight(rule: string): number { + const heightMatch = rule.match(/height:\s*(\d+)vh;/); + expect(heightMatch).toBeTruthy(); + return Number(heightMatch![1]); +} + describe("core modals mobile css coverage", () => { + it("TaskDetailModal: keeps desktop, tablet, mobile, and embedded height invariants", () => { + const css = loadAllAppCss(); + const tabletBlock = getTabletBlock(css); + const mobileBlock = getMainMobileBlock(css); + + const baseRule = getFirstRuleBlock(css, ".modal.task-detail-modal"); + expect(baseRule).toContain("height: 85vh;"); + expect(baseRule).toContain("max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);"); + expect(baseRule).toContain("resize: both;"); + + const tabletRule = getLastRuleBlock(tabletBlock, ".modal.task-detail-modal"); + expect(tabletRule).toContain("height: 92vh;"); + expect(extractVhHeight(tabletRule)).toBeGreaterThan(extractVhHeight(baseRule)); + expect(tabletRule).toContain("max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - 16px);"); + + const mobileRule = getLastRuleBlock(mobileBlock, ".modal.task-detail-modal"); + expect(mobileRule).toContain("height: 100dvh;"); + expect(mobileRule).toContain("max-height: 100dvh;"); + expect(mobileRule).toContain("resize: none;"); + + const embeddedRule = getFirstRuleBlock(css, ".task-detail-content--embedded"); + expect(embeddedRule).toContain("height: 100%;"); + expect(tabletBlock).not.toContain(".task-detail-content--embedded"); + }); + it("TaskDetailModal: modal-actions uses safe-area inset bottom padding", () => { const css = loadAllAppCss(); const mobileBlock = getMainMobileBlock(css); diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 1aecef5f7b..77501e8cf0 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkflowDefinition } from "@fusion/core"; +import type { WorkflowDefinition, WorkflowIrNodeKind } from "@fusion/core"; import { parseWorkflowIr } from "@fusion/core"; import type { Node as FlowNode } from "@xyflow/react"; import { @@ -32,7 +32,7 @@ import { FOREACH_CHILD_X, FOREACH_CHILD_Y, } from "../workflow-flow-mapping"; -import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; +import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; import type { TraitCatalogEntry } from "../../api"; function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition { @@ -198,6 +198,55 @@ describe("workflow-flow-mapping v2 round-trip", () => { expect(byId.j1.config?.onBranchFailure).toBe("fail-fast"); }); + + it("preserves aliased IR node kinds when round-tripping through editor render kinds", () => { + const ir: WorkflowDefinition["ir"] = { + version: "v2", + name: "merge aliases", + columns: [{ id: "in-progress", name: "In progress", traits: [] }], + nodes: [ + { id: "gate", kind: "merge-gate", column: "in-progress", config: { name: "Gate" } }, + { id: "attempt", kind: "merge-attempt", column: "in-progress" }, + { id: "hold", kind: "manual-merge-hold", column: "in-progress", config: { release: "manual" } }, + { + id: "retry", + kind: "retry-backoff", + column: "in-progress", + config: { + maxIterations: 2, + template: { + nodes: [{ id: "retry-step", kind: "prompt", config: { prompt: "try again" } }], + edges: [{ from: "retry-step", to: "retry-step", condition: "retry", kind: "rework" }], + }, + }, + }, + ] as WorkflowDefinition["ir"]["nodes"], + edges: [], + }; + + const { nodes, edges } = irToFlow(v2Def(ir)); + expect(nodes.find((node) => node.id === "gate")?.type).toBe("gate"); + expect(nodes.find((node) => node.id === "hold")?.type).toBe("hold"); + expect(nodes.find((node) => node.id === "retry")?.type).toBe("hold"); + + const { ir: out } = flowToIr("merge aliases", nodes, edges, columnsOf(v2Def(ir))); + if (out.version !== "v2") throw new Error("expected v2"); + const byId = Object.fromEntries(out.nodes.map((node) => [node.id, node])); + expect(byId.gate.kind).toBe("merge-gate"); + expect(byId.gate.config?.name).toBe("Gate"); + expect(byId.attempt.kind).toBe("merge-attempt"); + expect(byId.hold.kind).toBe("manual-merge-hold"); + expect(byId.hold.config?.release).toBe("manual"); + expect(byId.retry.kind).toBe("retry-backoff"); + expect(byId.retry.config).toEqual({ + maxIterations: 2, + template: { + nodes: [{ id: "retry-step", kind: "prompt", config: { prompt: "try again" } }], + edges: [{ from: "retry-step", to: "retry-step", condition: "retry", kind: "rework" }], + }, + }); + }); + it("emits swimlane band group nodes that flowToIr strips back out", () => { const { nodes } = irToFlow(v2Def(ir)); const bands = nodes.filter((n) => isColumnBandNode(n.id)); @@ -360,6 +409,103 @@ describe("workflow-flow-mapping validation helpers", () => { }); }); +// ── IR-only graph node kinds map to existing editor node shapes ───────────── + +const VALID_EDITOR_NODE_KINDS: readonly WorkflowEditorNodeKind[] = [ + "start", + "end", + "prompt", + "script", + "gate", + "merge", + "hold", + "split", + "join", + "foreach", + "loop", + "step-review", + "parse-steps", + "code", + "notify", +]; + +const IR_ONLY_EDITOR_KIND = { + "merge-gate": "gate", + "merge-attempt": "merge", + "manual-merge-hold": "hold", + "retry-backoff": "hold", + "recovery-router": "gate", + "branch-group-member-integration": "merge", + "branch-group-promotion": "merge", +} satisfies Partial<Record<WorkflowIrNodeKind, WorkflowEditorNodeKind>>; + +describe("workflow-flow-mapping editor kind mapping", () => { + it("maps workflow-owned IR-only node kinds to valid editor kinds", () => { + const irOnlyKinds = Object.keys(IR_ONLY_EDITOR_KIND) as (keyof typeof IR_ONLY_EDITOR_KIND)[]; + const ir: WorkflowDefinition["ir"] = { + version: "v2", + name: "policy-nodes", + columns: [{ id: "work", name: "Work", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "work" }, + ...irOnlyKinds.map((kind) => ({ id: kind, kind, column: "work" as const })), + { id: "foreach", kind: "foreach", column: "work", config: { + source: "task-steps", + template: { + nodes: [{ id: "template-merge-gate", kind: "merge-gate" }], + edges: [], + }, + } }, + { id: "end", kind: "end", column: "work" }, + ], + edges: [], + }; + + const { nodes } = irToFlow(makeDef(ir)); + const stepNodes = nodes.filter((node) => !isColumnBandNode(node.id)); + expect(stepNodes.every((node) => VALID_EDITOR_NODE_KINDS.includes(node.data.kind))).toBe(true); + expect(stepNodes.every((node) => VALID_EDITOR_NODE_KINDS.includes(node.type as WorkflowEditorNodeKind))).toBe(true); + + for (const rawKind of irOnlyKinds) { + const flowNode = stepNodes.find((node) => node.id === rawKind); + const expectedKind = IR_ONLY_EDITOR_KIND[rawKind]; + expect(flowNode?.type).toBe(expectedKind); + expect(flowNode?.data.kind).toBe(expectedKind); + expect(flowNode?.type).not.toBe(rawKind); + expect(flowNode?.data.kind).not.toBe(rawKind); + } + + const templateChild = stepNodes.find((node) => node.id === foreachChildFlowId("foreach", "template-merge-gate")); + expect(templateChild?.type).toBe("gate"); + expect(templateChild?.data.kind).toBe("gate"); + expect(templateChild?.type).not.toBe("merge-gate"); + }); + + it("keeps merge seam and PR graph-node special cases mapped to existing editor kinds", () => { + const ir: WorkflowDefinition["ir"] = { + version: "v1", + name: "special-cases", + nodes: [ + { id: "merge-seam", kind: "prompt", config: { seam: "merge" } }, + { id: "pr-merge", kind: "pr-merge" }, + { id: "pr-create", kind: "pr-create" }, + { id: "pr-respond", kind: "pr-respond" }, + ], + edges: [], + }; + + const byId = Object.fromEntries(irToFlow(makeDef(ir)).nodes.map((node) => [node.id, node])); + expect(byId["merge-seam"]?.type).toBe("merge"); + expect(byId["merge-seam"]?.data.kind).toBe("merge"); + expect(byId["pr-merge"]?.type).toBe("merge"); + expect(byId["pr-merge"]?.data.kind).toBe("merge"); + expect(byId["pr-create"]?.type).toBe("prompt"); + expect(byId["pr-create"]?.data.kind).toBe("prompt"); + expect(byId["pr-respond"]?.type).toBe("prompt"); + expect(byId["pr-respond"]?.data.kind).toBe("prompt"); + }); +}); + // ── U8: step-inversion round-trip (foreach template, rework edges) ─────────── describe("workflow-flow-mapping foreach + rework round-trip", () => { diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 2372c474b4..58e06f0620 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -11,11 +11,35 @@ * original inline JSX. */ import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Settings } from "@fusion/core"; import { MovedSettingsStub } from "./MovedSettingsStub"; import type { SectionBaseProps } from "./context"; +interface LegacyAutoMergeStampCandidate { + taskId: string; + column: string; + cleared: boolean; +} + +interface LegacyAutoMergeStampListResponse { + candidates: LegacyAutoMergeStampCandidate[]; + count: number; +} + +interface LegacyAutoMergeStampApplyResponse { + cleared: LegacyAutoMergeStampCandidate[]; + count: number; +} + +async function readLegacyAutoMergeStampResponse(response: Response): Promise<LegacyAutoMergeStampListResponse> { + if (!response.ok) { + throw new Error(await response.text() || "Failed to load legacy auto-merge stamps"); + } + return response.json() as Promise<LegacyAutoMergeStampListResponse>; +} + export interface MergeSectionProps extends SectionBaseProps { scopeBanner: ReactNode; integrationBranchOptions: string[]; @@ -34,6 +58,54 @@ export function MergeSection({ onOpenWorkflowSettings, }: MergeSectionProps) { const { t } = useTranslation("app"); + const [legacyStampCandidates, setLegacyStampCandidates] = useState<LegacyAutoMergeStampCandidate[]>([]); + const [legacyStampLoading, setLegacyStampLoading] = useState(true); + const [legacyStampApplying, setLegacyStampApplying] = useState(false); + const [legacyStampError, setLegacyStampError] = useState<string | null>(null); + const [legacyStampSuccess, setLegacyStampSuccess] = useState<string | null>(null); + + const loadLegacyAutoMergeStamps = useCallback(async () => { + setLegacyStampLoading(true); + setLegacyStampError(null); + try { + const data = await readLegacyAutoMergeStampResponse( + await fetch("/api/maintenance/legacy-automerge-stamps"), + ); + setLegacyStampCandidates(Array.isArray(data.candidates) ? data.candidates : []); + } catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to load legacy auto-merge stamps"); + } finally { + setLegacyStampLoading(false); + } + }, []); + + useEffect(() => { + void loadLegacyAutoMergeStamps(); + }, [loadLegacyAutoMergeStamps]); + + const applyLegacyAutoMergeStampCleanup = async () => { + const confirmed = window.confirm( + "Apply cleanup for legacy auto-merge stamps? This clears only legacy non-override in-review stamps returned by the store and never touches genuine per-task overrides.", + ); + if (!confirmed) return; + setLegacyStampApplying(true); + setLegacyStampError(null); + setLegacyStampSuccess(null); + try { + const response = await fetch("/api/maintenance/legacy-automerge-stamps/apply", { method: "POST" }); + if (!response.ok) { + throw new Error(await response.text() || "Failed to apply legacy auto-merge stamp cleanup"); + } + const data = await response.json() as LegacyAutoMergeStampApplyResponse; + setLegacyStampSuccess(`Cleared ${data.count} legacy auto-merge stamp${data.count === 1 ? "" : "s"}.`); + await loadLegacyAutoMergeStamps(); + } catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to apply legacy auto-merge stamp cleanup"); + } finally { + setLegacyStampApplying(false); + } + }; + return ( <> {scopeBanner} @@ -55,6 +127,43 @@ export function MergeSection({ <small>When enabled, tasks that pass review are automatically merged into the main branch</small> </details> </div> + <div className="form-group" data-testid="legacy-automerge-stamp-cleanup-panel"> + <h5 className="settings-section-heading">Legacy auto-merge stamp cleanup</h5> + <small> + Finds in-review tasks whose auto-merge value came from the legacy review-entry stamp. + Dry-run is automatic; applying delegates to the store cleanup and preserves genuine + per-task overrides. + </small> + {legacyStampLoading ? ( + <small aria-live="polite">Checking for legacy auto-merge stamps…</small> + ) : legacyStampCandidates.length === 0 ? ( + <small data-testid="legacy-automerge-stamp-empty-state"> + No legacy auto-merge stamps to clean up. + </small> + ) : ( + <> + <small>{legacyStampCandidates.length} legacy auto-merge stamp{legacyStampCandidates.length === 1 ? "" : "s"} ready to clean up.</small> + <ul> + {legacyStampCandidates.map((candidate) => ( + <li key={candidate.taskId} data-testid="legacy-automerge-stamp-candidate-row"> + <strong>{candidate.taskId}</strong> — {candidate.column} + </li> + ))} + </ul> + <button + type="button" + className="btn" + onClick={applyLegacyAutoMergeStampCleanup} + disabled={legacyStampApplying} + data-testid="legacy-automerge-stamp-apply-button" + > + {legacyStampApplying ? "Applying cleanup…" : "Apply cleanup"} + </button> + </> + )} + {legacyStampSuccess ? <small className="settings-success" aria-live="polite">{legacyStampSuccess}</small> : null} + {legacyStampError ? <small className="settings-error" role="alert">{legacyStampError}</small> : null} + </div> <div className="form-group"> <label htmlFor="mergerMode">AI merge</label> <select diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 56462b56a8..53e8e7780e 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -28,7 +28,7 @@ import { import { CustomModelDropdown } from "../../CustomModelDropdown"; import { applyPresetToSelection } from "../../../utils/modelPresets"; import type { ToastType } from "../../../hooks/useToast"; -import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; +import type { ModelLane, SectionBaseProps, SectionSaveHandler, SettingsFormState } from "./context"; type LaneStatus = "inherited" | "overridden"; @@ -94,12 +94,23 @@ export interface ProjectModelsSectionModelProps { confirmDelete: (options: { title: string; message: string; danger?: boolean }) => Promise<boolean>; } +export class WorkflowLaneFlushRejection extends Error { + readonly rejections: WorkflowSettingRejection[]; + + constructor(rejections: WorkflowSettingRejection[]) { + super("Workflow model lane settings were rejected"); + this.name = "WorkflowLaneFlushRejection"; + this.rejections = rejections; + } +} + export interface ProjectModelsSectionProps extends SectionBaseProps { scopeBanner: ReactNode; models: ProjectModelsSectionModelProps; projectId?: string; addToast: (message: string, type?: ToastType) => void; onOpenWorkflowSettings?: () => void; + registerWorkflowLaneSaver?: (saver: SectionSaveHandler | null) => void; } export function ProjectModelsSection({ @@ -109,6 +120,7 @@ export function ProjectModelsSection({ models, projectId, onOpenWorkflowSettings, + registerWorkflowLaneSaver, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); const { @@ -141,7 +153,6 @@ export function ProjectModelsSection({ const [workflowPayload, setWorkflowPayload] = useState<WorkflowSettingValuesPayload | null>(null); const [workflowLoading, setWorkflowLoading] = useState(false); const [workflowPending, setWorkflowPending] = useState<Record<string, unknown>>({}); - const [workflowSaving, setWorkflowSaving] = useState(false); const [workflowRejections, setWorkflowRejections] = useState<Record<string, WorkflowSettingRejection>>({}); const workflowReqSeq = useRef(0); const workflowDirty = Object.keys(workflowPending).length > 0; @@ -211,7 +222,6 @@ export function ProjectModelsSection({ const saveWorkflowLanes = useCallback(async () => { if (!projectId || !workflowDirty) return; - setWorkflowSaving(true); try { const payload = await updateWorkflowSettingValues(workflowId, workflowPending, projectId); setWorkflowPayload(payload); @@ -222,15 +232,18 @@ export function ProjectModelsSection({ const rejections = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? []; if (rejections.length > 0) { setWorkflowRejections(Object.fromEntries(rejections.map((rejection) => [rejection.settingId, rejection]))); - return; + throw new WorkflowLaneFlushRejection(rejections); } } throw err; - } finally { - setWorkflowSaving(false); } }, [projectId, workflowDirty, workflowId, workflowPending]); + useEffect(() => { + registerWorkflowLaneSaver?.(saveWorkflowLanes); + return () => registerWorkflowLaneSaver?.(null); + }, [registerWorkflowLaneSaver, saveWorkflowLanes]); + // The project DEFAULT lane and restored title-summarizer lane remain editable // here. Execution/planning/validator workflow-specific lanes still redirect to // workflow settings below. @@ -427,22 +440,13 @@ export function ProjectModelsSection({ </div> ); })} - <div className="settings-model-lane-actions" aria-label="Default workflow model lane actions"> - <button - type="button" - className="btn btn-primary btn-sm" - data-testid="save-workflow-model-lanes" - onClick={saveWorkflowLanes} - disabled={!workflowDirty || workflowSaving} - > - {workflowSaving ? "Saving…" : "Save workflow models"} - </button> - {onOpenWorkflowSettings ? ( + {onOpenWorkflowSettings ? ( + <div className="settings-model-lane-actions" aria-label="Default workflow model lane actions"> <button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}> Advanced workflow policy </button> - ) : null} - </div> + </div> + ) : null} </> )} diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx index 43ad43a4e4..85852b2ee0 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -126,6 +126,20 @@ export function SchedulingSection({ </select> <small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small> </div> + <div className="form-group"> + <label htmlFor="engineerBacklogAutoClaim" className="checkbox-label"> + <input + id="engineerBacklogAutoClaim" + type="checkbox" + checked={form.engineerBacklogAutoClaim === true} + onChange={(e) => + setForm((f) => ({ ...f, engineerBacklogAutoClaim: e.target.checked })) + } + /> + Let engineer agents auto-claim backlog tasks + </label> + <small>Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.</small> + </div> <div className="form-group"> <label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label> <input diff --git a/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx new file mode 100644 index 0000000000..25c8e71bc3 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MergeSection } from "../MergeSection"; +import type { MergeSectionProps } from "../MergeSection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), +})); + +function jsonResponse(body: unknown, ok = true): Response { + return { + ok, + json: async () => body, + text: async () => typeof body === "string" ? body : JSON.stringify(body), + } as Response; +} + +function makeProps(): MergeSectionProps { + return { + scopeBanner: null, + form: { + autoMerge: true, + merger: { mode: "ai" }, + testMode: false, + mergeStrategy: "direct", + } as MergeSectionProps["form"], + setForm: vi.fn(), + integrationBranchOptions: ["main"], + integrationBranchCustomMode: false, + setIntegrationBranchCustomMode: vi.fn(), + }; +} + +describe("MergeSection legacy auto-merge stamp cleanup", () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.innerWidth = 1024; + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders the store-provided candidate list without client-side filtering", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + candidates: [ + { taskId: "FN-101", column: "in-review", cleared: false }, + { taskId: "FN-USER", column: "in-review", cleared: false }, + ], + count: 2, + })); + vi.stubGlobal("fetch", fetchMock); + + render(<MergeSection {...makeProps()} />); + + await waitFor(() => expect(screen.getByText("FN-101")).toBeInTheDocument()); + expect(screen.getByText("FN-USER")).toBeInTheDocument(); + expect(screen.getAllByTestId("legacy-automerge-stamp-candidate-row")).toHaveLength(2); + expect(screen.getByTestId("legacy-automerge-stamp-apply-button")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/maintenance/legacy-automerge-stamps"); + }); + + it("renders an explicit empty state and no apply shell when there are zero candidates", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ candidates: [], count: 0 }))); + + render(<MergeSection {...makeProps()} />); + + expect(await screen.findByTestId("legacy-automerge-stamp-empty-state")).toHaveTextContent( + "No legacy auto-merge stamps to clean up.", + ); + expect(screen.queryByTestId("legacy-automerge-stamp-apply-button")).not.toBeInTheDocument(); + }); + + it("requires confirmation, posts apply, and re-fetches to the empty state", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + candidates: [{ taskId: "FN-101", column: "in-review", cleared: false }], + count: 1, + })) + .mockResolvedValueOnce(jsonResponse({ + cleared: [{ taskId: "FN-101", column: "in-review", cleared: true }], + count: 1, + })) + .mockResolvedValueOnce(jsonResponse({ candidates: [], count: 0 })); + vi.stubGlobal("fetch", fetchMock); + + render(<MergeSection {...makeProps()} />); + + fireEvent.click(await screen.findByTestId("legacy-automerge-stamp-apply-button")); + + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("never touches genuine per-task overrides")); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + "/api/maintenance/legacy-automerge-stamps/apply", + { method: "POST" }, + )); + expect(await screen.findByTestId("legacy-automerge-stamp-empty-state")).toBeInTheDocument(); + }); + + it("is operable at a narrow mobile width", async () => { + window.innerWidth = 390; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ + candidates: [{ taskId: "FN-MOBILE", column: "in-review", cleared: false }], + count: 1, + }))); + + render(<MergeSection {...makeProps()} />); + + expect(await screen.findByText("FN-MOBILE")).toBeInTheDocument(); + const applyButton = screen.getByTestId("legacy-automerge-stamp-apply-button"); + expect(applyButton.tagName).toBe("BUTTON"); + expect(applyButton).toHaveTextContent("Apply cleanup"); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/context.ts b/packages/dashboard/app/components/settings/sections/context.ts index 388a30b260..17253ea239 100644 --- a/packages/dashboard/app/components/settings/sections/context.ts +++ b/packages/dashboard/app/components/settings/sections/context.ts @@ -42,6 +42,9 @@ export type SetSettingsForm = ( updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState), ) => void; +/** Async callback registered by a section when it owns a shell-triggered save side effect. */ +export type SectionSaveHandler = () => Promise<void>; + /** Props every extracted section receives. */ export interface SectionBaseProps { /** The single merged settings form (global + project keys). */ diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 0ae907cfc9..06c6c929ad 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -5,6 +5,7 @@ import type { WorkflowIrColumn, WorkflowIrNode, WorkflowIrEdge, + WorkflowIrNodeKind, WorkflowDefinition, WorkflowFieldDefinition, WorkflowSettingDefinition, @@ -127,17 +128,57 @@ function isV2(ir: WorkflowIr): ir is WorkflowIrV2 { return ir.version === "v2"; } -/** Resolve the editor node "type" for an IR node (merge seam → "merge"). */ +const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([ + "start", + "prompt", + "script", + "gate", + "end", + "hold", + "split", + "join", + "foreach", + "loop", + "step-review", + "parse-steps", + "code", + "notify", +]); + +const GRAPH_ONLY_EDITOR_KIND: Partial<Record<WorkflowIrNodeKind, WorkflowEditorNodeKind>> = { + "merge-gate": "gate", + "merge-attempt": "merge", + "manual-merge-hold": "hold", + "retry-backoff": "hold", + "recovery-router": "gate", + "branch-group-member-integration": "merge", + "branch-group-promotion": "merge", + "pr-merge": "merge", + "pr-create": "prompt", + "pr-respond": "prompt", +}; + +function isSameKindEditorNodeKind( + kind: WorkflowIrNodeKind, +): kind is Extract<WorkflowEditorNodeKind, WorkflowIrNodeKind> { + return SAME_KIND_EDITOR_NODE_KINDS.has(kind); +} + +/** + * Resolve the editor node "type" for an IR node. Graph-only IR policy nodes map + * to the closest existing editor shape: merge/recovery gates render as gate, + * merge/branch actions render as merge, passive waits render as hold, and PR + * nodes reuse merge/prompt until dedicated renderers exist. + */ function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind { const seam = node.config?.seam; if (seam === "merge") return "merge"; - // PR node kinds (pr-create/pr-respond/pr-merge) are graph node kinds but have - // no dedicated editor palette renderer yet; map them to the closest existing - // editor shape so the workflow editor renders them as recognizable nodes. - // (Dedicated PR-node editor rendering is a follow-up, not part of this work.) - if (node.kind === "pr-merge") return "merge"; - if (node.kind === "pr-create" || node.kind === "pr-respond") return "prompt"; - return node.kind; + const mapped = GRAPH_ONLY_EDITOR_KIND[node.kind]; + if (mapped) return mapped; + + if (isSameKindEditorNodeKind(node.kind)) return node.kind; + + return "prompt"; } function nodeLabel(node: WorkflowIr["nodes"][number]): string { @@ -147,6 +188,14 @@ function nodeLabel(node: WorkflowIr["nodes"][number]): string { return node.id; } +function dataIrKind(node: WorkflowIrNode, editorNodeKind: WorkflowEditorNodeKind): Partial<WorkflowFlowNodeData> { + return node.kind === editorNodeKind ? {} : { irKind: node.kind }; +} + +function preservedIrKind(data: WorkflowFlowNodeData): WorkflowIrNode["kind"] | undefined { + return typeof data.irKind === "string" ? (data.irKind as WorkflowIrNode["kind"]) : undefined; +} + /** Build React Flow swimlane band group nodes from the workflow's columns. */ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode<WorkflowFlowNodeData>[] { return columns.map((col, index): FlowNode<WorkflowFlowNodeData> => ({ @@ -177,7 +226,7 @@ function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefine } function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined { - if (node.kind !== "loop") return undefined; + if (node.kind !== "loop" && node.kind !== "retry-backoff") return undefined; const cfg = node.config as Partial<WorkflowLoopConfig> | undefined; if (!cfg || !cfg.template) return undefined; return cfg as WorkflowLoopConfig; @@ -272,7 +321,7 @@ export function irToFlow(def: WorkflowDefinition): { position: childPos, parentId: node.id, extent: "parent", - data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, + data: { kind: innerKind, ...dataIrKind(inner, innerKind), label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, deletable: true, zIndex: WF_STEP_NODE_Z_INDEX, }); @@ -288,6 +337,7 @@ export function irToFlow(def: WorkflowDefinition): { position: pos ?? { x: 80 + index * 180, y: fallbackY }, data: { kind, + ...dataIrKind(node, kind), label: nodeLabel(node), config: { ...restCfg }, column, @@ -305,6 +355,7 @@ export function irToFlow(def: WorkflowDefinition): { position: pos ?? { x: 80 + index * 180, y: fallbackY }, data: { kind, + ...dataIrKind(node, kind), label: nodeLabel(node), config: { ...(node.config ?? {}) }, column, @@ -324,7 +375,11 @@ export function irToFlow(def: WorkflowDefinition): { function nodeConfig(node: FlowNode<WorkflowFlowNodeData>): Record<string, unknown> | undefined { const data = node.data; const config: Record<string, unknown> = { ...(data.config ?? {}) }; - const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; + const fallbackLabel = data.kind === "merge" + ? "Merge boundary" + : node.parentId + ? templateNodeIdFromChild(node.parentId, node.id) + : node.id; if (data.kind !== "start" && data.kind !== "end" && data.label && data.label !== fallbackLabel) { config.name = data.label; } else { @@ -377,10 +432,17 @@ export function flowToIr( function toIrNode(node: FlowNode<WorkflowFlowNodeData>, localId: string): WorkflowIrNode { const data = node.data; const config = nodeConfig(node); + const originalKind = preservedIrKind(data); if (data.kind === "merge") { + if (originalKind) { + return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined }; + } return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } }; } - if (data.kind === "foreach" || data.kind === "loop") { + if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") { + if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") { + return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined }; + } // Reassemble the template from this group's children. const children = childrenByGroup.get(node.id) ?? []; const templateNodes: WorkflowIrNode[] = children.map((c) => { @@ -395,13 +457,13 @@ export function flowToIr( const baseCfg = (config ?? {}) as Record<string, unknown>; return { id: localId, - kind: data.kind, + kind: originalKind ?? data.kind, config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } }, }; } return { id: localId, - kind: data.kind as WorkflowIrNode["kind"], + kind: originalKind ?? (data.kind as WorkflowIrNode["kind"]), config: config && Object.keys(config).length ? config : undefined, }; } @@ -947,7 +1009,7 @@ function irNodeToFlowNode( id, type: kind, position, - data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } }, + data: { kind, ...dataIrKind(node, kind), label: nodeLabel(node), config: { ...(node.config ?? {}) } }, deletable: node.kind !== "start" && node.kind !== "end", zIndex: WF_STEP_NODE_Z_INDEX, }; @@ -1025,7 +1087,7 @@ export function insertFragment( position: childPos, parentId: id, extent: "parent", - data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, + data: { kind: innerKind, ...dataIrKind(inner, innerKind), label: nodeLabel(inner), config: { ...(inner.config ?? {}) } }, deletable: true, zIndex: WF_STEP_NODE_Z_INDEX, }); @@ -1041,6 +1103,7 @@ export function insertFragment( position: pos, data: { kind: groupKind, + ...dataIrKind(node, groupKind), label: nodeLabel(node), config: { ...restCfg }, templateEmpty: template.nodes.length === 0, diff --git a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts index 86dc623264..eaaf1e7385 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts @@ -592,6 +592,166 @@ describe("useMobileKeyboard", () => { } }); + it("FN-6362: resets stale iOS keyboard metrics on visibility restore when the keyboard collapsed but focus remains", async () => { + const { listeners, mockVV } = setupMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + const input = document.createElement("textarea"); + document.body.appendChild(input); + + const { result } = renderHook(() => useMobileKeyboard()); + + input.focus(); + Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 180, writable: true, configurable: true }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(true); + expect(result.current.viewportOffsetTop).toBe(180); + }); + + // iOS can restore with the visual viewport back at full height while + // window.innerHeight still reflects the pre-background keyboard shrink. + // The retained focused input plus impossible sample used to hold the stale + // keyboard-open metrics forever because no blur/resize followed. + Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "height", { value: 844, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true }); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(false); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.viewportHeight).toBeNull(); + }); + + input.remove(); + }); + + it("FN-6362: resets stale iOS keyboard metrics on pageshow when stale offset drift remains", async () => { + const { listeners, mockVV } = setupMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + const input = document.createElement("textarea"); + document.body.appendChild(input); + + const { result } = renderHook(() => useMobileKeyboard()); + + input.focus(); + Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 160, writable: true, configurable: true }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(true); + expect(result.current.viewportOffsetTop).toBe(160); + }); + + Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "height", { value: 844, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 120, writable: true, configurable: true }); + + const pageshow = new Event("pageshow") as PageTransitionEvent; + Object.defineProperty(pageshow, "persisted", { value: false }); + + act(() => { + window.dispatchEvent(pageshow); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(false); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.viewportHeight).toBeNull(); + }); + + input.remove(); + }); + + it("FN-6362: keeps a genuinely-open restored viewport open", async () => { + const { mockVV } = setupMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + const input = document.createElement("textarea"); + document.body.appendChild(input); + + const { result } = renderHook(() => useMobileKeyboard()); + + input.focus(); + Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true }); + + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(true); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.viewportHeight).toBe(520); + }); + + input.remove(); + }); + + it("FN-6362: resets Android-style shrink metrics on restore without introducing offset drift", async () => { + const { listeners, mockVV } = setupMobileVisualViewport({ + innerHeight: 800, + vvHeight: 800, + }); + + const input = document.createElement("textarea"); + document.body.appendChild(input); + + const { result } = renderHook(() => useMobileKeyboard()); + + input.focus(); + Object.defineProperty(mockVV, "height", { value: 500, writable: true, configurable: true }); + Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(true); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.viewportHeight).toBe(500); + }); + + Object.defineProperty(mockVV, "height", { value: 800, writable: true, configurable: true }); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(false); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.viewportHeight).toBeNull(); + }); + + input.remove(); + }); + // FN-3290 regression: focusout must reset keyboard state when input blurs describe("FN-3290: focusout resets keyboard state", () => { it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => { diff --git a/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts index 7e915bbc0a..209c1458d9 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileScrollLock.test.ts @@ -1,6 +1,11 @@ import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { _resetLockState, useMobileScrollLock } from "../useMobileScrollLock"; +import { + _resetLockState, + useMobileKeyboardViewportLock, + useMobileScrollLock, + useMobileViewportRestoreReset, +} from "../useMobileScrollLock"; describe("useMobileScrollLock", () => { let savedInnerWidth: number; @@ -20,6 +25,7 @@ describe("useMobileScrollLock", () => { scrollSpy = vi.fn(); window.scrollTo = scrollSpy as unknown as typeof window.scrollTo; Object.defineProperty(window, "scrollY", { value: 0, writable: true, configurable: true }); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); }); afterEach(() => { @@ -59,6 +65,119 @@ describe("useMobileScrollLock", () => { Object.defineProperty(window, "innerWidth", { value: 1280, writable: true, configurable: true }); } + function setVisibilityState(value: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { value, configurable: true }); + } + + it("snaps stale iOS document scroll to top on visibilitychange restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + setVisibilityState("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + }); + + it("snaps stale iOS document scroll to top on pageshow restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + }); + + it("does not reset document scroll on Android restore", () => { + makeAndroid(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not reset document scroll on desktop restore", () => { + makeDesktop(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not reset document scroll on visibilitychange hidden", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + setVisibilityState("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not fight an active fullscreen mobile scroll lock on restore", () => { + makeMobile(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileScrollLock(true)); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("does not fight an active keyboard viewport lock on restore", () => { + makeMobile(); + renderHook(() => useMobileKeyboardViewportLock(true)); + scrollSpy.mockClear(); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("is idempotent when already aligned on restore", () => { + makeMobile(); + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + + expect(scrollSpy).not.toHaveBeenCalled(); + expect(document.body.style.position).toBe(""); + expect(document.body.style.top).toBe(""); + }); + + it("clears orphaned body offset styles without a live lock", () => { + makeMobile(); + document.body.style.position = "fixed"; + document.body.style.top = "-120px"; + document.body.style.left = "0"; + document.body.style.right = "0"; + document.body.style.width = "100%"; + renderHook(() => useMobileViewportRestoreReset(true)); + + document.dispatchEvent(new Event("visibilitychange")); + + expect(document.body.style.position).toBe(""); + expect(document.body.style.top).toBe(""); + expect(document.body.style.left).toBe(""); + expect(document.body.style.right).toBe(""); + expect(document.body.style.width).toBe(""); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + it("pins body with position:fixed and overflow:hidden on mobile when enabled", () => { makeMobile(); Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); diff --git a/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx new file mode 100644 index 0000000000..902b06eb28 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx @@ -0,0 +1,218 @@ +import { render, screen } from "@testing-library/react"; +import { useRef } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useModalResizePersist } from "../useModalResizePersist"; + +const STORAGE_KEY = "fusion:test-modal-size"; + +type ResizeObserverCallback = ConstructorParameters<typeof ResizeObserver>[0]; + +const resizeObserverCallbacks = new Set<ResizeObserverCallback>(); + +class MockResizeObserver implements ResizeObserver { + readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObserverCallbacks.add(callback); + } + + observe = vi.fn(); + unobserve = vi.fn(); + + disconnect = vi.fn(() => { + resizeObserverCallbacks.delete(this.callback); + }); +} + +function setViewport(width: number, height = 800): void { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { width, height }, + }); + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes("max-width: 768px") + ? width <= 768 + : query.includes("max-height: 480px") + ? height <= 480 + : query.includes("min-width: 769px") && query.includes("max-width: 1024px") + ? width >= 769 && width <= 1024 + : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; +} + +function dispatchPointerEvent( + target: EventTarget, + type: string, + init: { clientX: number; clientY: number; pointerId?: number; pointerType?: string }, +): void { + const event = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent; + Object.defineProperties(event, { + clientX: { value: init.clientX }, + clientY: { value: init.clientY }, + pointerId: { value: init.pointerId ?? 1 }, + pointerType: { value: init.pointerType ?? "touch" }, + }); + target.dispatchEvent(event); +} + +function installModalGeometry(node: HTMLElement, width = 500, height = 400): void { + Object.defineProperty(node, "offsetWidth", { + configurable: true, + get: () => Number.parseFloat(node.style.width) || width, + }); + Object.defineProperty(node, "offsetHeight", { + configurable: true, + get: () => Number.parseFloat(node.style.height) || height, + }); + node.getBoundingClientRect = vi.fn(() => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: node.offsetWidth, + bottom: node.offsetHeight, + width: node.offsetWidth, + height: node.offsetHeight, + toJSON: () => ({}), + })); +} + +function triggerResizeObservers(): void { + for (const callback of resizeObserverCallbacks) { + callback([], {} as ResizeObserver); + } +} + +function Harness({ + initialHeight, + initialWidth, + isOpen = true, + storageKey = STORAGE_KEY, +}: { + initialHeight?: string; + initialWidth?: string; + isOpen?: boolean; + storageKey?: string; +}) { + const modalRef = useRef<HTMLDivElement | null>(null); + useModalResizePersist(modalRef, isOpen, storageKey); + + return ( + <div + data-testid="modal" + ref={modalRef} + className="modal" + style={{ width: initialWidth, height: initialHeight }} + /> + ); +} + +describe("useModalResizePersist", () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + resizeObserverCallbacks.clear(); + vi.stubGlobal("ResizeObserver", MockResizeObserver); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.style.userSelect = ""; + }); + + it("injects a touch-capable resize grip on tablet and persists dragged size", () => { + setViewport(900); + render(<Harness />); + + const modal = screen.getByTestId("modal"); + installModalGeometry(modal); + + const grip = modal.querySelector(".modal-resize-grip") as HTMLElement; + expect(grip).toBeTruthy(); + + expect(grip).toHaveAttribute("role", "separator"); + expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner"); + + dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20, pointerType: "touch" }); + dispatchPointerEvent(document, "pointermove", { clientX: 70, clientY: 65, pointerType: "touch" }); + dispatchPointerEvent(document, "pointerup", { clientX: 70, clientY: 65, pointerType: "touch" }); + + expect(modal.style.width).toBe("560px"); + expect(modal.style.height).toBe("445px"); + + vi.advanceTimersByTime(200); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")) + .toEqual({ width: 560, height: 445 }); + }); + + it("keeps desktop grip and native ResizeObserver persistence/restore behavior", () => { + setViewport(1280); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 })); + + render(<Harness />); + const modal = screen.getByTestId("modal"); + installModalGeometry(modal); + + expect(modal.querySelector(".modal-resize-grip")).toBeTruthy(); + expect(modal.style.width).toBe("610px"); + expect(modal.style.height).toBe("480px"); + + modal.style.width = "640px"; + modal.style.height = "500px"; + triggerResizeObservers(); + vi.advanceTimersByTime(200); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")) + .toEqual({ width: 640, height: 500 }); + }); + + it("clears inline size and does not inject a grip on mobile", () => { + setViewport(700); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 })); + + render(<Harness initialWidth="610px" initialHeight="480px" />); + const mobileModal = screen.getByTestId("modal"); + + expect(mobileModal.querySelector(".modal-resize-grip")).toBeNull(); + expect(mobileModal.style.width).toBe(""); + expect(mobileModal.style.height).toBe(""); + }); + + it("removes the grip and drag listeners when closed or unmounted", () => { + setViewport(900); + const removeSpy = vi.spyOn(document, "removeEventListener"); + const { rerender, unmount } = render(<Harness isOpen />); + + const modal = screen.getByTestId("modal"); + installModalGeometry(modal); + const grip = modal.querySelector(".modal-resize-grip") as HTMLElement; + expect(grip).toBeTruthy(); + + dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20 }); + rerender(<Harness isOpen={false} />); + + expect(modal.querySelector(".modal-resize-grip")).toBeNull(); + expect(removeSpy).toHaveBeenCalledWith("pointermove", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("pointerup", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("pointercancel", expect.any(Function)); + + rerender(<Harness isOpen />); + expect(modal.querySelector(".modal-resize-grip")).toBeTruthy(); + unmount(); + expect(modal.querySelector(".modal-resize-grip")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index 157799e6fd..50143bd1a7 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -3,6 +3,39 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getViewportMode, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode"; const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; +const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); + +function stubScreen(width: number, height: number) { + Object.defineProperty(window, "screen", { configurable: true, value: { width, height } }); +} + +function stubMissingScreen() { + Object.defineProperty(window, "screen", { configurable: true, value: undefined }); +} + +function installViewportMedia(options: { width: boolean; height: boolean; tablet: boolean }) { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: + query === MOBILE_MEDIA_QUERY + ? options.width || options.height + : query === MOBILE_WIDTH_MEDIA_QUERY + ? options.width + : query === MOBILE_HEIGHT_MEDIA_QUERY + ? options.height + : query === TABLET_MEDIA_QUERY + ? options.tablet + : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); +} type TestMediaQueryList = MediaQueryList & { setMatches: (matches: boolean) => void; @@ -13,6 +46,8 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) const listeners = new Map<string, Set<() => void>>(); const matches = new Map<string, boolean>([ [MOBILE_MEDIA_QUERY, initial.mobile], + [MOBILE_WIDTH_MEDIA_QUERY, initial.mobile], + [MOBILE_HEIGHT_MEDIA_QUERY, false], [TABLET_MEDIA_QUERY, initial.tablet], ]); const queries = new Map<string, TestMediaQueryList>(); @@ -40,6 +75,9 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) dispatchEvent: vi.fn(() => true), setMatches: (nextMatches: boolean) => { matches.set(query, nextMatches); + if (query === MOBILE_MEDIA_QUERY) { + matches.set(MOBILE_WIDTH_MEDIA_QUERY, nextMatches); + } }, dispatchChange: () => { for (const listener of [...queryListeners]) listener(); @@ -66,16 +104,20 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) describe("useViewportMode", () => { afterEach(() => { vi.unstubAllGlobals(); + if (originalScreenDescriptor) { + Object.defineProperty(window, "screen", originalScreenDescriptor); + } }); it("treats short landscape phones as mobile", () => { + stubScreen(844, 390); vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ matches: - query === MOBILE_MEDIA_QUERY + query === MOBILE_MEDIA_QUERY || query === MOBILE_HEIGHT_MEDIA_QUERY ? true - : query === "(min-width: 769px) and (max-width: 1024px)" + : query === MOBILE_WIDTH_MEDIA_QUERY || query === "(min-width: 769px) and (max-width: 1024px)" ? false : false, media: query, @@ -89,6 +131,50 @@ describe("useViewportMode", () => { expect(renderHook(() => useViewportMode()).result.current).toBe("mobile"); }); + it("keeps tablet mode when only the short-height clause matches on a tablet-class screen", () => { + stubScreen(1024, 768); + installViewportMedia({ width: false, height: true, tablet: true }); + + expect(getViewportMode()).toBe("tablet"); + expect(renderHook(() => useViewportMode()).result.current).toBe("tablet"); + }); + + it("keeps desktop mode when only the short-height clause matches on a desktop-class screen", () => { + stubScreen(1920, 1080); + installViewportMedia({ width: false, height: true, tablet: false }); + + expect(getViewportMode()).toBe("desktop"); + expect(renderHook(() => useViewportMode()).result.current).toBe("desktop"); + }); + + it("keeps mobile portrait mode from width regardless of height", () => { + stubScreen(390, 844); + installViewportMedia({ width: true, height: false, tablet: false }); + + expect(getViewportMode()).toBe("mobile"); + expect(renderHook(() => useViewportMode()).result.current).toBe("mobile"); + }); + + it("falls back to width-only mobile detection when screen data is unavailable", () => { + stubMissingScreen(); + installViewportMedia({ width: false, height: true, tablet: true }); + expect(() => getViewportMode()).not.toThrow(); + expect(getViewportMode()).toBe("tablet"); + + installViewportMedia({ width: true, height: false, tablet: false }); + expect(getViewportMode()).toBe("mobile"); + }); + + it("falls back to width-only mobile detection when screen dimensions are zero", () => { + stubScreen(0, 0); + installViewportMedia({ width: false, height: true, tablet: false }); + expect(() => getViewportMode()).not.toThrow(); + expect(getViewportMode()).toBe("desktop"); + + installViewportMedia({ width: true, height: true, tablet: false }); + expect(getViewportMode()).toBe("mobile"); + }); + it("updates from mobile to tablet when the mobile media query changes", () => { const viewport = createViewportMediaMock({ mobile: true, tablet: false }); const { result } = renderHook(() => useViewportMode()); diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts index fca5d3497c..fe37576aff 100644 --- a/packages/dashboard/app/hooks/useMobileKeyboard.ts +++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts @@ -34,6 +34,10 @@ function updateBaselineViewportHeight(nextHeight: number): void { } } +function resetBaselineViewportHeight(): void { + _baselineViewportHeight = null; +} + function isKeyboardFocusableElement(el: Element | null): boolean { if (!el) return false; if (el instanceof HTMLTextAreaElement) return true; @@ -66,7 +70,18 @@ function hasImpossibleViewportSample(): boolean { return window.visualViewport.offsetTop + window.visualViewport.height > window.innerHeight + IMPOSSIBLE_VIEWPORT_EPSILON_PX; } -function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS): KeyboardMetrics { +function isCollapsedRestoreViewportSample(baselineHeight: number): boolean { + if (typeof window === "undefined" || !window.visualViewport) { + return false; + } + + return window.visualViewport.height >= baselineHeight - IOS_VIEWPORT_SHRINK_MIN_PX; +} + +function getKeyboardMetrics( + previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS, + { bypassImpossibleSampleHold = false }: { bypassImpossibleSampleHold?: boolean } = {}, +): KeyboardMetrics { if (typeof window === "undefined" || !window.visualViewport) { return CLOSED_KEYBOARD_METRICS; } @@ -92,7 +107,7 @@ function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_M // FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard // transition while height is still near the pre-keyboard baseline. Reject // that impossible snapshot and keep the last stable metrics until settle. - if (focused && hasImpossibleViewportSample()) { + if (focused && hasImpossibleViewportSample() && !bypassImpossibleSampleHold) { return previousMetrics; } @@ -139,7 +154,7 @@ function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_M /** Reset cached viewport baseline. Exported for tests only. */ export function _resetInitialViewportHeight(): void { - _baselineViewportHeight = null; + resetBaselineViewportHeight(); } interface UseMobileKeyboardOptions { @@ -267,19 +282,7 @@ export function useMobileKeyboard( stableFrames = 0; rafId = window.requestAnimationFrame(pollFrame); }; - const updateWithTail = () => { - cancelHeadUpdate(); - if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) { - // FN-5155: focusin/page-restore can arrive before visualViewport height - // catches up to the keyboard transition. Defer the head commit one frame - // so the tail/poll can converge instead of publishing the stale sample. - headRafId = window.requestAnimationFrame(() => { - headRafId = null; - update(); - }); - } else { - update(); - } + const scheduleTailUpdates = () => { scheduleUpdate(50); scheduleUpdate(200); scheduleUpdate(500); @@ -288,24 +291,58 @@ export function useMobileKeyboard( startStabilityPoll(); }; + const updateWithTail = () => { + cancelHeadUpdate(); + if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) { + // FN-5155: focusin can arrive before visualViewport height catches up + // to the keyboard transition. Defer the head commit one frame so the + // tail/poll can converge instead of publishing the stale sample. + headRafId = window.requestAnimationFrame(() => { + headRafId = null; + update(); + }); + } else { + update(); + } + scheduleTailUpdates(); + }; + + const resetOnRestore = () => { + cancelHeadUpdate(); + const baselineHeight = getBaselineViewportHeight(); + const collapsedRestoreSample = isCollapsedRestoreViewportSample(baselineHeight); + if (collapsedRestoreSample) { + resetBaselineViewportHeight(); + } + commitMetrics(getKeyboardMetrics(stableMetricsRef.current, { + bypassImpossibleSampleHold: collapsedRestoreSample, + })); + scheduleTailUpdates(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + resetOnRestore(); + }; + updateWithTail(); vv.addEventListener("resize", update); vv.addEventListener("scroll", updateScrollOnly); document.addEventListener("focusin", updateWithTail); document.addEventListener("focusout", update); - // When the user navigates back to this view, force a fresh snapshot - // — without it the hook initializes with stale metrics (keyboard up - // from before, but our state thinks it's closed). - document.addEventListener("visibilitychange", updateWithTail); - window.addEventListener("pageshow", updateWithTail); + // When the user navigates back to this view, force a fresh snapshot that + // can bypass the stale impossible-sample hold if the viewport has already + // returned to its closed baseline while the input retained focus. + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pageshow", resetOnRestore); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", updateScrollOnly); document.removeEventListener("focusin", updateWithTail); document.removeEventListener("focusout", update); - document.removeEventListener("visibilitychange", updateWithTail); - window.removeEventListener("pageshow", updateWithTail); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pageshow", resetOnRestore); for (const timeoutId of timeoutIds) { clearTimeout(timeoutId); } diff --git a/packages/dashboard/app/hooks/useMobileScrollLock.ts b/packages/dashboard/app/hooks/useMobileScrollLock.ts index 929fd44845..bbe961134e 100644 --- a/packages/dashboard/app/hooks/useMobileScrollLock.ts +++ b/packages/dashboard/app/hooks/useMobileScrollLock.ts @@ -120,10 +120,139 @@ function releaseLock(): void { void scrollY; } +export function isAnyMobileScrollLockActive(): boolean { + return lockCount > 0 || kbLockCount > 0; +} + +function clearOrphanedBodyOffset(): void { + if (savedStyles !== null || kbSavedStyles !== null) return; + const body = document.body; + if (body.style.position === "fixed") { + body.style.position = ""; + } + if (body.style.top) { + body.style.top = ""; + } + if (body.style.left === "0px") { + body.style.left = ""; + } + if (body.style.right === "0px") { + body.style.right = ""; + } + if (body.style.width === "100%") { + body.style.width = ""; + } +} + +function resetStaleDocumentScrollOnRestore(): void { + if (isAnyMobileScrollLockActive()) return; + clearOrphanedBodyOffset(); + if (window.scrollY > 0) { + window.scrollTo(0, 0); + } +} + /** Test-only: reset the module-level lock state. */ export function _resetLockState(): void { lockCount = 0; savedStyles = null; + kbLockCount = 0; + kbSavedStyles = null; +} + +// --- Keyboard viewport lock (non-blurring variant) ------------------------- +// +// The `position: fixed` lock above is correct for fullscreen overlays whose +// input is focused AFTER the lock is applied (modals). It is WRONG for the +// inline chat composer: there the input is focused FIRST (the tap raises the +// keyboard), and pinning `body { position: fixed }` a beat later — once +// `keyboardOpen` flips true — makes iOS Safari blur the focused textarea and +// collapse the keyboard the instant it opens (no visible jump, because the +// dashboard's base layout is already at scrollY 0). +// +// This variant mirrors the QuickChat overlay's proven approach: lock +// `overflow: hidden` on <html>/<body> and snap scroll to the top, WITHOUT +// touching `position`. No position change → iOS keeps the input focused, so +// the keyboard stays up. Independent ref-count from the modal lock so the two +// never interfere. +let kbLockCount = 0; +let kbSavedStyles: { + htmlOverflow: string; + bodyOverflow: string; +} | null = null; + +function applyKeyboardLock(): void { + if (typeof window === "undefined") return; + if (kbLockCount > 0) { + kbLockCount += 1; + return; + } + const html = document.documentElement; + const body = document.body; + kbSavedStyles = { + htmlOverflow: html.style.overflow, + bodyOverflow: body.style.overflow, + }; + window.scrollTo(0, 0); + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + kbLockCount = 1; +} + +function releaseKeyboardLock(): void { + if (typeof window === "undefined") return; + if (kbLockCount === 0) return; + kbLockCount -= 1; + if (kbLockCount > 0 || !kbSavedStyles) return; + document.documentElement.style.overflow = kbSavedStyles.htmlOverflow; + document.body.style.overflow = kbSavedStyles.bodyOverflow; + kbSavedStyles = null; + window.scrollTo(0, 0); +} + +/** + * Pin the mobile viewport while the soft keyboard is up for an INLINE + * (non-overlay) focused input — chat composer, inline edits. Uses an + * overflow-only lock that does not change `position`, so iOS does not blur + * the already-focused input. iOS-only; no-op on desktop/Android. + */ +export function useMobileKeyboardViewportLock(enabled: boolean): void { + useEffect(() => { + if (!enabled || !isMobileDevice() || !isIOS()) return; + applyKeyboardLock(); + return () => { + releaseKeyboardLock(); + }; + }, [enabled]); +} + +/** + * Snap stale iOS document scroll/body offset back to the dashboard's resting + * position when the page is restored from background or bfcache. Active locks + * own their own restore path, so this only runs when the page is otherwise + * unlocked. + */ +export function useMobileViewportRestoreReset(enabled: boolean): void { + useEffect(() => { + if (!enabled || !isMobileDevice() || !isIOS()) return; + + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + resetStaleDocumentScrollOnRestore(); + }; + + const handlePageShow = () => { + resetStaleDocumentScrollOnRestore(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pageshow", handlePageShow); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pageshow", handlePageShow); + }; + }, [enabled]); } /** diff --git a/packages/dashboard/app/hooks/useModalResizePersist.ts b/packages/dashboard/app/hooks/useModalResizePersist.ts index ed6ed55cb5..5417bcd872 100644 --- a/packages/dashboard/app/hooks/useModalResizePersist.ts +++ b/packages/dashboard/app/hooks/useModalResizePersist.ts @@ -1,10 +1,32 @@ import { useEffect, type RefObject } from "react"; +import { isMobileViewport } from "./useViewportMode"; + interface PersistedSize { width?: number; height?: number; } +const RESIZE_GRIP_CLASS = "modal-resize-grip"; +const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner"; + +function readPersistableSize(node: HTMLElement): PersistedSize { + const styleWidth = Number.parseFloat(node.style.width); + const styleHeight = Number.parseFloat(node.style.height); + return { + width: node.offsetWidth > 0 + ? node.offsetWidth + : Number.isFinite(styleWidth) + ? styleWidth + : undefined, + height: node.offsetHeight > 0 + ? node.offsetHeight + : Number.isFinite(styleHeight) + ? styleHeight + : undefined, + }; +} + /** * Persist a resizable modal's user-chosen dimensions across opens. * @@ -37,13 +59,12 @@ export function useModalResizePersist( // would override the mobile CSS and leave the modal stuck at a partial // height. Skip restoration; also clear any width/height left over from // a prior desktop render of the same modal instance. - const isMobile = - typeof window !== "undefined" && - ("ontouchstart" in window || navigator.maxTouchPoints > 0) && - window.innerWidth <= 768; - if (isMobile) { + const existingGrip = node.querySelector(`:scope > .${RESIZE_GRIP_CLASS}`); + + if (isMobileViewport()) { node.style.removeProperty("width"); node.style.removeProperty("height"); + existingGrip?.remove(); return; } @@ -59,34 +80,109 @@ export function useModalResizePersist( // ignore corrupted entry } - // jsdom (and very old browsers) lacks ResizeObserver — skip persistence - // gracefully rather than throw. Restoration above still ran. - if (typeof ResizeObserver === "undefined") return; - - let lastSavedW = node.offsetWidth; - let lastSavedH = node.offsetHeight; let saveTimer: ReturnType<typeof setTimeout> | null = null; - - const observer = new ResizeObserver(() => { - const w = node.offsetWidth; - const h = node.offsetHeight; - if (w === lastSavedW && h === lastSavedH) return; - lastSavedW = w; - lastSavedH = h; + const scheduleSave = () => { + const { width, height } = readPersistableSize(node); + if (typeof width !== "number" || typeof height !== "number") return; // Debounce so we don't spam localStorage during the drag. if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { try { - localStorage.setItem(storageKey, JSON.stringify({ width: w, height: h })); + localStorage.setItem(storageKey, JSON.stringify({ width, height })); } catch { // quota / private mode — best-effort } }, 200); - }); + }; + + let lastSavedW = node.offsetWidth; + let lastSavedH = node.offsetHeight; + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + const w = node.offsetWidth; + const h = node.offsetHeight; + if (w === lastSavedW && h === lastSavedH) return; + lastSavedW = w; + lastSavedH = h; + scheduleSave(); + }); + + observer?.observe(node); + + const grip = document.createElement("div"); + grip.className = RESIZE_GRIP_CLASS; + grip.setAttribute("role", "separator"); + grip.setAttribute("aria-label", RESIZE_GRIP_LABEL); + grip.dataset.resizeDirection = "se"; + existingGrip?.remove(); + node.appendChild(grip); + + let cleanupActiveDrag: (() => void) | null = null; + + const onPointerDown = (event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (typeof grip.setPointerCapture === "function") { + grip.setPointerCapture(event.pointerId); + } + + const startRect = node.getBoundingClientRect(); + const startWidth = startRect.width || + node.offsetWidth || + Number.parseFloat(node.style.width) || + 0; + const startHeight = startRect.height || + node.offsetHeight || + Number.parseFloat(node.style.height) || + 0; + const startX = event.clientX; + const startY = event.clientY; + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + const onPointerMove = (moveEvent: PointerEvent) => { + moveEvent.preventDefault(); + const nextWidth = startWidth + moveEvent.clientX - startX; + const nextHeight = startHeight + moveEvent.clientY - startY; + if (nextWidth > 0) node.style.width = `${nextWidth}px`; + if (nextHeight > 0) node.style.height = `${nextHeight}px`; + scheduleSave(); + }; + + const endDrag = (upEvent: PointerEvent) => { + if (typeof grip.releasePointerCapture === "function") { + grip.releasePointerCapture(upEvent.pointerId); + } + document.body.style.userSelect = previousUserSelect; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", endDrag); + document.removeEventListener("pointercancel", endDrag); + scheduleSave(); + cleanupActiveDrag = null; + }; + + cleanupActiveDrag = () => { + document.body.style.userSelect = previousUserSelect; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", endDrag); + document.removeEventListener("pointercancel", endDrag); + }; + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", endDrag); + document.addEventListener("pointercancel", endDrag); + }; + + grip.addEventListener("pointerdown", onPointerDown); - observer.observe(node); return () => { - observer.disconnect(); + cleanupActiveDrag?.(); + grip.removeEventListener("pointerdown", onPointerDown); + grip.remove(); + observer?.disconnect(); if (saveTimer) clearTimeout(saveTimer); }; }, [ref, isOpen, storageKey]); diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index ed2f81b4a4..a192996c9e 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -5,12 +5,34 @@ export type ViewportMode = "mobile" | "tablet" | "desktop"; // `(max-height: 480px)` catches phones held in landscape, which can exceed // 768 CSS px wide but stay short. Without it, landscape phones fall out of // mobile mode and lose the bottom nav bar + get the desktop horizontally- -// scrollable board. +// scrollable board. Runtime mode resolution additionally gates this height +// clause on phone-class physical screen size so virtual keyboards cannot flip +// tablet/desktop layouts into mobile mode by shrinking the CSS viewport height. export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)"; +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; + +// The virtual keyboard shrinks the CSS/visual viewport height (matching +// `(max-height: 480px)`) but never the device's physical screen. Only treat a +// short viewport as a landscape phone when the smaller physical screen edge is +// phone-class. Fail safe (return false) when screen data is unavailable. +function isPhoneClassScreen(): boolean { + if (typeof window === "undefined" || !window.screen) return false; + const { width, height } = window.screen; + if (!width || !height) return false; + return Math.min(width, height) <= 480; +} + +export function isMobileViewport(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches || + (window.matchMedia(MOBILE_HEIGHT_MEDIA_QUERY).matches && isPhoneClassScreen()); +} + export function getViewportMode(): ViewportMode { if (typeof window === "undefined") return "desktop"; - if (window.matchMedia(MOBILE_MEDIA_QUERY).matches) return "mobile"; + if (isMobileViewport()) return "mobile"; if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches) return "tablet"; return "desktop"; } diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 0f5f9beca3..5e1f3bc1ea 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -996,6 +996,7 @@ body { padding: var(--board-padding); overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; scroll-padding-inline: 50%; scrollbar-color: var(--border) transparent; @@ -1143,6 +1144,7 @@ body { } .modal { + position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -1152,6 +1154,37 @@ body { display: flex; flex-direction: column; } + +.modal-resize-grip { + position: absolute; + right: 0; + bottom: 0; + z-index: 2; + width: var(--space-lg); + height: var(--space-lg); + cursor: se-resize; + touch-action: none; + background: transparent; +} + +.modal-resize-grip::after { + content: ""; + position: absolute; + right: var(--space-xs); + bottom: var(--space-xs); + width: var(--space-md); + height: var(--space-md); + border-right: var(--btn-border-width) solid var(--border); + border-bottom: var(--btn-border-width) solid var(--border); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.modal-resize-grip:hover::after, +.modal-resize-grip:focus-visible::after, +.modal-resize-grip:active::after { + opacity: 1; +} .modal-lg { width: 640px; } @@ -3256,32 +3289,51 @@ input[type="range"]:focus-visible { font-size: 16px; } - /* Prevent ancestor elements from producing a second horizontal scrollbar */ + /* Lock the document to the visual viewport's inline axis. The board is the + only always-present horizontal scroller on mobile; root/header/footer + gestures must stay vertical-only so iOS/Android cannot park the whole + page off-axis and expose the offscreen-right void. */ html, body { + width: 100%; + max-width: 100%; overflow: hidden; + overflow-x: hidden; + overflow-y: hidden; /* Stop Chrome's overscroll/rubber-band on mobile — without this the user can pull the page up to expose empty space above the dashboard. */ overscroll-behavior: none; + overscroll-behavior-x: none; + overscroll-behavior-y: none; + touch-action: pan-y; } /* Disable pinch-zoom globally on mobile. Android Chrome ignores `user-scalable=no` for a11y, and the kanban board's horizontally- scrollable layout interacts badly with zoom-out (exposes the offscreen-right area). `touch-action` is not inherited — it applies - to the target element only — so we have to set `pan-x pan-y` - (keep scroll panning, block pinch-zoom) on every element. */ + to the target element only — so default every element to vertical page + panning, then opt known horizontal scrollers back into pan-x below. */ * { - touch-action: pan-x pan-y; + touch-action: pan-y; } #root { + width: 100%; + max-width: 100%; + min-width: 0; overflow: hidden; + overflow-x: hidden; + overflow-y: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; } - /* Prevent horizontal overflow from wide content */ + /* Prevent horizontal overflow from wide content without sizing descendants + to the layout viewport when the visual viewport is narrower/drifted. */ * { - max-width: 100vw; + max-width: 100%; + max-inline-size: 100%; } pre, @@ -3289,6 +3341,8 @@ input[type="range"]:focus-visible { .code-block { overflow-x: auto; max-width: 100%; + max-inline-size: 100%; + touch-action: pan-x pan-y; word-break: break-all; word-break: break-word; } @@ -3307,6 +3361,8 @@ input[type="range"]:focus-visible { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; + max-inline-size: 100%; + touch-action: pan-x pan-y; } /* Global touch target enforcement on mobile */ @@ -3341,6 +3397,8 @@ input[type="range"]:focus-visible { display: flex; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; -webkit-overflow-scrolling: touch; scroll-snap-type: x proximity; overflow-anchor: none; @@ -3351,6 +3409,8 @@ input[type="range"]:focus-visible { padding-bottom: var(--space-md); gap: var(--space-md); width: 100%; + max-width: 100%; + max-inline-size: 100%; } .board::-webkit-scrollbar { @@ -3378,6 +3438,11 @@ input[type="range"]:focus-visible { .workflow-output-modal-overlay { padding-top: 0; align-items: stretch; + inline-size: 100%; + max-inline-size: 100%; + overflow-x: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; } .modal:not(.confirm-dialog), @@ -3386,6 +3451,9 @@ input[type="range"]:focus-visible { .gm-modal { width: 100%; max-width: 100%; + inline-size: 100%; + max-inline-size: 100%; + min-width: 0; height: 100vh; height: 100dvh; max-height: 100vh; @@ -3398,6 +3466,10 @@ input[type="range"]:focus-visible { padding-bottom: env(safe-area-inset-bottom, 0px); } + .modal-resize-grip { + display: none; + } + /* Settings modal: use the section picker as the only mobile navigation */ .settings-layout { flex-direction: column; diff --git a/packages/dashboard/app/utils/__tests__/mobileBarKeyboardFlags.test.ts b/packages/dashboard/app/utils/__tests__/mobileBarKeyboardFlags.test.ts index f64509c2b8..b360016c63 100644 --- a/packages/dashboard/app/utils/__tests__/mobileBarKeyboardFlags.test.ts +++ b/packages/dashboard/app/utils/__tests__/mobileBarKeyboardFlags.test.ts @@ -7,6 +7,7 @@ describe("computeMobileBarKeyboardFlags", () => { isMobile: true, keyboardOpen: true, anyModalOpen: false, + overlayOpen: false, isIOS: false, }); @@ -15,11 +16,12 @@ describe("computeMobileBarKeyboardFlags", () => { expect(flags.navKeyboardOpen).toBe(true); }); - it("hides and collapses footer on iOS when keyboard is open and no modal is open", () => { + it("hides and collapses footer on iOS when keyboard is open and no overlay is open", () => { const flags = computeMobileBarKeyboardFlags({ isMobile: true, keyboardOpen: true, anyModalOpen: false, + overlayOpen: false, isIOS: true, }); @@ -33,6 +35,7 @@ describe("computeMobileBarKeyboardFlags", () => { isMobile: true, keyboardOpen: true, anyModalOpen: true, + overlayOpen: false, isIOS: true, }); @@ -46,6 +49,7 @@ describe("computeMobileBarKeyboardFlags", () => { isMobile: true, keyboardOpen: false, anyModalOpen: false, + overlayOpen: false, isIOS: true, }); @@ -61,6 +65,7 @@ describe("computeMobileBarKeyboardFlags", () => { isMobile: false, keyboardOpen: true, anyModalOpen: false, + overlayOpen: false, isIOS: true, }); @@ -70,4 +75,54 @@ describe("computeMobileBarKeyboardFlags", () => { footerKeyboardOpen: false, }); }); + + it("keeps the board footer visible on iOS when a fullscreen overlay owns the keyboard", () => { + const flags = computeMobileBarKeyboardFlags({ + isMobile: true, + keyboardOpen: true, + anyModalOpen: false, + overlayOpen: true, + isIOS: true, + }); + + expect(flags.footerHidden).toBe(false); + expect(flags.navKeyboardOpen).toBe(true); + expect(flags.footerKeyboardOpen).toBe(true); + }); + + it("keeps Android board-layout behavior unchanged when a fullscreen overlay owns the keyboard", () => { + const flags = computeMobileBarKeyboardFlags({ + isMobile: true, + keyboardOpen: true, + anyModalOpen: false, + overlayOpen: true, + isIOS: false, + }); + + expect(flags.footerHidden).toBe(false); + expect(flags.navKeyboardOpen).toBe(true); + expect(flags.footerKeyboardOpen).toBe(false); + }); + + it("suppresses the original iOS board-shift trigger only when the Quick Chat overlay flag is set", () => { + const originalBoardShiftTrigger = computeMobileBarKeyboardFlags({ + isMobile: true, + keyboardOpen: true, + anyModalOpen: false, + overlayOpen: false, + isIOS: true, + }); + const quickChatOverlayKeyboard = computeMobileBarKeyboardFlags({ + isMobile: true, + keyboardOpen: true, + anyModalOpen: false, + overlayOpen: true, + isIOS: true, + }); + + expect(originalBoardShiftTrigger.footerHidden).toBe(true); + expect(quickChatOverlayKeyboard.footerHidden).toBe(false); + expect(quickChatOverlayKeyboard.navKeyboardOpen).toBe(true); + expect(quickChatOverlayKeyboard.footerKeyboardOpen).toBe(true); + }); }); diff --git a/packages/dashboard/app/utils/chatInputAutosize.ts b/packages/dashboard/app/utils/chatInputAutosize.ts new file mode 100644 index 0000000000..dbdc4ce0c2 --- /dev/null +++ b/packages/dashboard/app/utils/chatInputAutosize.ts @@ -0,0 +1,18 @@ +// Keep a generous cap so pasted multi-paragraph text stays visible while +// still preventing the composer from overtaking the message pane on short viewports. +export const CHAT_INPUT_MAX_HEIGHT_PX = 640; +export const TABLET_INPUT_MAX_HEIGHT_PX = 200; + +export function resolveChatInputOverflowY( + scrollHeight: number, + maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX, +): "auto" | "hidden" { + return scrollHeight > maxHeight ? "auto" : "hidden"; +} + +export function clampChatInputHeight(scrollHeight: number, maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX): number { + // Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height, + // so a 0-scrollHeight measurement (e.g. before layout) still yields a + // sensible inline height instead of collapsing the composer to 0. + return Math.max(40, Math.min(scrollHeight, maxHeight)); +} diff --git a/packages/dashboard/app/utils/mobileBarKeyboardFlags.ts b/packages/dashboard/app/utils/mobileBarKeyboardFlags.ts index f83fc39636..bc0535f015 100644 --- a/packages/dashboard/app/utils/mobileBarKeyboardFlags.ts +++ b/packages/dashboard/app/utils/mobileBarKeyboardFlags.ts @@ -2,6 +2,8 @@ export interface MobileBarKeyboardFlagsInput { isMobile: boolean; keyboardOpen: boolean; anyModalOpen: boolean; + /** True when a fullscreen mobile overlay owns keyboard/viewport layout. */ + overlayOpen: boolean; isIOS: boolean; } @@ -17,14 +19,20 @@ export interface MobileBarKeyboardFlags { * position remains correct above the mobile nav. Only iOS should apply the * footer keyboard-collapse class (`bottom: 0`) used to let the keyboard cover * bars when visualViewport shifts independently. + * + * Fullscreen mobile overlays (for example Quick Chat's sheet) own their own + * visual viewport handling. Treat them like modals for board-layout padding so + * overlay-local keyboards never shift the underlying board. */ export function computeMobileBarKeyboardFlags({ isMobile, keyboardOpen, anyModalOpen, + overlayOpen, isIOS, }: MobileBarKeyboardFlagsInput): MobileBarKeyboardFlags { - const footerHidden = isMobile && keyboardOpen && !anyModalOpen && isIOS; + const boardLayoutSuppressed = anyModalOpen || overlayOpen; + const footerHidden = isMobile && keyboardOpen && !boardLayoutSuppressed && isIOS; const navKeyboardOpen = isMobile && keyboardOpen; const footerKeyboardOpen = navKeyboardOpen && isIOS; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index ef13ba6565..b2e61a469b 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/dashboard", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion dashboard: React UI and HTTP API server for monitoring and controlling the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", @@ -62,8 +62,8 @@ "dev": "pnpm build && pnpm typecheck && pnpm dev:serve", "dev:serve": "vite dev", "pretest": "node ../../scripts/ensure-test-artifacts.mjs", - "test": "pnpm run test:quality:app && pnpm run test:quality:api", - "test:quality:app": "pnpm run test:quality:app:foundation-api && pnpm run test:quality:app:foundation-ui && pnpm run test:quality:app:foundation-hooks-utils && pnpm run test:quality:app:components-a && pnpm run test:quality:app:components-b && pnpm run test:quality:app:app && pnpm run test:quality:app:chat && pnpm run test:quality:app:settings && pnpm run test:quality:app:backfill", + "test": "node scripts/run-quality-tests.mjs", + "test:quality:app": "node scripts/run-quality-tests.mjs --group app", "test:quality:app:foundation-api": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-foundation-api --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", "test:quality:app:foundation-ui": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-foundation-ui --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", "test:quality:app:foundation-hooks-utils": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-foundation-hooks-utils --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", @@ -77,15 +77,17 @@ "test:quality:app:backfill-2": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-backfill --silent=passed-only --reporter=dot --shard=2/4", "test:quality:app:backfill-3": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-backfill --silent=passed-only --reporter=dot --shard=3/4", "test:quality:app:backfill-4": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-app-quality-backfill --silent=passed-only --reporter=dot --shard=4/4", - "test:quality:api": "pnpm run test:quality:api:curated && pnpm run test:quality:api:backfill", - "test:quality:api:curated": "vitest run --project dashboard-api-quality --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", - "test:quality:api:backfill": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=1/2 && node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=2/2", + "test:quality:api": "node scripts/run-quality-tests.mjs --group api", + "test:quality:api:curated": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", + "test:quality:api:backfill": "pnpm run test:quality:api:backfill-1 && pnpm run test:quality:api:backfill-2", "test:app": "vitest run --project dashboard-app --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", "test:api": "vitest run --project dashboard-api --silent=passed-only --reporter=dot", "test:deep": "vitest run --project dashboard-app --project dashboard-api --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'", "test:browser-smoke": "node scripts/browser-layout-smoke.mjs", "test:build": "vitest run --project dashboard-app --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json", + "test:quality:api:backfill-1": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=1/2", + "test:quality:api:backfill-2": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=2/2" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/packages/dashboard/scripts/__tests__/run-quality-tests.test.ts b/packages/dashboard/scripts/__tests__/run-quality-tests.test.ts new file mode 100644 index 0000000000..ef34cc0114 --- /dev/null +++ b/packages/dashboard/scripts/__tests__/run-quality-tests.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +interface QualityLane { + name: string; + group: "app" | "api"; + args: string[]; +} + +interface LaneResult { + lane: QualityLane; + ok: boolean; + code?: number; + signal?: NodeJS.Signals; +} + +interface RunQualityTestsModule { + qualityLanes: QualityLane[]; + resolveConcurrency(env?: Record<string, string | undefined>): number; + runQualityTests(options?: { + group?: "all" | "app" | "api"; + concurrency?: number; + lanes?: QualityLane[]; + runner?: (lane: QualityLane) => Promise<LaneResult>; + }): Promise<{ ok: boolean; failed: LaneResult[]; completed: number; skipped: number }>; +} + +async function loadModule(): Promise<RunQualityTestsModule> { + return (await import("../run-quality-tests.mjs")) as RunQualityTestsModule; +} + +function lane(name: string): QualityLane { + return { name, group: "app", args: ["--heap=6144", "run", "--project", name] }; +} + +describe("dashboard quality orchestrator", () => { + it("clamps dashboard quality concurrency to the safe bound", async () => { + const { resolveConcurrency } = await loadModule(); + + expect(resolveConcurrency({})).toBe(2); + expect(resolveConcurrency({ FUSION_DASHBOARD_TEST_CONCURRENCY: "1" })).toBe(1); + expect(resolveConcurrency({ FUSION_DASHBOARD_TEST_CONCURRENCY: "5" })).toBe(2); + expect(resolveConcurrency({ FUSION_DASHBOARD_TEST_CONCURRENCY: "not-a-number" })).toBe(2); + }); + + it("runs only up to the configured concurrency and does not invoke artifact bootstrap per lane", async () => { + const { runQualityTests } = await loadModule(); + const lanes = [lane("one"), lane("two"), lane("three")]; + const running = new Set<string>(); + let maxRunning = 0; + const launched: string[] = []; + + const result = await runQualityTests({ + lanes, + concurrency: 2, + runner: async (qualityLane) => { + launched.push(qualityLane.name); + expect(qualityLane.args.join(" ")).not.toContain("ensure-test-artifacts"); + running.add(qualityLane.name); + maxRunning = Math.max(maxRunning, running.size); + await Promise.resolve(); + running.delete(qualityLane.name); + return { lane: qualityLane, ok: true }; + }, + }); + + expect(result).toMatchObject({ ok: true, completed: 3, skipped: 0 }); + expect(result.failed).toEqual([]); + expect(launched).toEqual(["one", "two", "three"]); + expect(maxRunning).toBeLessThanOrEqual(2); + }); + + it("stops scheduling new lanes after a failed lane", async () => { + const { runQualityTests } = await loadModule(); + const lanes = [lane("one"), lane("two"), lane("three")]; + const launched: string[] = []; + + const result = await runQualityTests({ + lanes, + concurrency: 1, + runner: async (qualityLane) => { + launched.push(qualityLane.name); + return { lane: qualityLane, ok: qualityLane.name !== "two", code: qualityLane.name === "two" ? 1 : 0 }; + }, + }); + + expect(result.ok).toBe(false); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].lane.name).toBe("two"); + expect(result.skipped).toBe(1); + expect(launched).toEqual(["one", "two"]); + }); + + it("treats signal-terminated lanes as failed", async () => { + const { runQualityTests } = await loadModule(); + const killedLane = lane("killed"); + + const result = await runQualityTests({ + lanes: [killedLane], + concurrency: 2, + runner: async (qualityLane) => ({ lane: qualityLane, ok: false, signal: "SIGKILL" }), + }); + + expect(result.ok).toBe(false); + expect(result.failed).toEqual([{ lane: killedLane, ok: false, signal: "SIGKILL" }]); + expect(result.completed).toBe(1); + expect(result.skipped).toBe(0); + }); +}); diff --git a/packages/dashboard/scripts/run-quality-tests.mjs b/packages/dashboard/scripts/run-quality-tests.mjs new file mode 100644 index 0000000000..6b4c15f445 --- /dev/null +++ b/packages/dashboard/scripts/run-quality-tests.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/* global console, process */ + +import { spawn } from "node:child_process"; +import { URL, fileURLToPath } from "node:url"; + +const HEAP_MB = 6144; +const DEFAULT_CONCURRENCY = 2; +const MAX_CONCURRENCY = 2; +const VITEST_WRAPPER = "scripts/run-vitest-with-heap.mjs"; +const EXCLUDE_BUILD_OUTPUT = ["--exclude", "**/build-output.test.ts"]; + +export const qualityLanes = [ + { + name: "app:foundation-api", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-foundation-api", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:foundation-ui", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-foundation-ui", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:foundation-hooks-utils", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-foundation-hooks-utils", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:components-a", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-components-a", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:components-b", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-components-b", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:app", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-app", "--reporter=default", "--silent=passed-only", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:chat", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-chat", "--reporter=default", "--silent=passed-only", ...EXCLUDE_BUILD_OUTPUT], + }, + { + name: "app:settings", + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-settings", "--reporter=default", "--silent=passed-only", ...EXCLUDE_BUILD_OUTPUT], + }, + ...[1, 2, 3, 4].map((shard) => ({ + name: `app:backfill-${shard}`, + group: "app", + args: ["--heap=6144", "run", "--project", "dashboard-app-quality-backfill", "--silent=passed-only", "--reporter=dot", `--shard=${shard}/4`], + })), + { + name: "api:curated", + group: "api", + args: ["--heap=6144", "run", "--project", "dashboard-api-quality", "--silent=passed-only", "--reporter=dot", ...EXCLUDE_BUILD_OUTPUT], + }, + ...[1, 2].map((shard) => ({ + name: `api:backfill-${shard}`, + group: "api", + args: ["--heap=6144", "run", "--project", "dashboard-api-quality-backfill", "--silent=passed-only", "--reporter=dot", `--shard=${shard}/2`], + })), +]; + +function parsePositiveInt(value) { + if (value === undefined || value === "") return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export function resolveConcurrency(env = process.env) { + const requested = parsePositiveInt(env.FUSION_DASHBOARD_TEST_CONCURRENCY) ?? DEFAULT_CONCURRENCY; + return Math.min(requested, MAX_CONCURRENCY); +} + +function parseArgs(argv) { + let group = "all"; + let list = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--group") { + group = argv[index + 1] ?? ""; + index += 1; + continue; + } + if (arg.startsWith("--group=")) { + group = arg.slice("--group=".length); + continue; + } + if (arg === "--list") { + list = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!["all", "app", "api"].includes(group)) { + throw new Error(`Invalid --group value ${JSON.stringify(group)}; expected all, app, or api`); + } + + return { group, list }; +} + +function selectLanes(group) { + return group === "all" ? qualityLanes : qualityLanes.filter((lane) => lane.group === group); +} + +function formatLaneCommand(lane) { + return `node ${VITEST_WRAPPER} ${lane.args.join(" ")}`; +} + +function runLane(lane) { + return new Promise((resolve) => { + const startedAt = Date.now(); + console.log(`[dashboard-quality] start ${lane.name}: ${formatLaneCommand(lane)}`); + // process-supervisor-allowlist: foreground test orchestrator runs bounded child processes and waits for each to finish + const child = spawn(process.execPath, [VITEST_WRAPPER, ...lane.args], { + cwd: new URL("..", import.meta.url), + stdio: "inherit", + env: process.env, + }); + + child.on("error", (error) => { + const durationSeconds = ((Date.now() - startedAt) / 1000).toFixed(1); + console.error(`[dashboard-quality] ${lane.name} failed to launch after ${durationSeconds}s`); + resolve({ lane, ok: false, error }); + }); + + child.on("close", (code, signal) => { + const durationSeconds = ((Date.now() - startedAt) / 1000).toFixed(1); + if (code === 0 && signal === null) { + console.log(`[dashboard-quality] pass ${lane.name} (${durationSeconds}s)`); + resolve({ lane, ok: true }); + return; + } + const suffix = signal ? `signal ${signal}` : `exit ${code ?? 1}`; + console.error(`[dashboard-quality] fail ${lane.name} (${durationSeconds}s, ${suffix})`); + resolve({ lane, ok: false, code: code ?? 1, signal }); + }); + }); +} + +export async function runQualityTests({ + group = "all", + concurrency = resolveConcurrency(), + lanes = selectLanes(group), + runner = runLane, +} = {}) { + const queue = [...lanes]; + const failed = []; + let running = 0; + let completed = 0; + let stopScheduling = false; + + console.log( + `[dashboard-quality] running ${queue.length} lane(s), group=${group}, concurrency=${concurrency}, heap=${HEAP_MB}MiB per lane`, + ); + + return new Promise((resolve) => { + const schedule = () => { + while (!stopScheduling && running < concurrency && queue.length > 0) { + const lane = queue.shift(); + running += 1; + void runner(lane).then((result) => { + running -= 1; + completed += 1; + if (!result.ok) { + failed.push(result); + stopScheduling = true; + } + if ((queue.length === 0 || stopScheduling) && running === 0) { + resolve({ ok: failed.length === 0, failed, completed, skipped: queue.length }); + return; + } + schedule(); + }); + } + if (queue.length === 0 && running === 0) { + resolve({ ok: failed.length === 0, failed, completed, skipped: 0 }); + } + }; + + schedule(); + }); +} + +async function main() { + const { group, list } = parseArgs(process.argv.slice(2)); + const lanes = selectLanes(group); + + if (list) { + for (const lane of lanes) { + console.log(`${lane.name}\t${formatLaneCommand(lane)}`); + } + return; + } + + const result = await runQualityTests({ group, lanes }); + if (!result.ok) { + console.error(`[dashboard-quality] failed lane(s): ${result.failed.map(({ lane }) => lane.name).join(", ")}`); + if (result.skipped > 0) { + console.error(`[dashboard-quality] skipped ${result.skipped} lane(s) after first failure`); + } + process.exit(1); + } + console.log(`[dashboard-quality] all ${result.completed} lane(s) passed`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts index c7f6d4f9ab..7434deaad8 100644 --- a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts +++ b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts @@ -1,42 +1,97 @@ // @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { globSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { dashboardQualityProjectGlobs } from "../../vitest.config"; const __dirname = dirname(fileURLToPath(import.meta.url)); const dashboardRoot = join(__dirname, "..", ".."); const dashboardPackageJsonPath = join(dashboardRoot, "package.json"); const vitestConfigPath = join(dashboardRoot, "vitest.config.ts"); +const dashboardQualityScriptPath = join(dashboardRoot, "scripts", "run-quality-tests.mjs"); +const qualityParityBaselineFileCount = 746; + +interface QualityLane { + name: string; + group: "app" | "api"; + args: string[]; +} function readDashboardPackageJson(): { scripts: Record<string, string> } { return JSON.parse(readFileSync(dashboardPackageJsonPath, "utf8")); } -describe("dashboard test config guard", () => { - it("keeps the dashboard quality gate split into sequential sub-runs", () => { - const { scripts } = readDashboardPackageJson(); +async function readQualityLanes(): Promise<QualityLane[]> { + const module = (await import(pathToFileURL(dashboardQualityScriptPath).href)) as { qualityLanes: QualityLane[] }; + return module.qualityLanes; +} - expect(scripts.test).toBe("pnpm run test:quality:app && pnpm run test:quality:api"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:foundation-api"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:foundation-ui"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:foundation-hooks-utils"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:components-a"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:components-b"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:app"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:chat"); - expect(scripts["test:quality:app"]).toContain("test:quality:app:settings"); - // The backfill lane (plan U2 / R7) closes the curated-gate hole: every - // app test file that no curated lane enumerates runs here. - expect(scripts["test:quality:app"]).toContain("test:quality:app:backfill"); - // The API gate runs the curated lane AND the backfill lane. - expect(scripts["test:quality:api"]).toContain("test:quality:api:curated"); - expect(scripts["test:quality:api"]).toContain("test:quality:api:backfill"); - expect(scripts["test:quality:app"]).not.toContain("dashboard-app-quality --project dashboard-api-quality"); +function projectNameForLane(lane: QualityLane): string { + const projectFlagIndex = lane.args.indexOf("--project"); + expect(projectFlagIndex).toBeGreaterThanOrEqual(0); + const projectName = lane.args[projectFlagIndex + 1]; + expect(projectName).toBeTruthy(); + return projectName; +} + +function expandDashboardGlobs(patterns: readonly string[]): Set<string> { + return new Set( + patterns.flatMap((pattern) => + globSync(pattern, { cwd: dashboardRoot, nodir: true }).map((file) => + relative(dashboardRoot, join(dashboardRoot, file)), + ), + ), + ); +} + +function expandProjectFiles(projectName: keyof typeof dashboardQualityProjectGlobs): Set<string> { + const project = dashboardQualityProjectGlobs[projectName]; + const included = expandDashboardGlobs(project.include); + const excluded = expandDashboardGlobs(project.exclude); + for (const file of excluded) { + included.delete(file); + } + return included; +} + +describe("dashboard test config guard", () => { + it("routes the dashboard quality gate through the bounded orchestrator", async () => { + const { scripts } = readDashboardPackageJson(); + const qualityLanes = await readQualityLanes(); + + expect(scripts.pretest).toBe("node ../../scripts/ensure-test-artifacts.mjs"); + expect(scripts.test).toBe("node scripts/run-quality-tests.mjs"); + expect(scripts["test:quality:app"]).toBe("node scripts/run-quality-tests.mjs --group app"); + expect(scripts["test:quality:api"]).toBe("node scripts/run-quality-tests.mjs --group api"); + expect(qualityLanes).toHaveLength(15); + expect(qualityLanes.map((lane) => lane.name)).toEqual([ + "app:foundation-api", + "app:foundation-ui", + "app:foundation-hooks-utils", + "app:components-a", + "app:components-b", + "app:app", + "app:chat", + "app:settings", + "app:backfill-1", + "app:backfill-2", + "app:backfill-3", + "app:backfill-4", + "api:curated", + "api:backfill-1", + "api:backfill-2", + ]); + + for (const lane of qualityLanes) { + expect(lane.args[0]).toBe("--heap=6144"); + expect(lane.args).not.toContain("-t"); + expect(lane.args.join(" ")).not.toContain("ensure-test-artifacts"); + } }); - it("pins every app-quality shard to the heap wrapper", () => { + it("pins compatibility lane scripts to the heap wrapper", () => { const { scripts } = readDashboardPackageJson(); for (const key of [ @@ -52,20 +107,24 @@ describe("dashboard test config guard", () => { "test:quality:app:backfill-2", "test:quality:app:backfill-3", "test:quality:app:backfill-4", + "test:quality:api:curated", + "test:quality:api:backfill-1", + "test:quality:api:backfill-2", ]) { expect(scripts[key]).toContain("node scripts/run-vitest-with-heap.mjs --heap=6144"); } }); - it("runs the settings lane unfiltered so no describe block can fall through a -t name filter", () => { - // Plan U2 / R7 structural fix: the settings lane used to be split into six - // `-t` name-filtered sub-runs, which meant a SettingsModal describe block - // matching none of the substrings ran in NO project. The whole - // SettingsModal.test.tsx file fits one heap-6144 lane, so the lane now runs - // the project unfiltered. Guard against a regression back to `-t` filters. + it("runs the settings lane unfiltered so no describe block can fall through a -t name filter", async () => { const { scripts } = readDashboardPackageJson(); + const qualityLanes = await readQualityLanes(); + const settingsLane = qualityLanes.find((lane) => lane.name === "app:settings"); + expect(scripts["test:quality:app:settings"]).toContain("--project dashboard-app-quality-settings"); expect(scripts["test:quality:app:settings"]).not.toContain("-t "); + expect(settingsLane?.args).toContain("--project"); + expect(settingsLane?.args).toContain("dashboard-app-quality-settings"); + expect(settingsLane?.args).not.toContain("-t"); for (const removed of [ "test:quality:app:settings-a1", "test:quality:app:settings-a2", @@ -98,6 +157,25 @@ describe("dashboard test config guard", () => { } expect(vitestConfig).toContain('"app/__tests__/spinner-animation.css.test.ts"'); - expect(vitestConfig).toContain('"scripts/__tests__/run-vitest-with-heap.test.ts"'); + expect(vitestConfig).toContain('"scripts/__tests__/{run-quality-tests,run-vitest-with-heap}.test.ts"'); + }); + + it("keeps orchestrated quality project coverage at the measured baseline", async () => { + const qualityLanes = await readQualityLanes(); + const laneProjects = new Set(qualityLanes.map(projectNameForLane)); + const knownProjects = Object.keys(dashboardQualityProjectGlobs); + + expect([...laneProjects].sort()).toEqual([...knownProjects].sort()); + + const files = new Set<string>(); + for (const projectName of laneProjects) { + const projectFiles = expandProjectFiles(projectName as keyof typeof dashboardQualityProjectGlobs); + expect(projectFiles.size).toBeGreaterThan(0); + for (const file of projectFiles) { + files.add(file); + } + } + + expect(files.size).toBeGreaterThanOrEqual(qualityParityBaselineFileCount); }); }); diff --git a/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts b/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts new file mode 100644 index 0000000000..c09b5b61c8 --- /dev/null +++ b/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import type { TaskStore } from "@fusion/core"; +import { createServer } from "../server.js"; +import { request as performRequest } from "../test-request.js"; + +function createStore(results: Array<{ taskId: string; column: string; cleared: boolean }> = []): TaskStore { + return { + reconcileLegacyAutoMergeStamps: vi.fn().mockResolvedValue(results), + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({}), + getRootDir: vi.fn().mockReturnValue("/tmp/project"), + getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"), + listTasks: vi.fn().mockResolvedValue([]), + getAgentLogs: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + getDatabase: vi.fn().mockReturnValue({ + exec: vi.fn(), + prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }), + }), + getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }), + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore; +} + +describe("legacy auto-merge stamp maintenance routes", () => { + it("GET returns dry-run candidates without apply", async () => { + const candidates = [{ taskId: "FN-101", column: "in-review", cleared: false }]; + const store = createStore(candidates); + const app = createServer(store); + + const response = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ candidates, count: 1 }); + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + }); + + it("POST delegates apply to the store API and returns cleared count", async () => { + const cleared = [{ taskId: "FN-101", column: "in-review", cleared: true }]; + const store = createStore(cleared); + const app = createServer(store); + + const response = await performRequest(app, "POST", "/api/maintenance/legacy-automerge-stamps/apply"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ cleared, count: 1 }); + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + }); + + it("handles zero-candidate dry-run and apply as clean no-ops", async () => { + const store = createStore([]); + const app = createServer(store); + + const dryRun = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + const applied = await performRequest(app, "POST", "/api/maintenance/legacy-automerge-stamps/apply"); + + expect(dryRun.status).toBe(200); + expect(dryRun.body).toEqual({ candidates: [], count: 0 }); + expect(applied.status).toBe(200); + expect(applied.body).toEqual({ cleared: [], count: 0 }); + }); + + it("maps store errors through the API error handler", async () => { + const store = createStore(); + vi.mocked(store.reconcileLegacyAutoMergeStamps).mockRejectedValue(new Error("store unavailable")); + const app = createServer(store); + + const response = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + + expect(response.status).toBe(500); + expect(response.body.error).toContain("store unavailable"); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts index 453a14358c..20836357ff 100644 --- a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts @@ -537,6 +537,38 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => { }); expect(mockExecuteHeartbeat).not.toHaveBeenCalled(); }); + it("fallback pause updates only agent state and does not auto-pause assigned tasks", async () => { + const { createServer } = await import("../server.js"); + app = createServer(store as any, { + heartbeatMonitor: { + executeHeartbeat: mockExecuteHeartbeat, + stopRun: mockStopRun, + }, + }); + (store.getTasksByAssignedAgent as ReturnType<typeof vi.fn>).mockResolvedValueOnce([ + { id: "FN-1", paused: false }, + { id: "FN-2", paused: undefined }, + ]); + mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "paused" }); + + const response = await request( + app, + "POST", + "/api/agents/agent-001/state", + JSON.stringify({ state: "paused" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ id: "agent-001", state: "paused" }); + await vi.waitFor(() => { + expect(mockGetActiveHeartbeatRun).toHaveBeenCalledWith("agent-001"); + }); + expect(store.getTasksByAssignedAgent).not.toHaveBeenCalled(); + expect(store.pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(store.pauseTask).not.toHaveBeenCalled(); + }); + it("falls back to direct state update when monitor lacks lifecycle helpers", async () => { const { createServer } = await import("../server.js"); app = createServer(store as any, { @@ -547,6 +579,8 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => { }); (store.getTasksByAssignedAgent as ReturnType<typeof vi.fn>).mockResolvedValueOnce([ { id: "FN-1", paused: true, pausedByAgentId: "agent-001" }, + { id: "FN-2", paused: true, pausedByAgentId: "agent-001", userPaused: true }, + { id: "FN-3", paused: true, userPaused: true }, ]); mockUpdateAgentState.mockResolvedValue({ id: "agent-001", state: "active" }); mockExecuteHeartbeat.mockResolvedValue(createMockRun({ id: "run-resume-1", status: "completed" })); @@ -563,6 +597,8 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => { await vi.waitFor(() => { expect(store.pauseTask).toHaveBeenCalledWith("FN-1", false); }); + expect(store.pauseTask).not.toHaveBeenCalledWith("FN-2", false); + expect(store.pauseTask).not.toHaveBeenCalledWith("FN-3", false); expect(mockExecuteHeartbeat).toHaveBeenCalledTimes(1); }); it("resuming to active does not auto-trigger heartbeat when disabled", async () => { diff --git a/packages/dashboard/src/__tests__/routes-approval.test.ts b/packages/dashboard/src/__tests__/routes-approval.test.ts index 17bc7c6af7..236a4b1161 100644 --- a/packages/dashboard/src/__tests__/routes-approval.test.ts +++ b/packages/dashboard/src/__tests__/routes-approval.test.ts @@ -5,9 +5,10 @@ import { get, request } from "../test-request.js"; const state = { requests: new Map<string, any>(), audits: new Map<string, any[]>(), - task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" }, + task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" } as any, agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" }, runAuditEvents: [] as any[], + pauseTaskCalls: [] as Array<{ id: string; paused: boolean }>, provisionedAgents: new Set<string>(), }; @@ -106,7 +107,8 @@ describe("approval routes", async () => { getFusionDir: () => "/tmp/fusion", getTask: async () => state.task, getSettings: async () => ({ worktrunk: {} }), - pauseTask: async (_id: string, paused: boolean) => { + pauseTask: async (id: string, paused: boolean) => { + state.pauseTaskCalls.push({ id, paused }); state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined }; }, recordRunAuditEvent: (event: any) => { @@ -136,6 +138,7 @@ describe("approval routes", async () => { executeApprovedAgentProvisioning.mockClear(); executeApprovedWorktrunkInstall.mockClear(); state.runAuditEvents = []; + state.pauseTaskCalls = []; state.provisionedAgents = new Set(["target-1"]); state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" }; state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" }; @@ -286,6 +289,25 @@ describe("approval routes", async () => { expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined }); }); + it("does not unpause user-paused tasks after approval decision", async () => { + state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1", userPaused: true }; + const app = createApp(); + + const res = await request( + app, + "POST", + "/api/approvals/apr-1/decision", + JSON.stringify({ decision: "approve" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(state.task.paused).toBe(true); + expect(state.task.userPaused).toBe(true); + expect(state.pauseTaskCalls).not.toContainEqual({ id: "FN-1", paused: false }); + expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined }); + }); + it("supports deny decision", async () => { const app = createApp(); const res = await request( diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 8ff8c55b39..78541cbedd 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -611,19 +611,25 @@ describe("Planning Mode Routes", () => { }); it("enforces rate limiting (1000 sessions per hour per IP)", async () => { - // Create 1000 sessions (should succeed) - for (let i = 0; i < 1000; i++) { - const res = await REQUEST( - buildApp(), - "POST", - "/api/planning/start", - JSON.stringify({ initialPlan: `Plan ${i}` }), - { "Content-Type": "application/json" } - ); - expect(res.status).toBe(201); + // Seed the in-memory limiter directly so this boundary test still proves + // the 1000th HTTP request is accepted and the 1001st is rejected without + // spending seconds creating 999 duplicate full planning sessions. + const candidateIps = ["::ffff:127.0.0.1", "127.0.0.1", "unknown"]; + for (const ip of candidateIps) { + for (let i = 0; i < 999; i += 1) { + expect(planningModule.checkRateLimit(ip)).toBe(true); + } } - // 1001st session should be rate limited + const allowed = await REQUEST( + buildApp(), + "POST", + "/api/planning/start", + JSON.stringify({ initialPlan: "Plan 1000" }), + { "Content-Type": "application/json" } + ); + expect(allowed.status).toBe(201); + const res = await REQUEST( buildApp(), "POST", @@ -3430,10 +3436,9 @@ describe("Saturated-slot regression: heartbeat wake routes", () => { ); expect(res.status).toBe(200); + await Promise.resolve(); // Active run conflict must still work under saturation - await vi.waitFor(() => { - expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled(); - }, { timeout: 1000 }); + expect(heartbeatMonitor.executeHeartbeat).not.toHaveBeenCalled(); } finally { agentStore?.close?.(); rmSync(tempDir, { recursive: true, force: true }); @@ -3757,6 +3762,31 @@ describe("POST /api/ai/summarize-title", () => { ); }); + it("accepts descriptions longer than 2000 characters", async () => { + const fusionCore = await import("@fusion/core"); + const summarizeTitleSpy = vi + .spyOn(fusionCore, "summarizeTitle") + .mockResolvedValueOnce("Generated title"); + + const description = "x".repeat(5000); + const res = await REQUEST( + buildApp(), + "POST", + "/api/ai/summarize-title", + JSON.stringify({ description }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ title: "Generated title" }); + expect(summarizeTitleSpy).toHaveBeenCalledWith( + description, + "/test/project", + undefined, + undefined, + ); + }); + it("emits structured diagnostics for unexpected summarize failures", async () => { const diagnostics = captureDiagnostics(); const fusionCore = await import("@fusion/core"); diff --git a/packages/dashboard/src/__tests__/routes-settings.test.ts b/packages/dashboard/src/__tests__/routes-settings.test.ts index b964ece831..6bff37c484 100644 --- a/packages/dashboard/src/__tests__/routes-settings.test.ts +++ b/packages/dashboard/src/__tests__/routes-settings.test.ts @@ -161,6 +161,31 @@ import { createFnAgent } from "@fusion/engine"; const mockIsGhAvailable = vi.mocked(isGhAvailable); const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); +function resetCreateFnAgentMockForInsightExtraction(): void { + vi.mocked(createFnAgent).mockImplementation(async (options?: { onText?: (delta: string) => void }) => { + const session = { + state: { + messages: [] as Array<{ role: string; content: string }>, + }, + prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }, message: string) { + options?.onText?.("mock-ai-output"); + this.state.messages.push({ role: "user", content: message }); + this.state.messages.push({ + role: "assistant", + content: JSON.stringify({ + summary: "Extracted insights", + insights: [{ category: "pattern", content: "Persist reusable memory-audit conventions" }], + prunedMemory: "## Architecture\n\nDurable architecture notes.", + }), + }); + }), + dispose: vi.fn(), + }; + + return { session } as never; + }); +} + function createMockGlobalSettingsStore() { return { getSettings: vi.fn().mockResolvedValue({}), @@ -189,6 +214,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore { updateGlobalSettings: vi.fn(), getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }), getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }), + listWorkflowSettingValuesForProject: vi.fn().mockReturnValue({}), getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()), logEntry: vi.fn().mockResolvedValue(undefined), getAgentLogs: vi.fn().mockResolvedValue([]), @@ -1375,17 +1401,24 @@ describe("GET /settings/scopes", () => { expect(res.body.global.persistAgentThinkingLog).toBe(false); expect(res.body.project.maxConcurrent).toBe(4); expect(res.body.project.autoMerge).toBe(false); + expect(res.body.workflowSettings).toEqual({}); + expect(res.body.workflowSettings).not.toBeNull(); + expect(typeof res.body.workflowSettings).toBe("object"); + expect(Array.isArray(res.body.workflowSettings)).toBe(false); expect(res.body.project.persistAgentToolOutput).toBeUndefined(); expect(res.body.project.persistAgentThinkingLogPermanent).toBeUndefined(); expect(res.body.project.persistAgentThinkingLogEphemeral).toBeUndefined(); expect(res.body.project.persistAgentThinkingLog).toBeUndefined(); }); - it("returns exact response envelope shape with only global and project keys", async () => { + it("returns exact response envelope shape with global, project, and workflowSettings keys", async () => { (store.getSettingsByScopeFast as ReturnType<typeof vi.fn>).mockResolvedValue({ global: { themeMode: "dark" }, project: { maxConcurrent: 4 }, }); + (store.listWorkflowSettingValuesForProject as ReturnType<typeof vi.fn>).mockReturnValue({ + "builtin:coding": { workflowStepTimeoutMs: 120000 }, + }); const res = await GET(buildApp(), "/api/settings/scopes"); @@ -1393,10 +1426,17 @@ describe("GET /settings/scopes", () => { // Assert exact envelope shape expect(res.body).toHaveProperty("global"); expect(res.body).toHaveProperty("project"); + expect(res.body).toHaveProperty("workflowSettings"); + expect(res.body.workflowSettings).not.toBeNull(); + expect(typeof res.body.workflowSettings).toBe("object"); + expect(Array.isArray(res.body.workflowSettings)).toBe(false); // No unexpected top-level keys const keys = Object.keys(res.body); - expect(keys).toHaveLength(2); - expect(keys).toEqual(["global", "project"]); + expect(keys).toHaveLength(3); + expect(keys).toEqual(["global", "project", "workflowSettings"]); + expect(res.body.workflowSettings).toEqual({ + "builtin:coding": { workflowStepTimeoutMs: 120000 }, + }); }); it("uses getSettingsByScopeFast and does not call getSettingsByScope", async () => { @@ -1579,6 +1619,10 @@ describe("GET /settings/scopes with projectId scoping", () => { expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); expect(scopedStore.getSettingsByScopeFast).toHaveBeenCalled(); expect(defaultStore.getSettingsByScopeFast).not.toHaveBeenCalled(); + expect(res.body.workflowSettings).toEqual({}); + expect(res.body.workflowSettings).not.toBeNull(); + expect(typeof res.body.workflowSettings).toBe("object"); + expect(Array.isArray(res.body.workflowSettings)).toBe(false); expect(res.body.project.maxConcurrent).toBe(8); expect(res.body.project.planningProvider).toBe("anthropic"); }); @@ -2741,6 +2785,7 @@ describe("POST /api/memory/extract", () => { let rootDir: string; beforeEach(() => { + resetCreateFnAgentMockForInsightExtraction(); rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-extract-")); mkdirSync(join(rootDir, ".fusion"), { recursive: true }); store = createMockStore({ @@ -2750,6 +2795,7 @@ describe("POST /api/memory/extract", () => { afterEach(() => { rmSync(rootDir, { recursive: true, force: true }); + resetCreateFnAgentMockForInsightExtraction(); }); function buildApp() { @@ -2788,7 +2834,6 @@ describe("POST /api/memory/extract", () => { prunedMemory: "## Architecture\n\nDurable architecture notes.", }); this.state.messages.push({ role: "assistant", content: response }); - return response; }), dispose: vi.fn(), }; @@ -2805,13 +2850,65 @@ describe("POST /api/memory/extract", () => { expect(res.status).toBe(200); expect(res.body).toHaveProperty("success", true); - expect(res.body).toHaveProperty("summary"); - expect(res.body).toHaveProperty("insightCount"); - expect(res.body).toHaveProperty("pruned"); + expect(typeof res.body.summary).toBe("string"); + expect(res.body.insightCount).toBeGreaterThanOrEqual(1); + expect(typeof res.body.pruned).toBe("boolean"); expect(existsSync(join(rootDir, ".fusion", "memory", "memory-insights.md"))).toBe(true); expect(existsSync(join(rootDir, ".fusion", "memory", "memory-audit.md"))).toBe(true); expect(existsSync(join(rootDir, ".fusion", "memory", "memory-audit-state.json"))).toBe(true); }); + + it("uses prompt return text when the session does not persist assistant state", async () => { + mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true }); + writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Working memory content for extraction that is long enough."); + + const session = { + state: { messages: [] as Array<{ role: string; content: string }> }, + prompt: vi.fn(async () => JSON.stringify({ + summary: "Returned extraction", + insights: [{ category: "pattern", content: "Prefer returned text when available" }], + })), + dispose: vi.fn(), + }; + + vi.mocked(createFnAgent).mockResolvedValue({ session } as never); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/memory/extract", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.insightCount).toBeGreaterThanOrEqual(1); + }); + + it("returns 503 when the agent produces no assistant text", async () => { + mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true }); + writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Working memory content for extraction that is long enough."); + + const session = { + state: { messages: [] as Array<{ role: string; content: string }> }, + prompt: vi.fn(async () => undefined), + dispose: vi.fn(), + }; + + vi.mocked(createFnAgent).mockResolvedValue({ session } as never); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/memory/extract", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(503); + expect(res.body.error).toContain("AI agent did not produce a response"); + }); }); describe("GET /api/memory/audit", () => { @@ -2819,6 +2916,7 @@ describe("GET /api/memory/audit", () => { let rootDir: string; beforeEach(() => { + resetCreateFnAgentMockForInsightExtraction(); rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-audit-")); mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true }); store = createMockStore({ diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 7c13caadcd..2fb489fed0 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -308,6 +308,92 @@ afterEach(() => { }); +describe("POST /tasks/:id/steer", () => { + let store: TaskStore; + + beforeEach(() => { + store = createMockStore({ + getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"), + } as Partial<TaskStore>); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function buildApp(heartbeatMonitor?: NonNullable<Parameters<typeof createApiRoutes>[1]>["heartbeatMonitor"]) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, heartbeatMonitor ? { heartbeatMonitor } : undefined)); + return app; + } + + it("records user steering comments and wakes the assigned immediate-response agent", async () => { + const updatedTask = { + ...FAKE_TASK_DETAIL, + id: "FN-001", + column: "in-progress" as const, + assignedAgentId: "agent-1", + steeringComments: [{ id: "steer-1", text: "Please continue", author: "user" as const, createdAt: "2026-06-12T00:00:00.000Z" }], + }; + const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" }); + const heartbeatMonitor = { + rootDir: "/fake/root", + startRun: vi.fn(), + executeHeartbeat, + stopRun: vi.fn(), + }; + vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined); + vi.spyOn(AgentStore.prototype, "getAgent").mockResolvedValue({ + id: "agent-1", + name: "Executor", + role: "executor", + runtimeConfig: { messageResponseMode: "immediate" }, + } as Awaited<ReturnType<AgentStore["getAgent"]>>); + vi.spyOn(AgentStore.prototype, "getActiveHeartbeatRun").mockResolvedValue(null); + (store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask); + + const res = await REQUEST(buildApp(heartbeatMonitor), "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text: "Please continue" }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.addSteeringComment).toHaveBeenCalledWith("FN-001", "Please continue", "user"); + expect(res.body.steeringComments).toEqual(updatedTask.steeringComments); + await vi.waitFor(() => { + expect(executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "agent-1", + source: "on_demand", + taskId: "FN-001", + triggerDetail: "steering-comment", + triggeringCommentIds: ["steer-1"], + triggeringCommentType: "steering", + contextSnapshot: expect.objectContaining({ + taskId: "FN-001", + triggerDetail: "steering-comment", + triggeringCommentIds: ["steer-1"], + triggeringCommentType: "steering", + wakeReason: "on_demand", + }), + })); + }); + }); + + it.each([ + ["", "text is required and must be a string"], + ["x".repeat(2001), "text must be between 1 and 2000 characters"], + ])("rejects invalid steering text %#", async (text, expectedError) => { + const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain(expectedError); + expect(store.addSteeringComment).not.toHaveBeenCalled(); + }); +}); + + describe("POST /tasks/:id/retry", () => { let store: TaskStore; @@ -1487,7 +1573,7 @@ describe("POST /tasks/:id/archive", () => { return app; } - it("archives a done task and returns the updated task", async () => { + it("archives a task from any live column and returns the updated task", async () => { const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" }; (store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask); @@ -1537,15 +1623,15 @@ describe("POST /tasks/:id/archive", () => { }); }); - it("returns 400 when task is not in done column", async () => { - (store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is in 'triage', must be in 'done'")); + it("returns 400 when task is already archived", async () => { + (store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive FN-001: task is already archived")); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), { "Content-Type": "application/json", }); expect(res.status).toBe(400); - expect(res.body.error).toContain("must be in 'done'"); + expect(res.body.error).toContain("already archived"); }); it("returns 500 on unexpected errors", async () => { diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 1449777c73..4edc531121 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -230,6 +230,28 @@ describe("workflow routes (U4)", () => { expect(list.some((w) => isBuiltinWorkflowId(w.id))).toBe(true); }); + it("GET /workflows/:id/optional-steps resolves declared optional steps", async () => { + const builtin = await get("/api/workflows/builtin%3Acoding/optional-steps"); + expect(builtin.status).toBe(200); + expect(builtin.body).toEqual([ + expect.objectContaining({ + templateId: "browser-verification", + name: "Browser Verification", + icon: "globe", + defaultOn: false, + }), + ]); + + const custom = await post("/api/workflows", { name: "A", ir: linearIr() }); + const customId = (custom.body as { id: string }).id; + const customSteps = await get(`/api/workflows/${customId}/optional-steps`); + expect(customSteps.status).toBe(200); + expect(customSteps.body).toEqual([]); + + const missing = await get("/api/workflows/WF-404/optional-steps"); + expect(missing.status).toBe(404); + }); + it("GET /traits returns the registry trait catalog (built-ins, with flags + schema)", async () => { const res = await get("/api/traits"); expect(res.status).toBe(200); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a73e15dcc5..f5e9ca011c 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1638,6 +1638,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); + // ── Maintenance Routes ───────────────────────────────────────────── + + /** + * GET /api/maintenance/legacy-automerge-stamps + * Dry-run the legacy auto-merge stamp cleanup and list candidates. + */ + router.get("/maintenance/legacy-automerge-stamps", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const candidates = await scopedStore.reconcileLegacyAutoMergeStamps(); + res.json({ candidates, count: candidates.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to list legacy auto-merge stamps"); + } + }); + + /** + * POST /api/maintenance/legacy-automerge-stamps/apply + * Apply the legacy auto-merge stamp cleanup via the store-owned reconcile API. + */ + router.post("/maintenance/legacy-automerge-stamps/apply", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const cleared = await scopedStore.reconcileLegacyAutoMergeStamps({ apply: true }); + res.json({ cleared, count: cleared.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to apply legacy auto-merge stamp cleanup"); + } + }); + // ── Backup Routes ───────────────────────────────────────────────── /** @@ -1848,6 +1884,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout * Returns: { title: string } * * Generates a concise title (≤60 characters) from descriptions longer than 200 characters. + * Long descriptions are accepted; core truncates model input before prompting. * Rate limited: 10 requests per hour per IP */ router.post("/ai/summarize-title", async (req, res) => { @@ -1863,7 +1900,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout summarizeTitle, validateDescription, MIN_DESCRIPTION_LENGTH, - MAX_DESCRIPTION_LENGTH: _MAX_DESCRIPTION_LENGTH, RateLimitError: _RateLimitError4, ValidationError: _ValidationError2, AiServiceError: _AiServiceError2, diff --git a/packages/dashboard/src/routes/__tests__/custom-provider-routes.test.ts b/packages/dashboard/src/routes/__tests__/custom-provider-routes.test.ts index ae52d07290..376e63b4ee 100644 --- a/packages/dashboard/src/routes/__tests__/custom-provider-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/custom-provider-routes.test.ts @@ -274,6 +274,67 @@ describe("custom provider routes", () => { }); }); + it("PUT /custom-providers/:id preserves stored key when a masked key is echoed back", async () => { + settings.customProviders = [ + { + id: "cp-1", + name: "Original", + apiType: "openai-compatible", + baseUrl: "https://original.example.com", + apiKey: "sk-real-secret-1234", + }, + ]; + + const updates: Array<Partial<GlobalSettings>> = []; + const app = createApp(settings, (patch) => updates.push(patch)); + const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", { + name: "Updated", + // The UI sends back the masked key when the field is left untouched. + apiKey: "sk-•••••1234", + }); + + expect(res.status).toBe(200); + const persisted = updates[0].customProviders as CustomProvider[]; + // The original key must survive — never overwritten with the mask. + expect(persisted[0]?.apiKey).toBe("sk-real-secret-1234"); + // And no mask character ever reaches the stored credential. + expect(persisted[0]?.apiKey).not.toContain("•"); + }); + + it("PUT /custom-providers/:id updates the key when a real key is provided", async () => { + settings.customProviders = [ + { + id: "cp-1", + name: "Original", + apiType: "openai-compatible", + baseUrl: "https://original.example.com", + apiKey: "sk-old-key-0000", + }, + ]; + + const updates: Array<Partial<GlobalSettings>> = []; + const app = createApp(settings, (patch) => updates.push(patch)); + const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", { + apiKey: "sk-brand-new-9999", + }); + + expect(res.status).toBe(200); + const persisted = updates[0].customProviders as CustomProvider[]; + expect(persisted[0]?.apiKey).toBe("sk-brand-new-9999"); + }); + + it("POST /custom-providers rejects a masked API key", async () => { + const app = createApp(settings); + const res = await REQUEST(app, "POST", "/api/custom-providers", { + name: "My Provider", + apiType: "openai-compatible", + baseUrl: "https://example.com/v1", + apiKey: "sk-•••••5678", + }); + + expect(res.status).toBe(400); + }); + it("PUT /custom-providers/:id returns 404 for non-existent id", async () => { const app = createApp(settings); const res = await REQUEST(app, "PUT", "/api/custom-providers/missing", { diff --git a/packages/dashboard/src/routes/register-agent-runtime-routes.ts b/packages/dashboard/src/routes/register-agent-runtime-routes.ts index 1d32fe7dce..6e26e364e7 100644 --- a/packages/dashboard/src/routes/register-agent-runtime-routes.ts +++ b/packages/dashboard/src/routes/register-agent-runtime-routes.ts @@ -465,29 +465,12 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun } } - if (nextState === "paused") { - const assignedTasks = await scopedStore.getTasksByAssignedAgent(agentId, { excludeArchived: true }); - const toPause = assignedTasks.filter((task) => task.paused !== true); - const results = await Promise.allSettled( - toPause.map((task) => scopedStore.pauseTask(task.id, true, undefined, { pausedByAgentId: agentId })), - ); - results.forEach((result, index) => { - if (result.status === "rejected") { - runtimeLogger.child("agent-state").warn("Failed to auto-pause assigned task", { - agentId, - taskId: toPause[index]?.id, - error: String(result.reason), - }); - } - }); - } - if (nextState === "active") { const pausedTasks = await scopedStore.getTasksByAssignedAgent(agentId, { pausedOnly: true, excludeArchived: true, }); - const toUnpause = pausedTasks.filter((task) => task.pausedByAgentId === agentId); + const toUnpause = pausedTasks.filter((task) => task.pausedByAgentId === agentId && !task.userPaused); const results = await Promise.allSettled( toUnpause.map((task) => scopedStore.pauseTask(task.id, false)), ); diff --git a/packages/dashboard/src/routes/register-approval-routes.ts b/packages/dashboard/src/routes/register-approval-routes.ts index 37d230cfed..23e37b69c1 100644 --- a/packages/dashboard/src/routes/register-approval-routes.ts +++ b/packages/dashboard/src/routes/register-approval-routes.ts @@ -222,7 +222,7 @@ async function resumeAfterDecision(params: { try { if (request.taskId) { const task = await scopedStore.getTask(request.taskId); - if (task?.paused && task.pausedByAgentId === request.requester.actorId) { + if (task?.paused && task.pausedByAgentId === request.requester.actorId && !task.userPaused) { await scopedStore.pauseTask(request.taskId, false, undefined); } } diff --git a/packages/dashboard/src/routes/register-custom-provider-routes.ts b/packages/dashboard/src/routes/register-custom-provider-routes.ts index 73df567d45..4f3a0d2aae 100644 --- a/packages/dashboard/src/routes/register-custom-provider-routes.ts +++ b/packages/dashboard/src/routes/register-custom-provider-routes.ts @@ -6,14 +6,31 @@ import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; +/** + * Sentinel character used to mask API keys for display. A real API key is an + * ASCII/Latin1 credential and will never contain this character, so its + * presence in an inbound value reliably indicates the client echoed back a + * masked (unchanged) key rather than a freshly entered one. + */ +const API_KEY_MASK_CHAR = "•"; + /** * Masks an API key for safe display, showing only the first 3 and last 4 characters. */ function maskApiKey(key: string): string { if (key.length <= 8) { - return "••••••••"; + return API_KEY_MASK_CHAR.repeat(8); } - return key.slice(0, 3) + "•••••" + key.slice(-4); + return key.slice(0, 3) + API_KEY_MASK_CHAR.repeat(5) + key.slice(-4); +} + +/** + * Returns true when a value is a masked API key echoed back from the UI rather + * than a real credential. Persisting a masked value would corrupt the stored + * key and break HTTP header encoding (the mask char is not a valid ByteString). + */ +function isMaskedApiKey(value: string): boolean { + return value.includes(API_KEY_MASK_CHAR); } /** @@ -126,6 +143,9 @@ function parseCreateBody(body: unknown): Omit<CustomProvider, "id"> { if (typeof row.apiKey !== "string") { throw badRequest("apiKey must be a string"); } + if (isMaskedApiKey(row.apiKey)) { + throw badRequest("apiKey appears to be a masked value; enter the real API key"); + } if (row.apiKey.trim().length > 0) { provider.apiKey = row.apiKey; } @@ -416,7 +436,13 @@ function parseUpdateBody(body: unknown): Partial<Omit<CustomProvider, "id">> { if (typeof row.apiKey !== "string") { throw badRequest("apiKey must be a string"); } - updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined; + // The UI loads the existing key masked (e.g. "abc•••••wxyz"). If the user + // saves without retyping it, that masked value is echoed back — leave the + // field absent from the update so the stored key is preserved rather than + // overwritten with the mask. + if (!isMaskedApiKey(row.apiKey)) { + updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined; + } } if (row.models !== undefined) { updates.models = validateModels(row.models); @@ -556,6 +582,9 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => { const body = req.body as Record<string, unknown>; const baseUrl = assertBaseUrl(body.baseUrl); + if (typeof body.apiKey === "string" && isMaskedApiKey(body.apiKey)) { + throw badRequest("apiKey appears to be a masked value; enter the real API key"); + } const apiKey = typeof body.apiKey === "string" && body.apiKey.trim().length > 0 ? body.apiKey.trim() diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index a1b2e4ec27..e332c1c4e9 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -1,7 +1,8 @@ import { access, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { resolvePlanningSettingsModel } from "@fusion/core"; +import { customProviderRegistryKey, resolvePlanningSettingsModel } from "@fusion/core"; +import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -77,6 +78,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { let useCursorCli = false; let resolvedPlanningProvider: string | undefined; let resolvedPlanningModelId: string | undefined; + let customProviders: CustomProvider[] = []; if (store) { try { const globalStore = store.getGlobalSettingsStore(); @@ -89,6 +91,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { useDroidCli = globalSettings.useDroidCli === true; useLlamaCpp = globalSettings.useLlamaCpp === true; useCursorCli = (globalSettings as Record<string, unknown>).useCursorCli === true; + customProviders = globalSettings.customProviders ?? []; const mergedSettings = await store.getSettingsFast(); const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings); @@ -163,6 +166,11 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { if (useClaudeCli) configuredProviders.add("pi-claude-cli"); if (useDroidCli) configuredProviders.add("droid-cli"); if (useLlamaCpp) configuredProviders.add("llama-server"); + // Custom providers are configured in Fusion's global settings rather than + // the auth.json/models.json stores, so add their registry keys explicitly. + for (const provider of customProviders) { + configuredProviders.add(customProviderRegistryKey(provider, customProviders)); + } models = models.filter((m) => configuredProviders.has(m.provider)); res.json({ diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 0a707487be..9790bcbd53 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -1720,8 +1720,15 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin session = agentResult.session; - // Send extraction prompt to AI - const responseText = await session.prompt(extractionPrompt); + // Send extraction prompt to AI. Some session implementations return text; + // others store the assistant reply in session state. + const promptResult = await session.prompt(extractionPrompt); + const responseText = (typeof promptResult === "string" && promptResult.trim()) + ? promptResult + : extractAssistantTextFromSession(session); + if (!responseText?.trim()) { + throw new ApiError(503, "AI agent did not produce a response for insight extraction"); + } // Process the result: merge insights, prune duplicates, and generate audit const result = await processAndAuditInsightExtraction(rootDir, { diff --git a/packages/dashboard/src/routes/register-settings-sync-routes.ts b/packages/dashboard/src/routes/register-settings-sync-routes.ts index cabba4f94a..1c71b5493f 100644 --- a/packages/dashboard/src/routes/register-settings-sync-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-routes.ts @@ -331,6 +331,9 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { ...payloadWithoutChecksum, checksum, }); + const workflowApplyResult = result.success + ? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings) + : { count: 0, keys: [] }; // applyRemoteSettings() only validates/strips the global payload; it does NOT // write the local global settings store. Persist the pulled global settings @@ -344,10 +347,6 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { invalidateAllGlobalSettingsCaches(); } - const workflowApplyResult = result.success - ? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings) - : { count: 0, keys: [] }; - // Record sync await central.updateSettingsSyncState(node.id, { lastSyncedAt: new Date().toISOString(), diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e5160abb2b..6977e9dc13 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1785,7 +1785,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); - // Archive task (done → archived) + // Archive task (any live column → archived) router.post("/tasks/:id/archive", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); @@ -1814,12 +1814,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork }); } - const status = (err instanceof Error ? err.message : String(err)).includes("must be in") ? 400 : 500; - throw new ApiError(status, err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + const status = message.includes("must be in") || message.includes("already archived") ? 400 : 500; + throw new ApiError(status, message); } }); - // Unarchive task (archived → done) + // Unarchive task (archived → restored column) router.post("/tasks/:id/unarchive", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 1355473083..871447bfd6 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,5 +1,5 @@ import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core"; -import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings } from "@fusion/core"; +import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps } from "@fusion/core"; import { createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { emitWorkflowSseEvent } from "../sse.js"; @@ -312,6 +312,20 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { } }); + // GET /api/workflows/:id/optional-steps — resolved optional step metadata. + router.get("/workflows/:id/optional-steps", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const workflowId = req.params.id; + await assertWorkflowExists(store, workflowId); + const ir = await resolveWorkflowIrById(store, workflowId); + res.json(resolveWorkflowOptionalSteps(ir)); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + // GET /api/workflows/:id router.get("/workflows/:id", async (req, res) => { try { diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 676ff87fa5..fd9d44c29b 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -231,7 +231,10 @@ const qualityAppComponentBatchBTests = buildComponentQualityInclude(batchedQuali const qualityAppAppOnlyTests = ["app/components/__tests__/App.test.tsx"]; const qualityAppChatOnlyTests = ["app/components/__tests__/ChatView.test.tsx"]; const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.test.tsx"]; -const quarantinedDashboardTests: string[] = []; +const quarantinedDashboardTests: string[] = [ + "app/components/__tests__/QuickEntryBox.test.tsx", + "src/__tests__/routes-settings.test.ts", +]; const qualityApiTests = [ // Critical HTTP/server behavior: auth, task/project/settings mutation, @@ -239,7 +242,7 @@ const qualityApiTests = [ "src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-manager,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,register-git-github.pr-resolve-conflicts,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-plugin-registry,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts", "src/__tests__/dashboard-test-config-guard.test.ts", "src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts", - "scripts/__tests__/run-vitest-with-heap.test.ts", + "scripts/__tests__/{run-quality-tests,run-vitest-with-heap}.test.ts", ]; // Backfill projects (plan U2 / R7). Historically the curated quality lanes @@ -262,6 +265,53 @@ const backfillApiExclude = [ ]; const qualityApiBackfillTests = ["src/**/*.test.{ts,tsx}"]; +export const dashboardQualityProjectGlobs = { + "dashboard-app-quality-foundation-api": { + include: qualityAppFoundationApiShardTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-foundation-ui": { + include: qualityAppFoundationUiShardTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-foundation-hooks-utils": { + include: qualityAppFoundationHooksAndUtilsTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-components-a": { + include: qualityAppComponentBatchATests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-components-b": { + include: qualityAppComponentBatchBTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-app": { + include: qualityAppAppOnlyTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-chat": { + include: qualityAppChatOnlyTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-settings": { + include: qualityAppSettingsOnlyTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-api-quality": { + include: qualityApiTests, + exclude: quarantinedDashboardTests, + }, + "dashboard-app-quality-backfill": { + include: qualityAppBackfillTests, + exclude: [...backfillAppExclude, ...quarantinedDashboardTests], + }, + "dashboard-api-quality-backfill": { + include: qualityApiBackfillTests, + exclude: [...backfillApiExclude, ...quarantinedDashboardTests], + }, +} as const; + export default defineConfig({ plugins: [react()], resolve: { diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 235ce5413c..94038567ed 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion/desktop +## 0.42.0 + +### Patch Changes + +- @fusion/dashboard@0.42.0 +- @fusion/core@0.42.0 + ## 0.41.0 ### Patch Changes diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ff065d303e..2d0ce33533 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@fusion/desktop", "productName": "Fusion", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "author": { "name": "Runfusion", diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index 585c6af50a..7b948524ef 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -45,8 +45,23 @@ export async function buildCore(): Promise<void> { await runWorkspaceBin("tsc", [], resolve(workspaceRoot, "packages", "core")); } +async function buildPackage(relativePath: string): Promise<void> { + await runWorkspaceBin("tsc", [], resolve(workspaceRoot, relativePath)); +} + +export async function buildDashboardRuntimePlugins(): Promise<void> { + await buildPackage("packages/plugin-sdk"); + await Promise.all([ + buildPackage("plugins/fusion-plugin-dependency-graph"), + buildPackage("plugins/fusion-plugin-hermes-runtime"), + buildPackage("plugins/fusion-plugin-openclaw-runtime"), + buildPackage("plugins/fusion-plugin-paperclip-runtime"), + ]); +} + export async function buildDashboard(): Promise<void> { const dashboardRoot = resolve(workspaceRoot, "packages", "dashboard"); + await buildDashboardRuntimePlugins(); await runWorkspaceBin("vite", ["build"], dashboardRoot); await runWorkspaceBin("tsc", [], dashboardRoot); } diff --git a/packages/desktop/src/__tests__/deep-link.test.ts b/packages/desktop/src/__tests__/deep-link.test.ts index e1d7b19136..bd3bc0a107 100644 --- a/packages/desktop/src/__tests__/deep-link.test.ts +++ b/packages/desktop/src/__tests__/deep-link.test.ts @@ -33,7 +33,9 @@ const mocks = vi.hoisted(() => { vi.mock("electron", () => ({ app: mocks.app, - BrowserWindow: vi.fn(() => mocks.browserWindow), + BrowserWindow: vi.fn(function () { + return mocks.browserWindow; + }), })); async function importDeepLinkModule() { diff --git a/packages/desktop/src/__tests__/main-integration.test.ts b/packages/desktop/src/__tests__/main-integration.test.ts index 78ff36f449..945f6fdc51 100644 --- a/packages/desktop/src/__tests__/main-integration.test.ts +++ b/packages/desktop/src/__tests__/main-integration.test.ts @@ -44,6 +44,7 @@ const mocks = vi.hoisted(() => { const app = { whenReady: vi.fn(() => Promise.resolve()), + getPath: vi.fn((name: string) => (name === "home" ? "/mock/home" : "/mock/other")), on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { appEvents.set(event, handler); }), @@ -51,14 +52,14 @@ const mocks = vi.hoisted(() => { isQuitting: false, }; - const BrowserWindow = vi.fn((options: Record<string, unknown>) => { + const BrowserWindow = vi.fn(function (options: Record<string, unknown>) { callLog.push("createMainWindow"); const instance = createWindowMock(); windowInstances.push({ instance, options }); return instance; }); - const Tray = vi.fn(() => { + const Tray = vi.fn(function () { const tray = createTrayMock(); trayInstances.push(tray); return tray; @@ -117,6 +118,14 @@ const mocks = vi.hoisted(() => { const getStatus = vi.fn(() => ({ source: "none", state: "stopped" })); const saveWindowState = vi.fn(); + const LocalRuntimeManager = vi.fn(function () { + return { + startLocal, + stopLocal, + getStatus, + getServerPort: vi.fn(() => 0), + }; + }); const DEFAULT_WINDOW_STATE = { width: 1280, @@ -149,6 +158,7 @@ const mocks = vi.hoisted(() => { startLocal, stopLocal, getStatus, + LocalRuntimeManager, DEFAULT_WINDOW_STATE, }; }); @@ -195,12 +205,7 @@ vi.mock("../native.js", () => ({ })); vi.mock("../local-runtime.js", () => ({ - LocalRuntimeManager: vi.fn(() => ({ - startLocal: mocks.startLocal, - stopLocal: mocks.stopLocal, - getStatus: mocks.getStatus, - getServerPort: vi.fn(() => 0), - })), + LocalRuntimeManager: mocks.LocalRuntimeManager, })); // Mock renderer module @@ -268,6 +273,8 @@ describe("main integration", () => { "setupAutoUpdater", "startUpdateCheckInterval", ]); + expect(mocks.LocalRuntimeManager).toHaveBeenCalledWith({ rootDir: "/mock/home" }); + expect(mocks.app.getPath).toHaveBeenCalledWith("home"); }); it("createMainWindow uses restored window state", async () => { diff --git a/packages/desktop/src/__tests__/main-local-mode.test.ts b/packages/desktop/src/__tests__/main-local-mode.test.ts index 91535f665d..6652046ff1 100644 --- a/packages/desktop/src/__tests__/main-local-mode.test.ts +++ b/packages/desktop/src/__tests__/main-local-mode.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => { const appHandlers = new Map<string, (...args: unknown[]) => void>(); const app = { whenReady: vi.fn(async () => undefined), + getPath: vi.fn((name: string) => (name === "home" ? "/mock/home" : "/mock/other")), on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { appHandlers.set(event, handler); return app; @@ -26,8 +27,12 @@ const mocks = vi.hoisted(() => { webContents: { send: vi.fn() }, }; - const BrowserWindow = vi.fn(() => browserWindow); - const Tray = vi.fn(() => ({ destroy: vi.fn() })); + const BrowserWindow = vi.fn(function () { + return browserWindow; + }); + const Tray = vi.fn(function () { + return { destroy: vi.fn() }; + }); const localRuntimeManager = { startLocal: vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4041 })), @@ -40,7 +45,11 @@ const mocks = vi.hoisted(() => { getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]), }; - return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager, screen }; + const LocalRuntimeManager = vi.fn(function () { + return localRuntimeManager; + }); + + return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager, LocalRuntimeManager, screen }; }); vi.mock("electron", () => ({ @@ -66,7 +75,7 @@ vi.mock("../native.js", () => ({ clampWindowStateToVisibleDisplay: vi.fn((state) => state), })); vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() })); -vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: vi.fn(() => mocks.localRuntimeManager) })); +vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: mocks.LocalRuntimeManager })); describe("main local mode", () => { beforeEach(() => { @@ -81,6 +90,8 @@ describe("main local mode", () => { const { initializeApp } = await import("../main.ts"); await initializeApp(); + expect(mocks.LocalRuntimeManager).toHaveBeenCalledWith({ rootDir: "/mock/home" }); + expect(mocks.app.getPath).toHaveBeenCalledWith("home"); expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled(); delete process.env.FUSION_DESKTOP_MODE; }); diff --git a/packages/desktop/src/__tests__/main.integration.test.ts b/packages/desktop/src/__tests__/main.integration.test.ts index 9295c30940..ad1335b75e 100644 --- a/packages/desktop/src/__tests__/main.integration.test.ts +++ b/packages/desktop/src/__tests__/main.integration.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const app = { whenReady: vi.fn(() => Promise.resolve()), + getPath: vi.fn((name: string) => (name === "home" ? "/mock/home" : "/mock/other")), on: vi.fn(), quit: vi.fn(), }; @@ -20,14 +21,18 @@ const mocks = vi.hoisted(() => { return { app, - BrowserWindow: vi.fn(() => browserWindow), - Tray: vi.fn(() => ({ - destroy: vi.fn(), - setImage: vi.fn(), - setContextMenu: vi.fn(), - setToolTip: vi.fn(), - on: vi.fn(), - })), + BrowserWindow: vi.fn(function () { + return browserWindow; + }), + Tray: vi.fn(function () { + return { + destroy: vi.fn(), + setImage: vi.fn(), + setContextMenu: vi.fn(), + setToolTip: vi.fn(), + on: vi.fn(), + }; + }), nativeImage: { createEmpty: vi.fn(() => ({ id: "empty-image" })), }, diff --git a/packages/desktop/src/__tests__/main.test.ts b/packages/desktop/src/__tests__/main.test.ts index 76172e508d..f1cc7c20cc 100644 --- a/packages/desktop/src/__tests__/main.test.ts +++ b/packages/desktop/src/__tests__/main.test.ts @@ -34,7 +34,9 @@ const mocks = vi.hoisted(() => { maximize: vi.fn(), }; - const BrowserWindow = vi.fn(() => browserWindowInstance) as unknown as { + const BrowserWindow = vi.fn(function () { + return browserWindowInstance; + }) as unknown as { (...args: unknown[]): typeof browserWindowInstance; getAllWindows: () => unknown[]; }; @@ -43,6 +45,7 @@ const mocks = vi.hoisted(() => { const app = { whenReady: vi.fn(() => Promise.resolve()), getVersion: vi.fn(() => "0.1.0"), + getPath: vi.fn(() => "/mock/home"), quit: vi.fn(), on: vi.fn(), }; @@ -59,7 +62,9 @@ const mocks = vi.hoisted(() => { on: vi.fn(), }; - const Tray = vi.fn(() => trayInstance); + const Tray = vi.fn(function () { + return trayInstance; + }); const Menu = { buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })), setApplicationMenu: vi.fn(), @@ -132,7 +137,9 @@ const mainDeps = vi.hoisted(() => { loadDesktopLaunchMode, saveDesktopLaunchMode, saveWindowState: vi.fn(), - LocalRuntimeManager: vi.fn(() => ({ startLocal, stopLocal, getStatus, getServerPort })), + LocalRuntimeManager: vi.fn(function () { + return { startLocal, stopLocal, getStatus, getServerPort }; + }), startLocal, }; }); @@ -201,6 +208,7 @@ describe("main process", () => { vi.clearAllMocks(); vi.resetModules(); delete process.env.FUSION_DESKTOP_MODE; + delete process.env.FUSION_HOME; if (originalDashboardUrl === undefined) { delete process.env.FUSION_DASHBOARD_URL; } else { @@ -299,10 +307,29 @@ describe("main process", () => { await initializeApp(); + expect(mainDeps.LocalRuntimeManager).toHaveBeenCalledWith({ rootDir: "/mock/home" }); expect(mainDeps.startLocal).toHaveBeenCalledTimes(1); expect(getCurrentDesktopLaunchMode()).toBe("local"); }); + it("anchors the local runtime root to the home dir, not process.cwd()", async () => { + const { resolveLocalRuntimeRoot } = await importMainModule(); + + // Packaged builds (notably the Linux AppImage launched from a desktop + // launcher) run with cwd at `/` or a read-only mount point, so the root + // must come from the home dir to keep `~/.fusion` writable. + expect(resolveLocalRuntimeRoot()).toBe("/mock/home"); + expect(mocks.app.getPath).toHaveBeenCalledWith("home"); + }); + + it("honors FUSION_HOME override for the local runtime root", async () => { + process.env.FUSION_HOME = "/custom/fusion-home"; + const { resolveLocalRuntimeRoot } = await importMainModule(); + + expect(resolveLocalRuntimeRoot()).toBe("/custom/fusion-home"); + expect(mocks.app.getPath).not.toHaveBeenCalled(); + }); + it("initializeApp does not start local runtime for remembered choose mode", async () => { mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("choose"); const { initializeApp } = await importMainModule(); diff --git a/packages/desktop/src/__tests__/native.test.ts b/packages/desktop/src/__tests__/native.test.ts index 9273220848..47ff30b809 100644 --- a/packages/desktop/src/__tests__/native.test.ts +++ b/packages/desktop/src/__tests__/native.test.ts @@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => { options: Record<string, unknown>; }> = []; - const Notification = vi.fn().mockImplementation((options: Record<string, unknown>) => { + const Notification = vi.fn().mockImplementation(function (options: Record<string, unknown>) { const listeners = new Map<string, () => void>(); const instance = { show: vi.fn(), @@ -92,7 +92,9 @@ vi.mock("electron", () => ({ app: mocks.app, dialog: mocks.dialog, Notification: mocks.Notification, - BrowserWindow: vi.fn(() => mocks.browserWindow), + BrowserWindow: vi.fn(function () { + return mocks.browserWindow; + }), })); vi.mock("electron-updater", () => ({ diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 8ceac4698f..87c83e98de 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -160,11 +160,30 @@ export function createMainWindow(state?: WindowState, launchTargetUrl?: string): return window; } +/** + * Resolve the project root for the embedded local runtime. + * + * Must NOT be `process.cwd()`: when a packaged build is launched from a desktop + * launcher or file manager (notably the Linux AppImage), cwd is `/` or the + * read-only squashfs mount point, so creating `<cwd>/.fusion/fusion.db` fails + * with EACCES/EROFS and the local runtime never starts ("Couldn't start local + * Fusion"). Anchor to a stable, writable per-user location instead — the home + * directory, so data lives in `~/.fusion` (consistent with the CLI). Honor + * `FUSION_HOME` for power users who want their data elsewhere. + */ +export function resolveLocalRuntimeRoot(): string { + const override = process.env.FUSION_HOME?.trim(); + if (override) { + return resolve(override); + } + return app.getPath("home"); +} + export async function initializeApp(): Promise<void> { const state = await loadWindowState(); const rememberedLaunchMode = await loadDesktopLaunchMode(); - localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() }); + localRuntimeManager = new LocalRuntimeManager({ rootDir: resolveLocalRuntimeRoot() }); currentDesktopLaunchMode = rememberedLaunchMode; currentRemoteLaunch = null; localRuntimeStartupAttempted = false; diff --git a/packages/droid-cli/CHANGELOG.md b/packages/droid-cli/CHANGELOG.md index ca28bf6ce7..6379fe4f38 100644 --- a/packages/droid-cli/CHANGELOG.md +++ b/packages/droid-cli/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/droid-cli +## 0.11.30 + +### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.30 + ## 0.11.29 ### Patch Changes diff --git a/packages/droid-cli/package.json b/packages/droid-cli/package.json index d823044361..08dc7d7e84 100644 --- a/packages/droid-cli/package.json +++ b/packages/droid-cli/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/droid-cli", - "version": "0.11.29", + "version": "0.11.30", "description": "First-party Fusion pi extension that routes LLM calls through the Droid CLI subprocess.", "license": "MIT", "private": true, diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 0d48317ce1..221b90cc4c 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -1,5 +1,13 @@ # @fusion/engine +## 0.42.0 + +### Patch Changes + +- 630b2a8: Allow narrowly scoped plan-only operational tasks to complete without source commits when their prompt or metadata explicitly declares no-source/no-code intent and their recorded evidence satisfies the task. The commit guard still rejects missing commits for normal implementation tasks and still enforces worktree and branch invariants before applying the no-commit exemption. + - @fusion/core@0.42.0 + - @fusion/pi-claude-cli@0.42.0 + ## 0.41.0 ### Patch Changes diff --git a/packages/engine/package.json b/packages/engine/package.json index d1aaf0930c..09c6b8ba8d 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/engine", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts b/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts index d1e74ec413..6bc4e7b570 100644 --- a/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts @@ -4,6 +4,7 @@ import { resolveHeartbeatSessionModels, resolveMergerSessionModel, resolvePlanningSessionModel, + resolveValidatorSessionModel, } from "../agent-session-helpers.js"; const assignedAgentRuntimeConfig = { @@ -33,6 +34,10 @@ describe("agent-session-helpers test mode overrides", () => { provider: "mock", modelId: "scripted", }); + expect(resolveValidatorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({ + provider: "mock", + modelId: "scripted", + }); expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({ provider: "mock", modelId: "scripted", @@ -65,6 +70,10 @@ describe("agent-session-helpers test mode overrides", () => { provider: "mock", modelId: "scripted", }); + expect(resolveValidatorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({ + provider: "mock", + modelId: "scripted", + }); expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({ provider: "mock", modelId: "scripted", @@ -77,7 +86,7 @@ describe("agent-session-helpers test mode overrides", () => { }); }); - it("keeps existing behavior when test mode is inactive", () => { + it("prefers freshly resolved settings over runtimeConfig when test mode is inactive", () => { const settings = { executionProvider: "openai", executionModelId: "gpt-4.1", @@ -90,22 +99,26 @@ describe("agent-session-helpers test mode overrides", () => { }; expect(resolveExecutorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({ - provider: "anthropic", - modelId: "claude-sonnet-4-5", + provider: "task-provider", + modelId: "task-model", }); expect(resolvePlanningSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({ - provider: "anthropic", - modelId: "claude-sonnet-4-5", + provider: "task-provider", + modelId: "task-model", + }); + expect(resolveValidatorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({ + provider: "task-provider", + modelId: "task-model", }); expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({ - provider: "anthropic", - modelId: "claude-sonnet-4-5", + provider: "openai", + modelId: "gpt-4.1", }); expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({ - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - fallbackProvider: "openai", - fallbackModelId: "gpt-4.1", + defaultProvider: "openai", + defaultModelId: "gpt-4.1", + fallbackProvider: undefined, + fallbackModelId: undefined, }); }); }); diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index c249713205..fe4958be2d 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { extractRuntimeHint, + extractRuntimeModel, + resolveExecutorSessionModel, resolveHeartbeatSessionModels, resolveMergerSessionModel, + resolvePlanningSessionModel, + resolveValidatorSessionModel, } from "../agent-session-helpers.js"; const { resolveRuntimeMock } = vi.hoisted(() => ({ @@ -39,29 +43,71 @@ describe("extractRuntimeHint", () => { }); }); -describe("resolveHeartbeatSessionModels", () => { - it("uses agent runtime model as primary and execution settings as fallback", () => { - expect(resolveHeartbeatSessionModels( - { - executionProvider: "openai", - executionModelId: "gpt-4.1", - }, - { model: "anthropic/claude-sonnet-4-5" }, - )).toEqual({ - defaultProvider: "anthropic", - defaultModelId: "claude-sonnet-4-5", - fallbackProvider: "openai", - fallbackModelId: "gpt-4.1", +describe("extractRuntimeModel", () => { + it("parses combined and separate runtime model pairs", () => { + expect(extractRuntimeModel({ model: " anthropic/claude-sonnet-4-5 " })).toEqual({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }); + expect(extractRuntimeModel({ modelProvider: " openai ", modelId: " gpt-4.1 " })).toEqual({ + provider: "openai", + modelId: "gpt-4.1", }); }); - it("uses execution settings model when runtime override is missing", () => { + it("does not turn malformed combined strings into complete model pairs", () => { + expect(extractRuntimeModel({ model: "gpt 5.3" })).toEqual({ + provider: undefined, + modelId: undefined, + }); + }); +}); + +describe("resolve session model parity", () => { + const settings = { + executionProvider: "openai", + executionModelId: "gpt-4.1", + planningProvider: "anthropic", + planningModelId: "claude-sonnet-4-5", + defaultProviderOverride: "google", + defaultModelIdOverride: "gemini-2.5-pro", + defaultProvider: "zai", + defaultModelId: "glm-5.1", + }; + + it("uses the same fresh settings model for executor and heartbeat when runtimeConfig is absent", () => { + const executor = resolveExecutorSessionModel(undefined, undefined, settings); + const heartbeat = resolveHeartbeatSessionModels(settings); + + expect(executor).toEqual({ provider: "openai", modelId: "gpt-4.1" }); + expect(heartbeat).toEqual({ + defaultProvider: executor.provider, + defaultModelId: executor.modelId, + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + }); + + it("ignores partial runtimeConfig pairs without mixing runtime and settings fields", () => { expect(resolveHeartbeatSessionModels( { executionProvider: "openai", executionModelId: "gpt-4.1", }, - {}, + { modelProvider: "stale-provider" }, + )).toEqual({ + defaultProvider: "openai", + defaultModelId: "gpt-4.1", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + + expect(resolveHeartbeatSessionModels( + { + executionProvider: "openai", + executionModelId: "gpt-4.1", + }, + { modelId: "gpt 5.3" }, )).toEqual({ defaultProvider: "openai", defaultModelId: "gpt-4.1", @@ -70,19 +116,300 @@ describe("resolveHeartbeatSessionModels", () => { }); }); - it("does not duplicate fallback when runtime and execution model are the same", () => { - expect(resolveHeartbeatSessionModels( - { - executionProvider: "openai", - executionModelId: "gpt-4.1", - }, - { modelProvider: "openai", modelId: "gpt-4.1" }, - )).toEqual({ + it("does not let a stale complete runtime model mask newer task or settings models", () => { + const staleRuntimeConfig = { model: "openai-codex/gpt-5.3-codex" }; + + expect(resolveExecutorSessionModel("task-provider", "task-model", settings, staleRuntimeConfig)).toEqual({ + provider: "task-provider", + modelId: "task-model", + }); + expect(resolveExecutorSessionModel(undefined, undefined, settings, staleRuntimeConfig)).toEqual({ + provider: "openai", + modelId: "gpt-4.1", + }); + expect(resolvePlanningSessionModel("planning-task-provider", "planning-task-model", settings, staleRuntimeConfig)).toEqual({ + provider: "planning-task-provider", + modelId: "planning-task-model", + }); + expect(resolvePlanningSessionModel(undefined, undefined, settings, staleRuntimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }); + expect(resolveHeartbeatSessionModels(settings, staleRuntimeConfig)).toEqual({ defaultProvider: "openai", defaultModelId: "gpt-4.1", fallbackProvider: undefined, fallbackModelId: undefined, }); + expect(resolveValidatorSessionModel("validator-task-provider", "validator-task-model", settings, staleRuntimeConfig)).toEqual({ + provider: "validator-task-provider", + modelId: "validator-task-model", + }); + expect(resolveValidatorSessionModel(undefined, undefined, { + ...settings, + validatorProvider: "google", + validatorModelId: "gemini-2.5-pro", + }, staleRuntimeConfig)).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + expect(resolveMergerSessionModel(settings, staleRuntimeConfig)).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + }); + + it("does not leak malformed gpt 5.3-style runtimeConfig into any automatic lane", () => { + const malformedRuntimeConfig = { modelId: "gpt 5.3" }; + + expect(resolveExecutorSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({ + provider: "openai", + modelId: "gpt-4.1", + }); + expect(resolvePlanningSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }); + expect(resolveHeartbeatSessionModels(settings, malformedRuntimeConfig)).toEqual({ + defaultProvider: "openai", + defaultModelId: "gpt-4.1", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + expect(resolveValidatorSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + expect(resolveMergerSessionModel(settings, malformedRuntimeConfig)).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + }); + + it("falls back through project override and global defaults before runtimeConfig", () => { + const staleRuntimeConfig = { model: "stale-provider/stale-model" }; + + expect(resolveMergerSessionModel({ + defaultProviderOverride: "google", + defaultModelIdOverride: "gemini-2.5-pro", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }, staleRuntimeConfig)).toEqual({ provider: "google", modelId: "gemini-2.5-pro" }); + expect(resolveMergerSessionModel({ + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }, staleRuntimeConfig)).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); + }); + + it("uses a complete runtime model only when no lane/task/default model is configured", () => { + const runtimeConfig = { modelProvider: "anthropic", modelId: "claude-opus-4" }; + + expect(resolveExecutorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4", + }); + expect(resolvePlanningSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4", + }); + expect(resolveHeartbeatSessionModels(undefined, runtimeConfig)).toEqual({ + defaultProvider: "anthropic", + defaultModelId: "claude-opus-4", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + expect(resolveValidatorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4", + }); + expect(resolveMergerSessionModel(undefined, runtimeConfig)).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4", + }); + }); + + it("covers backend-only surfaces; desktop and mobile breakpoints are not applicable", () => { + expect(resolveExecutorSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({ + provider: "openai", + modelId: "gpt-4.1", + }); + expect(resolvePlanningSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }); + expect(resolveValidatorSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + expect(resolveMergerSessionModel(settings, { model: "stale/old" })).toEqual({ + provider: "google", + modelId: "gemini-2.5-pro", + }); + }); +}); + +describe("project model override precedence invariant", () => { + const staleRuntimeConfig = { model: "stale-provider/stale-model" }; + const partialRuntimeConfigs: Array<Record<string, unknown>> = [ + { modelProvider: "stale-provider" }, + { modelId: "stale-model" }, + { model: "stale-provider" }, + ]; + + const sessionCases = [ + { + label: "executor", + settings: { executionProvider: "project-exec-provider", executionModelId: "project-exec-model" }, + resolve: (runtimeConfig?: Record<string, unknown>) => + resolveExecutorSessionModel(undefined, undefined, { + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + }, runtimeConfig), + expected: { provider: "project-exec-provider", modelId: "project-exec-model" }, + }, + { + label: "planning", + settings: { planningProvider: "project-plan-provider", planningModelId: "project-plan-model" }, + resolve: (runtimeConfig?: Record<string, unknown>) => + resolvePlanningSessionModel(undefined, undefined, { + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + }, runtimeConfig), + expected: { provider: "project-plan-provider", modelId: "project-plan-model" }, + }, + { + label: "validator", + settings: { validatorProvider: "project-validator-provider", validatorModelId: "project-validator-model" }, + resolve: (runtimeConfig?: Record<string, unknown>) => + resolveValidatorSessionModel(undefined, undefined, { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + }, runtimeConfig), + expected: { provider: "project-validator-provider", modelId: "project-validator-model" }, + }, + { + label: "heartbeat execution lane", + settings: { executionProvider: "project-heartbeat-provider", executionModelId: "project-heartbeat-model" }, + resolve: (runtimeConfig?: Record<string, unknown>) => { + const resolved = resolveHeartbeatSessionModels({ + executionProvider: "project-heartbeat-provider", + executionModelId: "project-heartbeat-model", + }, runtimeConfig); + return { provider: resolved.defaultProvider, modelId: resolved.defaultModelId }; + }, + expected: { provider: "project-heartbeat-provider", modelId: "project-heartbeat-model" }, + }, + { + label: "merger default lane", + settings: { defaultProviderOverride: "project-default-provider", defaultModelIdOverride: "project-default-model" }, + resolve: (runtimeConfig?: Record<string, unknown>) => + resolveMergerSessionModel({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig), + expected: { provider: "project-default-provider", modelId: "project-default-model" }, + }, + ]; + + it.each(sessionCases)("$label project override wins when runtimeConfig is absent, complete, or partial", ({ resolve, expected }) => { + expect(resolve()).toEqual(expected); + expect(resolve(staleRuntimeConfig)).toEqual(expected); + for (const partialRuntimeConfig of partialRuntimeConfigs) { + expect(resolve(partialRuntimeConfig)).toEqual(expected); + } + }); + + it("per-task overrides still outrank saved project lane overrides", () => { + const runtimeConfig = { model: "stale-provider/stale-model" }; + + expect(resolveExecutorSessionModel("task-provider", "task-model", { + executionProvider: "project-provider", + executionModelId: "project-model", + }, runtimeConfig)).toEqual({ provider: "task-provider", modelId: "task-model" }); + expect(resolvePlanningSessionModel("task-planning-provider", "task-planning-model", { + planningProvider: "project-planning-provider", + planningModelId: "project-planning-model", + }, runtimeConfig)).toEqual({ provider: "task-planning-provider", modelId: "task-planning-model" }); + expect(resolveValidatorSessionModel("task-validator-provider", "task-validator-model", { + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + }, runtimeConfig)).toEqual({ provider: "task-validator-provider", modelId: "task-validator-model" }); + }); + + it("falls back to global lanes and global defaults only when project lanes are unset", () => { + const runtimeConfig = { model: "stale-provider/stale-model" }; + + expect(resolveExecutorSessionModel(undefined, undefined, { + executionGlobalProvider: "global-exec-provider", + executionGlobalModelId: "global-exec-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "global-exec-provider", modelId: "global-exec-model" }); + expect(resolvePlanningSessionModel(undefined, undefined, { + planningGlobalProvider: "global-plan-provider", + planningGlobalModelId: "global-plan-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "global-plan-provider", modelId: "global-plan-model" }); + expect(resolveValidatorSessionModel(undefined, undefined, { + validatorGlobalProvider: "global-validator-provider", + validatorGlobalModelId: "global-validator-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-validator-provider", modelId: "global-validator-model" }); + expect(resolveMergerSessionModel({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + }); + + it("uses complete runtimeConfig only after project defaults and globals are absent", () => { + const runtimeConfig = { model: "runtime-provider/runtime-model" }; + + expect(resolveExecutorSessionModel(undefined, undefined, { + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }, runtimeConfig)).toEqual({ provider: "project-default-provider", modelId: "project-default-model" }); + expect(resolvePlanningSessionModel(undefined, undefined, { + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }, runtimeConfig)).toEqual({ provider: "global-default-provider", modelId: "global-default-model" }); + expect(resolveValidatorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({ + provider: "runtime-provider", + modelId: "runtime-model", + }); + expect(resolveHeartbeatSessionModels(undefined, runtimeConfig)).toEqual({ + defaultProvider: "runtime-provider", + defaultModelId: "runtime-model", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); + }); + + it("forces mock/scripted across session surfaces when testMode or mock default is active", () => { + const runtimeConfig = { model: "runtime-provider/runtime-model" }; + const testModeSettings = { + testMode: true, + executionProvider: "project-exec-provider", + executionModelId: "project-exec-model", + planningProvider: "project-plan-provider", + planningModelId: "project-plan-model", + validatorProvider: "project-validator-provider", + validatorModelId: "project-validator-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + }; + + expect(resolveExecutorSessionModel("task-provider", "task-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolvePlanningSessionModel("task-plan-provider", "task-plan-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveValidatorSessionModel("task-validator-provider", "task-validator-model", testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveMergerSessionModel(testModeSettings, runtimeConfig)).toEqual({ provider: "mock", modelId: "scripted" }); + expect(resolveHeartbeatSessionModels({ defaultProvider: "mock", defaultModelId: "global-default-model" }, runtimeConfig)).toEqual({ + defaultProvider: "mock", + defaultModelId: "scripted", + fallbackProvider: undefined, + fallbackModelId: undefined, + }); }); }); @@ -220,15 +547,10 @@ describe("createResolvedAgentSession", () => { }); describe("resolveMergerSessionModel", () => { - it("uses assigned agent runtime model when both provider and modelId are present", () => { + it("uses assigned agent runtime model only when no default model pair is configured", () => { expect( resolveMergerSessionModel( - { - defaultProviderOverride: "openai", - defaultModelIdOverride: "gpt-4.1", - defaultProvider: "anthropic", - defaultModelId: "claude-3-5-sonnet", - }, + {}, { model: " anthropic/claude-3-5-sonnet-20241022 " }, ), ).toEqual({ diff --git a/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts b/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts new file mode 100644 index 0000000000..3713b786c2 --- /dev/null +++ b/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { ProjectEngine } from "../project-engine.js"; +import { runtimeLog } from "../logger.js"; +import type { Settings, Task } from "@fusion/core"; + +function makeSettings(autoMerge: boolean): Settings { + return { + autoMerge, + globalPause: false, + enginePaused: false, + maintenanceIntervalMs: 900_000, + } as Settings; +} + +function makeEngineHarness(tasks: Task[]) { + const events = new EventEmitter(); + const auditEvents: unknown[] = []; + const store = Object.assign(events, { + listTasks: vi.fn(async ({ column }: { column?: string } = {}) => tasks.filter((task) => !column || task.column === column)), + recordRunAuditEvent: vi.fn((event: unknown) => { + auditEvents.push(event); + return event; + }), + updateTask: vi.fn(), + moveTask: vi.fn(), + pauseTask: vi.fn(), + }); + const engine = Object.create(ProjectEngine.prototype) as ProjectEngine & { + settingsHandlers: Array<(payload: { settings: Settings; previous: Settings }) => Promise<void> | void>; + legacyAutoMergeStampAdvisoryEmitted: boolean; + mergeAbortController: AbortController | null; + activeMergeSession: null; + scheduleMergeActiveReconciliation: (intervalMs: number) => void; + }; + engine.settingsHandlers = []; + engine.legacyAutoMergeStampAdvisoryEmitted = false; + engine.mergeAbortController = null; + engine.activeMergeSession = null; + engine.scheduleMergeActiveReconciliation = vi.fn(); + (engine as any).runtime = {}; + (engine as any).automationStore = null; + (engine as any).wireSettingsListeners(store); + return { engine, store, auditEvents }; +} + +describe("auto-merge toggle legacy advisory", () => { + it("emits an operator advisory on global autoMerge OFF for legacy in-review stamps without mutating tasks", async () => { + const legacy = { + id: "FN-LEGACY", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + } as Task; + const absent = { + id: "FN-ABSENT", + column: "in-review", + autoMerge: true, + } as Task; + const user = { + id: "FN-USER", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "user", + } as Task; + const todoLegacy = { + id: "FN-TODO", + column: "todo", + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + } as Task; + const { engine, store, auditEvents } = makeEngineHarness([legacy, absent, user, todoLegacy]); + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + + try { + const autoMergeOffHandler = engine.settingsHandlers[2]; + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(true) }); + + expect(store.listTasks).toHaveBeenCalledWith({ column: "in-review" }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("FN-LEGACY"); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("FN-ABSENT"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("FN-USER"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("FN-TODO"); + + expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1); + expect(auditEvents[0]).toMatchObject({ + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-advisory", + target: "settings.autoMerge", + metadata: { + taskIds: ["FN-LEGACY", "FN-ABSENT"], + changedTaskState: false, + }, + }); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.pauseTask).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("does not advise for genuine user overrides or non-off transitions", async () => { + const user = { + id: "FN-USER", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "user", + } as Task; + const { engine, store } = makeEngineHarness([user]); + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + + try { + const autoMergeOffHandler = engine.settingsHandlers[2]; + await autoMergeOffHandler?.({ settings: makeSettings(true), previous: makeSettings(false) }); + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(false) }); + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(true) }); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/packages/engine/src/__tests__/cli-agent-executor.test.ts b/packages/engine/src/__tests__/cli-agent-executor.test.ts index d3f9609a0d..03954e2386 100644 --- a/packages/engine/src/__tests__/cli-agent-executor.test.ts +++ b/packages/engine/src/__tests__/cli-agent-executor.test.ts @@ -287,7 +287,7 @@ describe("cli-agent executor seam (U7)", () => { it("re-entry: a fresh run kills the prior live session and spawns a new PTY", async () => { const { executor } = makeExecutor(taskDetail()); // First run, left live (no done). - void (executor as any).runGraphCustomNode(cliNode, taskDetail(), {}); + const firstP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {}); await vi.waitFor(() => expect(state.ptys).toHaveLength(1)); lastPty().emitData("READY\r\n"); const firstId = await vi.waitFor(() => { @@ -299,6 +299,8 @@ describe("cli-agent executor seam (U7)", () => { // Let the first run's async injection settle (it drives the machine to busy // and would otherwise overwrite the killed reason mid-race). await vi.waitFor(() => expect(hub.getStateMachine(firstId)?.getState()).toBe("busy")); + const firstSession = (executor as any).activeCliTaskSessions.get("FN-100"); + expect(firstSession?.sessionId).toBe(firstId); // Drop the first run's active handle to simulate a graph re-entry without abort. (executor as any).activeCliTaskSessions.delete("FN-100"); @@ -314,6 +316,12 @@ describe("cli-agent executor seam (U7)", () => { hub.ingest(second.id, { kind: "done" }); const result = await secondP; expect(result.outcome).toBe("success"); + + // FN-6341: the original flake left this first run as a dropped `void` promise; + // settle the task-session after proving re-entry killed its PTY so no hub/store + // work can outlive afterEach's db.close(). + await firstSession.kill("killed"); + await expect(firstP).resolves.toMatchObject({ outcome: "failure", value: "cli-agent-killed" }); }); // ── Ceiling produces a typed surfaced value, not a hang ────────────────────── diff --git a/packages/engine/src/__tests__/docs-evidence-example.test.ts b/packages/engine/src/__tests__/docs-evidence-example.test.ts new file mode 100644 index 0000000000..b15b106938 --- /dev/null +++ b/packages/engine/src/__tests__/docs-evidence-example.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { detectExternalIntegrationEvidenceGaps } from "../spec-validation/external-integration-evidence.js"; + +const workspaceRoot = resolve(import.meta.dirname, "../../../.."); +const contributingPath = resolve(workspaceRoot, "docs", "contributing.md"); + +function extractEvidenceExample(): string { + const contributing = readFileSync(contributingPath, "utf8"); + const match = contributing.match( + /<!-- evidence-example:start -->([\s\S]*?)<!-- evidence-example:end -->/, + ); + expect(match?.[1]).toBeDefined(); + + const fenced = match?.[1]?.trim() ?? ""; + const fenceMatch = fenced.match(/^```markdown\r?\n([\s\S]*?)\r?\n```$/); + expect(fenceMatch?.[1]).toBeDefined(); + return fenceMatch?.[1] ?? ""; +} + +describe("documented external integration evidence example", () => { + it("satisfies the spec-validation gate", () => { + const example = extractEvidenceExample(); + + expect(detectExternalIntegrationEvidenceGaps({ promptContent: example })).toEqual([]); + }); + + it("fails the gate when checksum evidence is removed", () => { + const example = extractEvidenceExample(); + const withoutChecksum = example.replace(/^- Checksum:.*$/m, "- Checksum:"); + + const findings = detectExternalIntegrationEvidenceGaps({ promptContent: withoutChecksum }); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]?.missing).toContain("checksum-or-source-of-truth-evidence"); + }); +}); diff --git a/packages/engine/src/__tests__/executor-branch-canonicalization.test.ts b/packages/engine/src/__tests__/executor-branch-canonicalization.test.ts index 3e43a42cf0..fdd3af0767 100644 --- a/packages/engine/src/__tests__/executor-branch-canonicalization.test.ts +++ b/packages/engine/src/__tests__/executor-branch-canonicalization.test.ts @@ -6,4 +6,26 @@ describe("executor branch canonicalization", () => { expect(canonicalFusionBranchName("FN-5083")).toBe("fusion/fn-5083"); expect(canonicalFusionBranchName("Fn-ABC-123")).toBe("fusion/fn-abc-123"); }); + + it("returns the canonical lowercase branch form for standard and case-only variant task IDs", () => { + expect(canonicalFusionBranchName("FN-6383")).toBe("fusion/fn-6383"); + expect(canonicalFusionBranchName("Fn-ABC-123")).toBe("fusion/fn-abc-123"); + expect(canonicalFusionBranchName("FUSION-001")).toBe("fusion/fusion-001"); + }); + + it("preserves already-lowercase task ids and documents that callers must not pass branch names", () => { + expect(canonicalFusionBranchName("fn-6383")).toBe("fusion/fn-6383"); + expect(canonicalFusionBranchName("fusion/fn-1")).toBe("fusion/fusion/fn-1"); + }); + + it("lowercases arbitrary task-id shapes without slugifying or trimming characters", () => { + expect(canonicalFusionBranchName("TASK_42")).toBe("fusion/task_42"); + expect(canonicalFusionBranchName("feature/Foo")).toBe("fusion/feature/foo"); + }); + + it("pins malformed and edge inputs to prefix-plus-lowercase behavior", () => { + expect(canonicalFusionBranchName("")).toBe("fusion/"); + expect(canonicalFusionBranchName(" ")).toBe("fusion/ "); + expect(canonicalFusionBranchName("ABC123XYZ")).toBe("fusion/abc123xyz"); + }); }); diff --git a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts new file mode 100644 index 0000000000..bb066772e3 --- /dev/null +++ b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts @@ -0,0 +1,280 @@ +// @ts-nocheck +// FN-6226 surface enumeration: engine-only behavior, so desktop/mobile +// breakpoints are N/A. These tests cover legacy seams, graph runtime +// primitives, custom graph prompt/script/gate nodes under a custom workflow +// selection, builtin/default selection behavior via the legacy seam, fast / +// standard / undefined executionMode data states, and the executor tool +// injection surface for fn_review_step vs mandatory fn_task_done. +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "./executor-test-helpers.js"; +import { getBuiltinWorkflow } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { WorkflowGraphTaskRunner } from "../workflow-graph-task-runner.js"; +import { + createMockStore, + mockedCreateFnAgent, + mockedExistsSync, + resetExecutorMocks, +} from "./executor-test-helpers.js"; + +const now = "2026-06-10T00:00:00.000Z"; + +function task(overrides: Record<string, unknown> = {}) { + return { + id: "FN-6226", + title: "Fast mode workflow task", + description: "exercise fast mode", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + prompt: "# Task\n## Steps\n### Step 1\n- [ ] do it", + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function makeExecutorForTask(liveTask = task()) { + const store = createMockStore(); + store.getTask.mockImplementation(async (id: string) => ({ ...liveTask, id })); + store.getSettings.mockResolvedValue({ + autoMerge: false, + experimentalFeatures: { workflowGraphExecutor: true }, + }); + return { store, executor: new TaskExecutor(store, "/tmp/test") }; +} + +function workflowResult() { + return { allPassed: true, results: [] }; +} + +describe("fast mode workflow/runtime invariants", () => { + beforeEach(() => { + resetExecutorMocks(); + mockedExistsSync.mockReturnValue(true); + }); + + it("graph executor with a custom workflow skips custom pre-merge prompt/gate nodes in fast mode", async () => { + const { store, executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); + const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true }); + const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true }); + + const definition = { + id: "WF-fast-custom", + name: "Fast custom", + description: "custom workflow", + kind: "workflow", + layout: {}, + createdAt: now, + updatedAt: now, + ir: { + version: "v1", + name: "Fast custom", + nodes: [ + { id: "start", kind: "start" }, + { id: "custom-review", kind: "prompt", config: { prompt: "Review this" } }, + { id: "custom-gate", kind: "gate", config: { prompt: "Gate this", gateMode: "gate" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "custom-review" }, + { from: "custom-review", to: "custom-gate" }, + { from: "custom-gate", to: "end" }, + ], + }, + }; + + const runner = new WorkflowGraphTaskRunner({ + store: { + getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-custom", stepIds: [] }), + getWorkflowDefinition: vi.fn(async () => definition), + }, + seams: (executor as any).createAuthoritativeWorkflowSeams({}), + primitives: (executor as any).createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } }), + runCustomNode: (node, nodeTask, context) => (executor as any).runGraphCustomNode(node, nodeTask, {}, undefined), + }); + + const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } }); + + expect(result.disposition).toBe("completed"); + expect(result.visitedNodeIds).toEqual(["start", "custom-review", "custom-gate"]); + expect(executeStep).not.toHaveBeenCalled(); + expect(executeScript).not.toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-6226", + "Fast mode — custom graph node 'custom-review' skipped", + undefined, + undefined, + ); + }); + + it("graph executor with builtin:coding selection skips the workflow-step seam in fast mode", async () => { + const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); + const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); + const seams = { + planning: vi.fn(async () => ({ outcome: "success", value: "planned" })), + execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })), + workflowStep: (executor as any).createAuthoritativeWorkflowSeams({}).workflowStep, + review: vi.fn(async () => ({ outcome: "success", value: "approved" })), + merge: vi.fn(async () => ({ outcome: "success", value: "merged" })), + schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })), + }; + const runner = new WorkflowGraphTaskRunner({ + store: { + getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }), + getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)), + }, + seams, + runCustomNode: vi.fn(async () => ({ outcome: "failure", value: "unexpected-custom-node" })), + }); + + const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } }); + + expect(result.disposition).toBe("completed"); + expect(result.visitedNodeIds).toContain("workflow-step"); + expect(runWorkflowSteps).not.toHaveBeenCalled(); + expect(seams.review).toHaveBeenCalledTimes(1); + expect(seams.merge).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["standard", "standard"], + ["undefined", undefined], + ["null", null], + ])("runs custom pre-merge prompt nodes in %s execution mode", async (_label, executionMode) => { + const { executor } = makeExecutorForTask(task({ executionMode, worktree: "/tmp/wt" })); + const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true }); + + const result = await (executor as any).runGraphCustomNode( + { id: "custom-review", kind: "prompt", config: { prompt: "Review this" } }, + task({ executionMode }), + {}, + undefined, + ); + + expect(result.outcome).toBe("success"); + expect(result.value).toBe("passed"); + expect(executeStep).toHaveBeenCalledTimes(1); + }); + + it.each(["prompt", "script", "gate"])("skips custom %s nodes in fast mode before workflow-step execution", async (kind) => { + const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); + const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true }); + const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true }); + const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" }; + + const result = await (executor as any).runGraphCustomNode( + { id: `custom-${kind}`, kind, config }, + task({ executionMode: "fast" }), + {}, + undefined, + ); + + expect(result).toMatchObject({ outcome: "success", value: "workflow-step-skipped" }); + expect(executeStep).not.toHaveBeenCalled(); + expect(executeScript).not.toHaveBeenCalled(); + }); + + it("does not bypass await-input custom graph nodes in fast mode", async () => { + const { executor } = makeExecutorForTask(task({ executionMode: "fast" })); + const awaitInput = vi.spyOn(executor as any, "runAwaitInputNode").mockResolvedValue({ outcome: "success", value: "awaiting-input" }); + + const result = await (executor as any).runGraphCustomNode( + { id: "human", kind: "prompt", config: { awaitInput: true } }, + task({ executionMode: "fast" }), + {}, + undefined, + ); + + expect(result.value).toBe("awaiting-input"); + expect(awaitInput).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["legacy seam", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowSeams(settings).workflowStep(task({ id: "FN-6226" }), {})], + ["graph primitive", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowPrimitives(settings).runWorkflowStep( + { run: { taskId: "FN-6226" }, node: { node: { id: "workflow-step" }, context: {} } }, + task({ id: "FN-6226" }), + { phase: "pre-merge", worktreePath: "/tmp/wt" }, + )], + ])("%s skips pre-merge workflow steps in fast mode", async (_label, invoke) => { + const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); + const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); + + const result = await invoke(executor, { experimentalFeatures: { workflowGraphExecutor: true } }); + + expect(result.outcome).toBe("success"); + expect(result.value).toBe("workflow-step-skipped"); + expect(runWorkflowSteps).not.toHaveBeenCalled(); + }); + + it.each([ + ["legacy seam", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowSeams(settings).workflowStep(task({ id: "FN-6226" }), {})], + ["graph primitive", (executor: TaskExecutor, settings: any) => (executor as any).createAuthoritativeWorkflowPrimitives(settings).runWorkflowStep( + { run: { taskId: "FN-6226" }, node: { node: { id: "workflow-step" }, context: {} } }, + task({ id: "FN-6226" }), + { phase: "pre-merge", worktreePath: "/tmp/wt" }, + )], + ])("%s runs pre-merge workflow steps for standard and default execution modes", async (_label, invoke) => { + for (const executionMode of ["standard", undefined]) { + const { executor } = makeExecutorForTask(task({ executionMode, worktree: "/tmp/wt" })); + const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult()); + + const result = await invoke(executor, { experimentalFeatures: { workflowGraphExecutor: true } }); + + expect(result.outcome).toBe("success"); + expect(runWorkflowSteps).toHaveBeenCalledTimes(1); + } + }); + + it("keeps fn_task_done mandatory while excluding fn_review_step in fast mode", async () => { + mockedCreateFnAgent.mockImplementation(async (opts: any) => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + sessionManager: { + getLeafId: vi.fn().mockReturnValue("leaf"), + branchWithSummary: vi.fn(), + navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), + }, + navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), + }, + capturedTools: opts.customTools, + })); + const store = createMockStore(); + store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "fast" })); + const executor = new TaskExecutor(store, "/tmp/test"); + + await executor.execute(task({ id: "FN-TOOLS", executionMode: "fast" })); + + const tools = mockedCreateFnAgent.mock.calls[0][0].customTools.map((tool: any) => tool.name); + expect(tools).toContain("fn_task_done"); + expect(tools).not.toContain("fn_review_step"); + }); + + it("includes fn_review_step in standard mode", async () => { + mockedCreateFnAgent.mockImplementation(async (opts: any) => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + sessionManager: { + getLeafId: vi.fn().mockReturnValue("leaf"), + branchWithSummary: vi.fn(), + navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), + }, + navigateTree: vi.fn().mockResolvedValue({ cancelled: false }), + }, + capturedTools: opts.customTools, + })); + const store = createMockStore(); + store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "standard" })); + const executor = new TaskExecutor(store, "/tmp/test"); + + await executor.execute(task({ id: "FN-TOOLS", executionMode: "standard" })); + + const tools = mockedCreateFnAgent.mock.calls[0][0].customTools.map((tool: any) => tool.name); + expect(tools).toContain("fn_review_step"); + }); +}); diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index 112ca63678..2067d04890 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -9,11 +9,12 @@ import { createFnAgent } from "../pi.js"; import { reviewStep as mockedReviewStepFn } from "../reviewer.js"; import { execSync } from "node:child_process"; import { findWorktreeUser, aiMergeTask } from "../merger.js"; -import { WorktreePool } from "../worktree-pool.js"; +import { WorktreePool, removeWorktree } from "../worktree-pool.js"; import { generateWorktreeName, slugify } from "../worktree-names.js"; import type { Task, TaskDetail } from "@fusion/core"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { StepSessionExecutor } from "../step-session-executor.js"; +import { executingTaskLock } from "../active-session-registry.js"; import { executorLog } from "../logger.js"; import { withRateLimitRetry } from "../rate-limit-retry.js"; import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js"; @@ -565,6 +566,251 @@ describe("TaskExecutor bounded recovery retries", () => { ); }); + it("force-requeue timeout reaps hung in-flight surfaces and removes the worktree before clearing guards", async () => { + vi.useFakeTimers(); + try { + const store = createMockStore(); + const agentStore = { + updateAgentState: vi.fn().mockResolvedValue(undefined), + deleteAgent: vi.fn().mockResolvedValue(undefined), + }; + const executor = new TaskExecutor(store, "/tmp/test", { agentStore: agentStore as any }); + const taskId = "FN-001"; + const worktreePath = "/tmp/test/.worktrees/FN-001"; + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + const workflowSession = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + const stepExecutor = { + abortAllSessionBash: vi.fn(), + terminateAllSessions: vi.fn().mockResolvedValue(undefined), + }; + const controller = new AbortController(); + const controllerAbort = vi.spyOn(controller, "abort"); + const subagent = { dispose: vi.fn(), state: {} }; + const cliSession = { kill: vi.fn().mockResolvedValue(undefined) }; + const childSession = { dispose: vi.fn(), state: {} }; + vi.mocked(removeWorktree).mockResolvedValue(undefined as any); + store.getTask.mockResolvedValue({ + id: taskId, + title: "Test", + description: "Test task", + column: "in-progress", + worktree: worktreePath, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + (executor as any).executing.add(taskId); + executingTaskLock.tryClaim(taskId); + (executor as any).activeWorktrees.set(taskId, worktreePath); + (executor as any).activeSessions.set(taskId, { session }); + (executor as any).activeStepExecutors.set(taskId, stepExecutor); + (executor as any).activeWorkflowStepSessions.set(taskId, workflowSession); + (executor as any).activeConfiguredCommandControllers.set(taskId, new Set([controller])); + (executor as any).activeSubagentSessions.set(taskId, new Set([subagent])); + (executor as any).activeCliTaskSessions.set(taskId, cliSession); + (executor as any).spawnedAgents.set(taskId, new Set(["child-agent"])); + (executor as any).childSessions.set("child-agent", childSession); + (executor as any).loopRecoveryState.set(taskId, { attempts: 1, pending: true }); + + executor.markStuckAborted(taskId, true); + await vi.advanceTimersByTimeAsync(60_000); + + expect(agentStore.updateAgentState).toHaveBeenCalledWith("child-agent", "paused"); + expect(agentStore.deleteAgent).toHaveBeenCalledWith("child-agent"); + expect(childSession.dispose).toHaveBeenCalledTimes(1); + expect(session.abort).toHaveBeenCalledTimes(1); + expect(session.dispose).toHaveBeenCalledTimes(1); + expect(stepExecutor.abortAllSessionBash).toHaveBeenCalledTimes(1); + expect(stepExecutor.terminateAllSessions).toHaveBeenCalled(); + expect(workflowSession.abort).toHaveBeenCalledTimes(1); + expect(workflowSession.dispose).toHaveBeenCalledTimes(1); + expect(controllerAbort).toHaveBeenCalledTimes(1); + expect(subagent.dispose).toHaveBeenCalledTimes(1); + expect(cliSession.kill).toHaveBeenCalledWith("killed"); + expect(removeWorktree).toHaveBeenCalledWith(expect.objectContaining({ + worktreePath, + rootDir: "/tmp/test", + taskId, + expectedOwnerTaskId: taskId, + })); + expect(store.updateTask).toHaveBeenCalledWith(taskId, { + status: "queued", + error: null, + worktree: null, + branch: null, + }); + expect(store.moveTask).toHaveBeenCalledWith(taskId, "todo", { preserveProgress: true }); + expect(session.abort.mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(removeWorktree).mock.invocationCallOrder[0]); + expect(vi.mocked(removeWorktree).mock.invocationCallOrder[0]).toBeLessThan(store.moveTask.mock.invocationCallOrder[0]); + const cleanupCompleteLogIndex = store.logEntry.mock.calls.findIndex(([, message]: any[]) => String(message).includes("Force-kill cleanup completed")); + expect(cleanupCompleteLogIndex).toBeGreaterThanOrEqual(0); + expect(store.moveTask.mock.invocationCallOrder[0]).toBeLessThan(store.logEntry.mock.invocationCallOrder[cleanupCompleteLogIndex]); + expect((executor as any).activeWorktrees.has(taskId)).toBe(false); + expect((executor as any).executing.has(taskId)).toBe(false); + expect(executingTaskLock.has(taskId)).toBe(false); + expect((executor as any).stuckAborted.has(taskId)).toBe(false); + expect((executor as any).loopRecoveryState.has(taskId)).toBe(false); + expect((executor as any).pausedAborted.has(taskId)).toBe(false); + expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-kill cleanup starting")); + expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-requeued after stuck-kill")); + expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("progress preserved")); + expect(store.logEntry).toHaveBeenCalledWith(taskId, expect.stringContaining("Force-kill cleanup completed")); + } finally { + vi.useRealTimers(); + executingTaskLock._clearForTest(); + } + }); + + it("force-requeue timeout preserves concurrent non-in-progress recovery without reaping surfaces", async () => { + vi.useFakeTimers(); + try { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + store.getTask.mockResolvedValue({ + id: "FN-001", + title: "Test", + description: "Test task", + column: "in-review", + worktree: "/tmp/test/.worktrees/FN-001", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + (executor as any).executing.add("FN-001"); + executingTaskLock.tryClaim("FN-001"); + (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).activeSessions.set("FN-001", { session }); + + executor.markStuckAborted("FN-001", true); + await vi.advanceTimersByTimeAsync(60_000); + + expect(session.abort).not.toHaveBeenCalled(); + expect(session.dispose).not.toHaveBeenCalled(); + expect(removeWorktree).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); + expect((executor as any).executing.has("FN-001")).toBe(false); + expect(executingTaskLock.has("FN-001")).toBe(false); + } finally { + vi.useRealTimers(); + executingTaskLock._clearForTest(); + } + }); + + it("force-requeue timeout no-ops when the executor unwound before the grace timer", async () => { + vi.useFakeTimers(); + try { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + (executor as any).executing.add("FN-001"); + executingTaskLock.tryClaim("FN-001"); + (executor as any).activeSessions.set("FN-001", { session }); + + executor.markStuckAborted("FN-001", true); + (executor as any).executing.delete("FN-001"); + executingTaskLock.release("FN-001"); + await vi.advanceTimersByTimeAsync(60_000); + + expect(session.abort).not.toHaveBeenCalled(); + expect(removeWorktree).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo", expect.anything()); + } finally { + vi.useRealTimers(); + executingTaskLock._clearForTest(); + } + }); + + it("force-requeue timeout logs non-fatal worktree cleanup failures distinctly", async () => { + vi.useFakeTimers(); + try { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + store.getTask.mockResolvedValue({ + id: "FN-001", + title: "Test", + description: "Test task", + column: "in-progress", + worktree: "/tmp/test/.worktrees/FN-001", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy")); + (executor as any).executing.add("FN-001"); + executingTaskLock.tryClaim("FN-001"); + (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).activeSessions.set("FN-001", { session }); + + executor.markStuckAborted("FN-001", true); + await vi.advanceTimersByTimeAsync(60_000); + + expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Force-kill cleanup failed to remove worktree")); + expect(store.logEntry).toHaveBeenCalledWith("FN-001", expect.stringContaining("Force-kill cleanup completed with non-fatal worktree removal failure")); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }); + } finally { + vi.useRealTimers(); + executingTaskLock._clearForTest(); + } + }); + + it("force-requeue timeout honors disabled preserveProgressOnStuckRequeue", async () => { + vi.useFakeTimers(); + try { + const store = createMockStore(); + store.getSettings.mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: false, + worktreeInitCommand: undefined, + preserveProgressOnStuckRequeue: false, + }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const resetSpy = vi.spyOn(executor as any, "resetStepsIfWorkLost").mockResolvedValue(undefined); + const session = { abort: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: {} }; + store.getTask.mockResolvedValue({ + id: "FN-001", + title: "Test", + description: "Test task", + column: "in-progress", + worktree: "/tmp/test/.worktrees/FN-001", + dependencies: [], + steps: [{ name: "step", status: "in-progress" }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + vi.mocked(removeWorktree).mockResolvedValue(undefined as any); + (executor as any).executing.add("FN-001"); + executingTaskLock.tryClaim("FN-001"); + (executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001"); + (executor as any).activeSessions.set("FN-001", { session }); + + executor.markStuckAborted("FN-001", true); + await vi.advanceTimersByTimeAsync(60_000); + + expect(resetSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", undefined); + } finally { + vi.useRealTimers(); + executingTaskLock._clearForTest(); + } + }); + it("does not let a late graph failure clobber a retryable requeue", async () => { const store = createMockStore(); const task = { @@ -698,6 +944,207 @@ describe("TaskExecutor bounded recovery retries", () => { expect(store.handoffToReview).not.toHaveBeenCalled(); }); + it("auto-retries a bounded transient resume-after-restart graph failure instead of parking", async () => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + graphResumeRetryCount: 0, + } as Task; + store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + { graphResumeRetryCount: 1, status: null, error: null }, + undefined, + ); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "failed" }), + expect.anything(), + ); + expect(store.handoffToReview).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); + }); + + it("auto-retries a bounded transient graph failure after unpause resume instead of parking", async () => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + graphResumeRetryCount: 0, + } as Task; + store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + { graphResumeRetryCount: 1, status: null, error: null }, + undefined, + ); + expect(store.handoffToReview).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); + }); + + it("parks a transient resume graph failure once the retry budget is exhausted", async () => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + graphResumeRetryCount: 2, + } as Task; + store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); + const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + + const message = "Workflow graph terminated with failure at node 'execute'"; + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); + expect(store.handoffToReview).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), + ); + expect(executeSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it.each([ + ["non-empty execute-seam reason", { result: { reason: "interpreter-error: boom", visitedNodeIds: ["execute"] } }], + ["settings/workflow-selection reason before node progress", { result: { reason: "settings-load-failed: boom", visitedNodeIds: [] } }], + ["completed step progress", { task: { steps: [{ name: "Step 1", status: "done" }] }, result: { visitedNodeIds: ["execute"] } }], + ["lastError", { task: { lastError: "boom" }, result: { visitedNodeIds: ["execute"] } }], + ["failureReason", { task: { failureReason: "boom" }, result: { visitedNodeIds: ["execute"] } }], + ])("preserves terminal failed handling for genuine graph failure: %s", async (_name, fixture) => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + graphResumeRetryCount: 0, + ...(fixture.task ?? {}), + } as Task; + store.getTask.mockResolvedValue({ ...task, paused: false, error: null }); + const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); + const executor = new TaskExecutor(store, "/tmp/test", {}); + const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + ...fixture.result, + }); + + const failedNode = fixture.result.visitedNodeIds.at(-1) ?? "unknown"; + const message = `Workflow graph terminated with failure at node '${failedNode}'`; + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); + expect(store.handoffToReview).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), + ); + expect(executeSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + describe("transient resume-after-restart graph failure classifier", () => { + const makeClassifierTask = (overrides: Partial<Task> = {}) => ({ + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [ + { name: "Step 1", status: "pending" }, + { name: "Step 2", status: "pending" }, + ], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Resumed after engine restart" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }) as Task; + + const isTransient = (task: Task, result: any) => { + const executor = new TaskExecutor(createMockStore(), "/tmp/test", {}); + return (executor as any).isTransientResumeAfterRestartGraphFailure(task, result); + }; + + it("accepts only the exact no-progress execute-seam post-resume signature", () => { + expect(isTransient(makeClassifierTask(), { visitedNodeIds: ["execute"] })).toBe(true); + expect(isTransient(makeClassifierTask({ log: [{ timestamp: new Date().toISOString(), action: "Resuming execution after unpause" }] }), { visitedNodeIds: ["execute"] })).toBe(true); + expect(isTransient(makeClassifierTask(), { visitedNodeIds: [] })).toBe(true); + }); + + it.each([ + ["non-empty reason", makeClassifierTask(), { visitedNodeIds: ["execute"], reason: "settings-load-failed: boom" }], + ["non-execute failed node", makeClassifierTask(), { visitedNodeIds: ["planning"] }], + ["completed step progress", makeClassifierTask({ steps: [{ name: "Step 1", status: "done" }] }), { visitedNodeIds: ["execute"] }], + ["lastError", makeClassifierTask({ lastError: "boom" } as any), { visitedNodeIds: ["execute"] }], + ["failureReason", makeClassifierTask({ failureReason: "boom" } as any), { visitedNodeIds: ["execute"] }], + ["missing resume log", makeClassifierTask({ log: [{ timestamp: new Date().toISOString(), action: "Started execution" }] }), { visitedNodeIds: ["execute"] }], + ])("rejects %s as genuine/non-transient", (_name, task, result) => { + expect(isTransient(task as Task, result)).toBe(false); + }); + }); + it("preserves genuine in-progress graph failure handling", async () => { const store = createMockStore(); const task = { diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index 92d25db99a..e52f7faa51 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -30,6 +30,7 @@ import { mockExecuteAll, mockTerminateAllSessions, mockCleanup, + mockSteerActiveSessions, resetExecutorMocks, } from "./executor-test-helpers.js"; @@ -3130,6 +3131,113 @@ describe("Real-time steering injection", () => { await executePromise; }); + it("injects new steering comments via active StepSessionExecutor on task:updated", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const steerActiveSessions = vi.fn().mockResolvedValue(undefined); + const newComment = { + id: "step-session-comment", + text: "Please adjust the active step", + createdAt: new Date().toISOString(), + author: "user" as const, + }; + + (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions }); + (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set()); + + await (store as any)._triggerAsync("task:updated", { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + steeringComments: [newComment], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + expect(steerActiveSessions).toHaveBeenCalledOnce(); + expect(steerActiveSessions.mock.calls[0][0]).toContain("📣 **New feedback**"); + expect(steerActiveSessions.mock.calls[0][0]).toContain("Please adjust the active step"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("Comment received mid-execution"), + "by user", + ); + }); + + it("injects new steering comments via active workflow step session on task:updated", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const steer = vi.fn().mockResolvedValue(undefined); + const newComment = { + id: "workflow-step-comment", + text: "Please adjust the workflow step", + createdAt: new Date().toISOString(), + author: "user" as const, + }; + + (executor as any).activeWorkflowStepSessions.set("FN-001", { steer }); + (executor as any).activeWorkflowStepSessionSeenSteeringIds.set("FN-001", new Set()); + + await (store as any)._triggerAsync("task:updated", { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + steeringComments: [newComment], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + expect(steer).toHaveBeenCalledOnce(); + expect(steer.mock.calls[0][0]).toContain("📣 **New feedback**"); + expect(steer.mock.calls[0][0]).toContain("Please adjust the workflow step"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("Comment received mid-execution"), + "by user", + ); + }); + + it("does not re-inject an already seen active StepSessionExecutor steering comment", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const steerActiveSessions = vi.fn().mockResolvedValue(undefined); + const comment = { + id: "step-session-seen-comment", + text: "Already delivered", + createdAt: new Date().toISOString(), + author: "user" as const, + }; + + (executor as any).activeStepExecutors.set("FN-001", { steerActiveSessions }); + (executor as any).activeStepExecutorSeenSteeringIds.set("FN-001", new Set([comment.id])); + + await (store as any)._triggerAsync("task:updated", { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + steeringComments: [comment], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + expect(steerActiveSessions).not.toHaveBeenCalled(); + }); + it("does not re-inject already seen steering comments", async () => { const store = createMockStore(); const steerFn = vi.fn().mockResolvedValue(undefined); diff --git a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts index dcf3710d2b..b6c8578c59 100644 --- a/packages/engine/src/__tests__/executor-task-done-invariant.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-invariant.test.ts @@ -9,6 +9,51 @@ import * as worktreePool from "../worktree-pool.js"; import { TaskStore } from "@fusion/core"; import { createMockStore, mockedCreateFnAgent, mockedExec, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js"; +const fn416Prompt = `# Task: FN-416 - Assign ready implementation task to active owner + +**Created:** 2026-06-12 +**Size:** S + +## Review Level: 1 (Plan Only) + +**Assessment:** This is an operational routing task with no expected product-source changes. + +## Mission +Assign or route exactly one ready implementation task to an eligible active owner, or record an intentional no-route state. No source files expected. + +## File Scope + +- FN-416 task document docs via fn_task_document_write +- .fusion/tasks/FN-416/ task log evidence only + +## Steps + +### Step 0: Preflight +- [x] Check board state + +### Step 1: Route exactly one existing ready task or record no-route +- [x] Record evidence in task documents/logs +`; + +const sourceChangingPlanOnlyPrompt = `# Task: FN-999 - Implement source fix + +**Size:** S + +## Review Level: 1 (Plan Only) + +## Mission +Implement a source-changing bug-fix in the executor. + +## File Scope + +- packages/engine/src/executor.ts + +## Steps + +### Step 1: Implement +- [ ] Change source +`; + function baseTask(overrides: Record<string, unknown> = {}) { return { id: "FN-4114", @@ -48,7 +93,7 @@ async function setup(overrides: Record<string, unknown> = {}) { }); const executor = new TaskExecutor(store as any, "/repo"); - await executor.execute(baseTask() as any); + await executor.execute(task as any); return { store, tool, setTask: (next: any) => (task = { ...task, ...next }) }; } @@ -111,6 +156,264 @@ describe("FN-4114 fn_task_done invariants", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); }); + it.each([ + "NO-OP: existing tests already cover this", + "PREMISE STALE: targeted reproduction already passes unchanged on HEAD", + "DUPLICATE: FN-6239 existing QuickChatFAB tests already cover this", + ])("FN-6275 allows verified no-op zero-commit completion with sentinel %s", async (summary) => { + const { store, tool } = await setup({ + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Implement", status: "skipped" as const }, + { name: "Testing & Verification", status: "done" as const }, + ], + currentStep: 2, + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", { summary }); + + expect(result.content[0].text).toContain("Task marked complete"); + expect(result.content[0].text).not.toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-4114", + expect.stringContaining("completion sentinel accepted"), + expect.stringContaining(summary), + undefined, + ); + expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ + type: "task:updated", + taskId: "FN-4114", + metadata: expect.objectContaining({ summary }), + })); + }); + + it("FN-6275 still refuses ordinary zero-commit completion summaries", async () => { + const { store, tool } = await setup({ + steps: [{ name: "Implement", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", { summary: "Verified existing behavior with targeted tests." }); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + }); + + it.each([ + ["wrong_toplevel", "/repo\n", "fusion/fn-4114\n"], + ["wrong_branch", "/repo/.worktrees/swift-falcon\n", "main\n"], + ] as const)("FN-6275 does not relax %s for sentinel summaries", async (reason, toplevel, branch) => { + const { store, tool } = await setup({ steps: [{ name: "Implement", status: "done" as const }] }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from(toplevel); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from(branch); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", { summary: "NO-OP: already covered" }); + + expect(result.content[0].text).toContain(`fn_task_done refused: ${reason}`); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-4114", { noCommitsExpected: true }); + }); + + it("FN-6275 sentinel summaries do not auto-complete multiple pending unreviewed steps", async () => { + const { store, tool } = await setup({ + steps: [ + { name: "Implement", status: "in-progress" as const }, + { name: "Testing", status: "pending" as const }, + ], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", { summary: "NO-OP: already covered" }); + + expect(result.content[0].text).toContain("fn_task_done refused (bulk-step-completion-without-review)"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + + it("FN-350 allows Review Level 1 coordination completion with zero commits when no source files are scoped", async () => { + const fn350Prompt = `# Task: FN-350 - Route Ready Swift Tasks to Executor Owner + +**Created:** 2026-06-12 +**Size:** S + +## Review Level: 1 (Plan Only) + +**Assessment:** This is a coordination/routing task that should not change product source, but it can affect execution ordering and owner assignment for active Swift implementation work. Risk is low if the executor follows the existing coordinator handoff policy, routes at most one existing ready task, and records clear evidence instead of creating duplicate implementation work. + +## Mission + +Route exactly one existing ready Swift implementation task to the durable executor owner, or record the intentional block if no safe candidate exists. Do not change product source. + +## File Scope + +Atlas Notes task-board artifacts only: + +- FN-350 task document \`docs\` via \`fn_task_document_write\` +- Board task metadata and logs via Fusion task tools + +## Steps + +### Step 0: Preflight +- [x] Required board records exist. + +### Step 1: Re-check live candidate readiness +- [x] Candidate readiness inspected. + +### Step 2: Select exactly one routing action +- [x] One routing action selected. + +### Step 3: Perform safe routing or record intentional block +- [x] Routing evidence recorded. + +### Step 4: Testing & Verification +- [x] Board-only verification recorded. + +### Step 5: Documentation & Delivery +- [x] Final documentation saved. + +## Do NOT + +- Do not edit product source. +- Do not create duplicate implementation tasks. +`; + const { store, tool } = await setup({ + id: "FN-350", + title: "Route Ready Swift Tasks to Executor Owner", + description: "Coordination/routing task with task-document evidence only.", + prompt: fn350Prompt, + branch: "fusion/fn-350", + noCommitsExpected: undefined, + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Re-check live candidate readiness", status: "done" as const }, + { name: "Select exactly one routing action", status: "done" as const }, + { name: "Perform safe routing or record intentional block", status: "done" as const }, + { name: "Testing & Verification", status: "done" as const }, + { name: "Documentation & Delivery", status: "in-progress" as const }, + ], + currentStep: 5, + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-350\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + store.moveTask.mockClear(); + const result = await tool.execute("id", { summary: "Recorded routing evidence in task documents and logs." }); + + expect(result.content[0].text).toContain("Task marked complete"); + expect(result.content[0].text).not.toContain("fn_task_done refused: no_commits"); + expect(store.moveTask.mock.calls).toEqual([["FN-350", "in-progress"]]); + expect(store.handoffToReview).not.toHaveBeenCalled(); + }); + + it("FN-350 refuses contradictory implementation plus coordination fallback prompts", async () => { + const prompt = `# Task: FN-350 - Route Ready Swift Tasks to Executor Owner + +## Review Level: 1 (Plan Only) + +**Assessment:** This is a coordination/routing task that should not change product source. + +## Mission +Implement the source fix if possible, or record the intentional block if no safe candidate exists. Do not change product source. + +## File Scope + +- FN-350 task document \`docs\` via \`fn_task_document_write\` + +## Steps + +### Step 1: Decide +- [x] Decision recorded. +`; + const { store, tool } = await setup({ + id: "FN-350", + title: "Route Ready Swift Tasks to Executor Owner", + description: "Coordination/routing task with task-document evidence only.", + prompt, + branch: "fusion/fn-350", + noCommitsExpected: undefined, + steps: [{ name: "Decide", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-350\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-350", "todo", { preserveProgress: true }); + }); + + it("FN-4114 still refuses source-changing implementation tasks with zero commits and no explicit no-commit contract", async () => { + const implementationPrompt = `# Task: FN-4114 - Implement source change + +**Size:** M + +## Review Level: 2 (Plan and Code) + +## Mission + +Implement a bug fix in the engine. + +## File Scope + +- packages/engine/src/executor.ts +- packages/engine/src/__tests__/executor-task-done-invariant.test.ts + +## Steps + +### Step 1: Implement +- [ ] Change source code and tests. +`; + const { store, tool } = await setup({ prompt: implementationPrompt, noCommitsExpected: undefined }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + it("FN-4114 allows no-commit completion when noCommitsExpected is true", async () => { const { store, tool } = await setup({ noCommitsExpected: true }); mockedExecSync.mockImplementation((cmd: string) => { @@ -124,10 +427,30 @@ describe("FN-4114 fn_task_done invariants", () => { const result = await tool.execute("id", {}); expect(result.content[0].text).toContain("Task marked complete"); expect(store.updateStep).toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-4114", + expect.stringContaining("noCommitsExpected=true"), + undefined, + undefined, + ); const revListCalled = mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("rev-list --count")); expect(revListCalled).toBe(false); }); + it("FN-4114 still refuses wrong_toplevel even when noCommitsExpected is true", async () => { + const { store, tool } = await setup({ noCommitsExpected: true }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: wrong_toplevel"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); + it("FN-4114 still refuses wrong_branch even when noCommitsExpected is true", async () => { const { store, tool } = await setup({ noCommitsExpected: true }); mockedExecSync.mockImplementation((cmd: string) => { @@ -141,6 +464,179 @@ describe("FN-4114 fn_task_done invariants", () => { expect(result.content[0].text).toContain("fn_task_done refused: wrong_branch"); expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); }); + it("FN-4114 allows no-commit completion when noCommitsExpected audit logging fails", async () => { + const { store, tool } = await setup({ noCommitsExpected: true }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + store.logEntry.mockImplementation(async (_id: string, message: string) => { + if (message.includes("no_commits guard skipped")) throw new Error("audit unavailable"); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("Task marked complete"); + expect(store.updateStep).toHaveBeenCalled(); + }); + + + it("FN-416 allows plan-only operational no-source completion with zero commits when the explicit flag is missing", async () => { + const { store, tool } = await setup({ + id: "FN-416", + branch: "fusion/fn-416", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: fn416Prompt, + sourceMetadata: { fileScope: ["FN-416 task document docs via fn_task_document_write"] }, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [ + { name: "Preflight", status: "done" as const }, + { name: "Route or record no-route", status: "done" as const }, + ], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-416\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("Task marked complete"); + expect(store.moveTask).not.toHaveBeenCalledWith("FN-416", "todo", { preserveProgress: true }); + expect(store.handoffToReview).not.toHaveBeenCalledWith("FN-416", expect.objectContaining({ + evidence: expect.objectContaining({ reason: "invariant-check-failed" }), + })); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-416", + expect.stringContaining("prompt/source metadata derived operational no-commit contract"), + undefined, + undefined, + ); + const revListCalled = mockedExecSync.mock.calls.some(([cmd]) => String(cmd).includes("rev-list --count")); + expect(revListCalled).toBe(false); + }); + it("FN-416 refuses plan-only operational no-source completion when File Scope is missing", async () => { + const promptWithoutFileScope = `# Task: FN-417 - Assign ready implementation task to active owner + +## Review Level: 1 (Plan Only) + +**Assessment:** This is an operational routing task with no expected product-source changes. + +## Mission +Assign or route exactly one ready implementation task to an eligible active owner, or record an intentional no-route state. No source files expected. + +## Steps + +### Step 1: Route exactly one existing ready task or record no-route +- [x] Record evidence in task documents/logs +`; + const { store, tool } = await setup({ + id: "FN-417", + branch: "fusion/fn-417", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: promptWithoutFileScope, + sourceMetadata: {}, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [{ name: "Route or record no-route", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-417\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-417", "todo", { preserveProgress: true }); + }); + + it("FN-416 refuses prompt-only evidence text when steps are incomplete and logs are empty", async () => { + const { store, tool } = await setup({ + id: "FN-418", + branch: "fusion/fn-418", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: fn416Prompt.replace("# Task: FN-416", "# Task: FN-418"), + sourceMetadata: { fileScope: ["FN-418 task document docs via fn_task_document_write"] }, + log: [], + steps: [{ name: "Route or record no-route", status: "in-progress" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-418\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-418", "todo", { preserveProgress: true }); + }); + + it("FN-416 refuses mixed no-source text with source-changing scope entries", async () => { + const mixedScopePrompt = fn416Prompt + .replace("# Task: FN-416", "# Task: FN-419") + .replace( + "- FN-416 task document docs via fn_task_document_write", + "- No source changes expected, but inspect packages/engine/src/executor.ts", + ); + const { store, tool } = await setup({ + id: "FN-419", + branch: "fusion/fn-419", + title: "Assign ready implementation task to active owner", + description: "Operational routing task with no expected product-source changes; record routing evidence or no-route state.", + reviewLevel: 1, + prompt: mixedScopePrompt, + sourceMetadata: { fileScope: ["No source changes expected, but inspect packages/engine/src/executor.ts"] }, + log: [{ timestamp: new Date().toISOString(), action: "Routing evidence recorded", outcome: "No-route state documented in task docs" }], + steps: [{ name: "Route or record no-route", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-419\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-419", "todo", { preserveProgress: true }); + }); + + it("FN-416 keeps the missing-commit guard for source-changing plan-only tasks without an explicit contract", async () => { + const { store, tool } = await setup({ + title: "Implement executor fix", + description: "Plan Only but requires source-changing implementation work.", + reviewLevel: 1, + prompt: sourceChangingPlanOnlyPrompt, + sourceMetadata: { fileScope: ["packages/engine/src/executor.ts"] }, + steps: [{ name: "Implement", status: "done" as const }], + }); + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/swift-falcon\n"); + if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4114\n"); + if (cmd.includes("rev-list --count")) return Buffer.from("0\n"); + if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n"); + return Buffer.from(""); + }); + + const result = await tool.execute("id", {}); + expect(result.content[0].text).toContain("fn_task_done refused: no_commits"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true }); + }); it("FN-4114 allows fn_task_done on valid worktree/branch/commit state", async () => { const { store, tool } = await setup(); diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 74a6d33642..84e253c3cf 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -224,6 +224,7 @@ vi.mock("node:fs", () => ({ export const mockExecuteAll: Mock<() => Promise<unknown[]>> = vi.fn().mockResolvedValue([]); export const mockTerminateAllSessions: Mock<() => Promise<void>> = vi.fn().mockResolvedValue(undefined); export const mockCleanup: Mock<() => Promise<void>> = vi.fn().mockResolvedValue(undefined); +export const mockSteerActiveSessions: Mock<(message: string) => Promise<void>> = vi.fn().mockResolvedValue(undefined); vi.mock("../step-session-executor.js", () => ({ StepSessionExecutor: vi.fn().mockImplementation(function () { @@ -231,6 +232,7 @@ vi.mock("../step-session-executor.js", () => ({ executeAll: mockExecuteAll, terminateAllSessions: mockTerminateAllSessions, cleanup: mockCleanup, + steerActiveSessions: mockSteerActiveSessions, }; }), })); @@ -345,6 +347,7 @@ export function createMockStore() { updatedAt: new Date().toISOString(), }), updateTask: vi.fn().mockResolvedValue({}), + recordActivity: vi.fn().mockResolvedValue({}), moveTask: vi.fn().mockResolvedValue({}), handoffToReview: vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review")), mergeTask: vi.fn().mockResolvedValue({}), @@ -415,6 +418,7 @@ export function resetExecutorMocks() { mockExecuteAll.mockResolvedValue([]); mockTerminateAllSessions.mockResolvedValue(undefined); mockCleanup.mockResolvedValue(undefined); + mockSteerActiveSessions.mockResolvedValue(undefined); // FN-4811 follow-up: the executingTaskLock is process-wide module state, so it must // be cleared between tests or earlier tests' claims will block later tests' execute() // calls ("expected at least 2 createFnAgent calls but got 0" / "expected not called diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 81aff37206..2c2c5ae5fc 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -571,6 +571,99 @@ describe("executeHeartbeat", () => { expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted"); }); + describe("agent pause does not pause assigned tasks", () => { + it("pauseAgent leaves zero, one, and many assigned tasks untouched", async () => { + for (const assignedTasks of [ + [], + [{ id: "FN-001", paused: undefined, pausedByAgentId: undefined }], + [ + { id: "FN-001", paused: undefined, pausedByAgentId: undefined }, + { id: "FN-002", paused: false, pausedByAgentId: undefined }, + { id: "FN-003", paused: true, userPaused: true, pausedByAgentId: undefined }, + ], + ]) { + const pauseTask = vi.fn().mockResolvedValue(undefined); + const getTasksByAssignedAgent = vi.fn().mockResolvedValue(assignedTasks); + mockTaskStore = createMockTaskStore({ pauseTask, getTasksByAssignedAgent }); + const store = createStoreWithAgentForExec({ taskId: assignedTasks[0]?.id }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + const before = structuredClone(assignedTasks); + + await monitor.pauseAgent("agent-001"); + + expect(pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(pauseTask).not.toHaveBeenCalled(); + expect(getTasksByAssignedAgent).not.toHaveBeenCalled(); + expect(assignedTasks).toEqual(before); + } + }); + + it("reproduces agent sleep symptom and keeps assigned task pause fields unchanged", async () => { + const assignedTask = { + id: "FN-001", + column: "todo", + paused: undefined, + pausedByAgentId: undefined, + }; + const pauseTask = vi.fn().mockResolvedValue(undefined); + mockTaskStore = createMockTaskStore({ + pauseTask, + getTasksByAssignedAgent: vi.fn().mockResolvedValue([assignedTask]), + }); + const store = createStoreWithAgentForExec({ taskId: "FN-001" }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.pauseAgent("agent-001"); + + expect(pauseTask).not.toHaveBeenCalled(); + expect(assignedTask.paused).toBeUndefined(); + expect(assignedTask.pausedByAgentId).toBeUndefined(); + expect(assignedTask.column).toBe("todo"); + }); + + it("executeHeartbeat does not pause its assigned task", async () => { + const pauseTask = vi.fn().mockResolvedValue(undefined); + mockTaskStore = createMockTaskStore({ pauseTask }); + const store = createStoreWithAgentForExec({ taskId: "FN-001" }); + const mockSession = createMockAgentSession(); + mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); + + expect(pauseTask).not.toHaveBeenCalledWith(expect.any(String), true, expect.anything(), expect.anything()); + expect(pauseTask).not.toHaveBeenCalled(); + }); + + it("resumeAgent cascade skips user-paused tasks but unpauses agent-only pauses", async () => { + const pauseTask = vi.fn().mockResolvedValue(undefined); + const getTasksByAssignedAgent = vi.fn().mockResolvedValue([ + { id: "FN-001", paused: true, pausedByAgentId: "agent-001" }, + { id: "FN-002", paused: true, pausedByAgentId: "agent-001", userPaused: true }, + { id: "FN-003", paused: true, userPaused: true }, + ]); + mockTaskStore = createMockTaskStore({ pauseTask, getTasksByAssignedAgent }); + const store = createStoreWithAgentForExec({ + taskId: "FN-001", + state: "active", + runtimeConfig: { enabled: false }, + }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.resumeAgent("agent-001", { cascadeToTasks: true }); + + expect(getTasksByAssignedAgent).toHaveBeenCalledWith("agent-001", { + pausedOnly: true, + excludeArchived: true, + }); + expect(pauseTask).toHaveBeenCalledTimes(1); + expect(pauseTask).toHaveBeenCalledWith("FN-001", false); + expect(pauseTask).not.toHaveBeenCalledWith("FN-002", false); + expect(pauseTask).not.toHaveBeenCalledWith("FN-003", false); + expect(mockedCreateFnAgent).not.toHaveBeenCalled(); + }); + }); + it("pauseForApproval pauses task and agent when taskId exists", async () => { const store = createStoreWithAgentForExec({ taskId: "FN-001" }); const pauseTask = vi.fn().mockResolvedValue(undefined); @@ -953,6 +1046,76 @@ describe("executeHeartbeat", () => { expect(toolNames).toContain("fn_task_log"); }); + it("honors engineerBacklogAutoClaim precedence for no-task auto-claim role compatibility", async () => { + const oldEnoughForBaseScore = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const candidateTask = { + id: "FN-CANDIDATE", + description: "implementation reliability follow-up", + title: "Implementation reliability", + prompt: "# PROMPT", + steps: [], + column: "todo", + dependencies: [], + log: [], + attachments: [], + createdAt: oldEnoughForBaseScore, + updatedAt: oldEnoughForBaseScore, + columnMovedAt: oldEnoughForBaseScore, + } as unknown as TaskDetail; + const scenarios = [ + { name: "engineer default", role: "engineer", settings: {}, runtimeConfig: {}, shouldClaim: false, promptText: "engineerBacklogAutoClaim disabled" }, + { name: "engineer project opt-in", role: "engineer", settings: { engineerBacklogAutoClaim: true }, runtimeConfig: {}, shouldClaim: true }, + { name: "engineer runtime opt-in overrides project off", role: "engineer", settings: { engineerBacklogAutoClaim: false }, runtimeConfig: { engineerBacklogAutoClaim: true }, shouldClaim: true }, + { name: "engineer runtime opt-out overrides project on", role: "engineer", settings: { engineerBacklogAutoClaim: true }, runtimeConfig: { engineerBacklogAutoClaim: false }, shouldClaim: false, promptText: "engineerBacklogAutoClaim disabled" }, + { name: "executor unchanged", role: "executor", settings: { engineerBacklogAutoClaim: false }, runtimeConfig: {}, shouldClaim: true }, + { name: "reviewer blocked with opt-in", role: "reviewer", settings: { engineerBacklogAutoClaim: true }, runtimeConfig: {}, shouldClaim: false, promptText: "executor or opted-in engineer role required" }, + { name: "custom blocked with opt-in", role: "custom", settings: { engineerBacklogAutoClaim: true }, runtimeConfig: {}, shouldClaim: false, promptText: "executor or opted-in engineer role required" }, + ] as const; + + for (const scenario of scenarios) { + vi.clearAllMocks(); + mockedAcquireTaskWorktree.mockResolvedValue({ + worktreePath: "/tmp/worktree-fn-candidate", + branch: "fusion/fn-candidate", + source: "existing", + hydrated: false, + isResume: true, + }); + const store = createStoreWithAgentForExec({ + taskId: undefined, + role: scenario.role, + soul: "implementation reliability owner", + runtimeConfig: scenario.runtimeConfig, + }); + const mockSession = createMockAgentSession(); + mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); + mockTaskStore = createMockTaskStore({ + getSettings: vi.fn().mockResolvedValue(scenario.settings), + listTasks: vi.fn().mockResolvedValue([candidateTask]), + getTask: vi.fn().mockResolvedValue(candidateTask), + }); + (store.claimTaskForAgent as ReturnType<typeof vi.fn>).mockResolvedValue({ + ok: true, + task: { id: "FN-CANDIDATE" }, + }); + + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); + + if (scenario.shouldClaim) { + expect(store.claimTaskForAgent, scenario.name).toHaveBeenCalledWith( + "agent-001", + "FN-CANDIDATE", + expect.objectContaining({ agentId: "agent-001", source: "timer" }), + ); + } else { + expect(store.claimTaskForAgent, scenario.name).not.toHaveBeenCalled(); + const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string; + expect(executionPrompt, scenario.name).toContain(scenario.promptText); + } + } + }); + it("auto-claim skips implementation candidates for non-executor agents", async () => { const store = createStoreWithAgentForExec({ taskId: undefined, @@ -1222,19 +1385,19 @@ describe("executeHeartbeat", () => { }); it("no-task run overrides a seeded task-scoped heartbeatProcedurePath in the assembled prompt", async () => { - const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-no-task-procedure-")); + const tmpDir = mkdtempSync(join(process.cwd(), ".tmp-fn-hb-no-task-procedure-")); try { - writeFileSync(join(tmpRoot, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8"); + writeFileSync(join(tmpDir, "HEARTBEAT.md"), HEARTBEAT_PROCEDURE, "utf-8"); const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator", - heartbeatProcedurePath: "HEARTBEAT.md", + heartbeatProcedurePath: `${tmpDir.split("/").pop()}/HEARTBEAT.md`, }); const mockSession = createMockAgentSession(); mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); - const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: process.cwd() }); const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); expect(result.status).toBe("completed"); @@ -1248,7 +1411,7 @@ describe("executeHeartbeat", () => { const savedRun = await store.getRunDetail("agent-001", result.id); expect(savedRun?.heartbeatProcedureSource).toBe("default-no-task-override"); } finally { - rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true }); } }); @@ -3001,7 +3164,7 @@ describe("executeHeartbeat", () => { expect(taskLogTool.name).toBe("fn_task_log"); }); - it("passes runtime model as primary and execution settings model as fallback", async () => { + it("passes execution settings model ahead of stale runtime model", async () => { const store = createStoreWithAgentForExec({ runtimeConfig: { model: "anthropic/claude-sonnet-4-5" }, }); @@ -3022,10 +3185,10 @@ describe("executeHeartbeat", () => { expect(mockedCreateFnAgent).toHaveBeenCalledOnce(); const callArgs = mockedCreateFnAgent.mock.calls[0]![0]; - expect(callArgs.defaultProvider).toBe("anthropic"); - expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5"); - expect(callArgs.fallbackProvider).toBe("openai"); - expect(callArgs.fallbackModelId).toBe("gpt-4.1"); + expect(callArgs.defaultProvider).toBe("openai"); + expect(callArgs.defaultModelId).toBe("gpt-4.1"); + expect(callArgs.fallbackProvider).toBeUndefined(); + expect(callArgs.fallbackModelId).toBeUndefined(); }); it("passes undefined model when runtimeConfig has no model", async () => { diff --git a/packages/engine/src/__tests__/heartbeat-scheduler.test.ts b/packages/engine/src/__tests__/heartbeat-scheduler.test.ts index f9857710c8..a293f22971 100644 --- a/packages/engine/src/__tests__/heartbeat-scheduler.test.ts +++ b/packages/engine/src/__tests__/heartbeat-scheduler.test.ts @@ -1206,6 +1206,7 @@ describe("HeartbeatTriggerScheduler", () => { describe("assignment watching", () => { let eventStore: EventEmitter & { + getAgent: ReturnType<typeof vi.fn>; getActiveHeartbeatRun: ReturnType<typeof vi.fn>; getBudgetStatus: ReturnType<typeof vi.fn>; getRecentRuns: ReturnType<typeof vi.fn>; @@ -1215,6 +1216,7 @@ describe("HeartbeatTriggerScheduler", () => { vi.useRealTimers(); // Ensure real timers for these tests eventStore = Object.assign(new EventEmitter(), { + getAgent: vi.fn().mockResolvedValue({ id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} }), getActiveHeartbeatRun: vi.fn().mockResolvedValue(null), getBudgetStatus: vi.fn().mockRejectedValue(new Error("budget status unavailable")), getRecentRuns: vi.fn().mockResolvedValue([]), @@ -1276,16 +1278,176 @@ describe("HeartbeatTriggerScheduler", () => { expect(eventStore.getActiveHeartbeatRun).not.toHaveBeenCalled(); }); - it("skips trigger when agent has active run", async () => { - (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({ - id: "run-active", - status: "active", - }); + // Regression surface checklist for deferred assignments: + // - active-run assignment skip records pending work; no-active-run control remains immediate + // - run-completion drain re-fires once, latest rapid re-assignment wins + // - transient global/engine pause, new active run, and parallel-execution guards preserve pending work + // - terminal missing/disabled/budget-exhausted states and unregister clear pending work + // - skipHeartbeatWhenIdle/long timer stalls are avoided because drain is completion-driven, not timer-driven + it("defers an active-run assignment and re-fires it exactly once on drain", async () => { + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); - const agent = { id: "agent-test", name: "Test" } as import("@fusion/core").Agent; - eventStore.emit("agent:assigned", agent, "FN-003"); + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-001"); await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + + await scheduler.drainPendingAssignment("agent-test"); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({ + taskId: "FN-001", + wakeReason: "assignment", + triggerDetail: "task-assigned", + })); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).toHaveBeenCalledOnce(); + }); + + it("does not record pending work when assignment fires immediately", async () => { + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-002"); + + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledOnce(); + }, { timeout: 1000 }); + + callback.mockClear(); + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("keeps only the latest task when assignments are repeated during an active run", async () => { + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); + + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-OLD"); + eventStore.emit("agent:assigned", agent, "FN-LATEST"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + + await scheduler.drainPendingAssignment("agent-test"); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({ taskId: "FN-LATEST" })); + }); + + it.each([ + ["globalPause", { globalPause: true }], + ["enginePaused", { enginePaused: true }], + ])("preserves pending assignment while %s blocks drain", async (_name, settings) => { + scheduler.stop(); + const pausedTaskStore = { getSettings: vi.fn().mockResolvedValue(settings) } as unknown as TaskStore; + scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback, pausedTaskStore); + scheduler.start(); + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); + + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-PAUSED"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + + (pausedTaskStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({}); + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({ taskId: "FN-PAUSED" })); + }); + + it("preserves pending assignment when a new active run exists at drain time", async () => { + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValueOnce({ id: "run-new", status: "active" }) + .mockResolvedValue(null); + + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-ACTIVE"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith("agent-test", "assignment", expect.objectContaining({ taskId: "FN-ACTIVE" })); + }); + + it.each([ + ["missing agent", async () => { + eventStore.getAgent.mockResolvedValue(null); + }], + ["disabled agent", async () => { + eventStore.getAgent.mockResolvedValue({ id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {}, runtimeConfig: { enabled: false } }); + }], + ["budget exhausted", async () => { + eventStore.getBudgetStatus.mockResolvedValue(createBudgetStatus({ + agentId: "agent-test", + isOverBudget: true, + isOverThreshold: true, + usagePercent: 100, + budgetLimit: 1000, + thresholdPercent: 80, + })); + }], + ])("clears pending assignment without re-fire for %s", async (_name, configureTerminal) => { + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-CLEAR"); + await new Promise((resolve) => setTimeout(resolve, 10)); + await configureTerminal(); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + + eventStore.getAgent.mockResolvedValue({ id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} }); + eventStore.getBudgetStatus.mockRejectedValue(new Error("budget status unavailable")); + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("preserves pending assignment while parallel execution guard blocks drain", async () => { + scheduler.stop(); + scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback, undefined, { + isTaskExecuting: (taskId) => taskId === "FN-EXECUTING", + }); + scheduler.start(); + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); + + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {}, runtimeConfig: { allowParallelExecution: false } } as import("@fusion/core").Agent; + eventStore.getAgent.mockResolvedValue(agent); + eventStore.emit("agent:assigned", agent, "FN-EXECUTING"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await scheduler.drainPendingAssignment("agent-test"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("clears pending assignment when unregistering an agent", async () => { + (eventStore.getActiveHeartbeatRun as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ id: "run-active", status: "active" }) + .mockResolvedValue(null); + + const agent = { id: "agent-test", name: "Test", role: "executor", state: "active", metadata: {} } as import("@fusion/core").Agent; + eventStore.emit("agent:assigned", agent, "FN-UNREGISTER"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + scheduler.unregisterAgent("agent-test"); + await scheduler.drainPendingAssignment("agent-test"); expect(callback).not.toHaveBeenCalled(); }); diff --git a/packages/engine/src/__tests__/merger-ai-cleanup-active-session.test.ts b/packages/engine/src/__tests__/merger-ai-cleanup-active-session.test.ts new file mode 100644 index 0000000000..440cd94166 --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-cleanup-active-session.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, realpathSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pruneExistingAiMergeWorktrees, resolveAiMergeRoot } from "../merger-ai.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; +import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "../self-healing.js"; +import type { RunAuditor } from "../run-audit.js"; + +const tracked = new Set<string>(); +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; + +afterEach(() => { + activeSessionRegistry.clear(); + for (const dir of tracked) { + try { rmSync(dir, RM); } catch { /* best effort */ } + } + tracked.clear(); +}); + +function makeAudit() { + const events: any[] = []; + const audit: RunAuditor = { + git: vi.fn(async (event: any) => { events.push(event); }), + database: vi.fn(async () => undefined), + filesystem: vi.fn(async () => undefined), + sandbox: vi.fn(async () => undefined), + }; + return { audit, events }; +} + +function tempProjectRoot(): string { + const dir = join(tmpdir(), `fusion-ai-merge-active-session-project-${Math.random().toString(36).slice(2)}-`); + mkdirSync(dir, { recursive: true }); + tracked.add(dir); + return dir; +} + +function tempAiMergeDir(rootDir: string, name: string): string { + const dir = join(resolveAiMergeRoot(rootDir), name); + mkdirSync(dir, { recursive: true }); + tracked.add(dir); + return dir; +} + +function makeAge(path: string, ageMs: number): void { + const old = new Date(Date.now() - ageMs); + utimesSync(path, old, old); +} + +describe("AI merge active-session pruning", () => { + it("pruneExistingAiMergeWorktrees skips active-session paths", async () => { + const projectRoot = tempProjectRoot(); + const stale = tempAiMergeDir(projectRoot, "fusion-ai-merge-fn-777-active"); + const canonical = realpathSync(stale); + activeSessionRegistry.registerPath(canonical, { taskId: "FN-777", kind: "ai-merge", ownerKey: "ai-merge:FN-777:attempt-1" }); + const { audit, events } = makeAudit(); + + await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async () => undefined))).resolves.toBe(0); + expect(existsSync(stale)).toBe(true); + expect(events).toEqual([]); + + activeSessionRegistry.unregisterPath(canonical); + makeAge(stale, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000); + await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async () => undefined))).resolves.toBe(1); + expect(existsSync(stale)).toBe(false); + }); +}); diff --git a/packages/engine/src/__tests__/merger-ai-cleanup.test.ts b/packages/engine/src/__tests__/merger-ai-cleanup.test.ts index 26e9647507..ab72637436 100644 --- a/packages/engine/src/__tests__/merger-ai-cleanup.test.ts +++ b/packages/engine/src/__tests__/merger-ai-cleanup.test.ts @@ -1,11 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execSync } from "node:child_process"; -import { cleanupAiMergeWorktree, pruneExistingAiMergeWorktrees, runAiMerge } from "../merger-ai.js"; +import { cleanupAiMergeWorktree, pruneExistingAiMergeWorktrees, resolveAiMergeRoot, runAiMerge } from "../merger-ai.js"; import { activeSessionRegistry } from "../active-session-registry.js"; +import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "../self-healing.js"; +import { classifyTransientMergeError } from "../transient-merge-error-classifier.js"; +import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "../worktree-paths.js"; import type { RunAuditor } from "../run-audit.js"; const fsState = vi.hoisted(() => ({ failReaddirPath: "" })); @@ -111,12 +114,34 @@ function makeStore(taskId = "FN-1") { } function tempAiMergeDir(name: string): string { - const dir = join(tmpdir(), name); - mkdirSync(dir, { recursive: true }); + const dir = mkdtempSync(join(tmpdir(), `${name}-`)); tracked.add(dir); return dir; } +function localAiMergeDir(projectRoot: string, name: string): string { + const dir = join(resolveAiMergeRoot(projectRoot), name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function legacyRepoAiMergeDir(projectRoot: string, name: string): string { + const dir = join(resolveLegacyAiMergeRootPath(projectRoot), name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function tempProjectRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-project-")); + tracked.add(dir); + return dir; +} + +function makeAge(path: string, ageMs: number): void { + const old = new Date(Date.now() - ageMs); + utimesSync(path, old, old); +} + function realMergeAgent(taskId = "FN-1") { return vi.fn(async (cwd: string) => { execSync(`git merge --squash fusion/${taskId.toLowerCase()}`, { cwd, stdio: "pipe" }); @@ -163,65 +188,162 @@ describe("AI merge temp worktree cleanup", () => { ])); }); + it("treats spawn git ENOENT during cleanup as idempotent already-absent success", async () => { + const mergeRoot = mkdtempSync(join(tmpdir(), "fusion-ai-merge-fn-1-enoent-cleanup-test-")); + tracked.add(mergeRoot); + const canonicalMergeRoot = realpathSync(mergeRoot); + const err = Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" }); + const gitRunner = vi.fn(async () => { throw err; }); + + const { events, logs } = await cleanup({ + mergeRoot, + gitRunner, + }); + + expect(gitRunner).toHaveBeenCalledWith(["worktree", "remove", "--force", canonicalMergeRoot], process.cwd()); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" }) }), + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true, alreadyAbsent: true, idempotent: true }) }), + ])); + expect(logs.join("\n")).toContain("treating cleanup as idempotent"); + }); + + it("removes the directory after git reports the temp path is not a working tree", async () => { + const err = new Error("Command failed: git worktree remove --force /tmp/fusion-ai-merge-fn-1\nfatal: '/tmp/fusion-ai-merge-fn-1' is not a working tree"); + + const { mergeRoot, events } = await cleanup({ + gitRunner: vi.fn(async () => { throw err; }), + }); + + expect(existsSync(mergeRoot)).toBe(false); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true }) }), + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true, alreadyAbsent: true, idempotent: true }) }), + ])); + }); + + it("still surfaces genuine filesystem cleanup failures", async () => { + const err = new Error("Directory not empty") as NodeJS.ErrnoException; + err.code = "ENOTEMPTY"; + + const { events, logs } = await cleanup({ rmRunner: vi.fn(async () => { throw err; }) as typeof rm }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: false, code: "ENOTEMPTY", error: "Directory not empty" }) }), + ])); + expect(logs.join("\n")).toContain("filesystem rm failed"); + }); + it("skips git removal but still audits filesystem cleanup when worktree was not added", async () => { const gitRunner = vi.fn(async () => ""); const { events } = await cleanup({ worktreeAdded: false, gitRunner }); - expect(gitRunner).not.toHaveBeenCalled(); + expect(gitRunner).toHaveBeenCalledTimes(1); + expect(gitRunner).toHaveBeenCalledWith(["worktree", "prune"], expect.any(String), { timeout: 30_000 }); expect(events.some((event) => event.metadata.phase === "git-remove")).toBe(false); expect(events).toEqual(expect.arrayContaining([ expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }), + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-prune", success: true }) }), ])); }); - it("pruneExistingAiMergeWorktrees removes stale same-task directories", async () => { - const stale = tempAiMergeDir("fusion-ai-merge-fn-777-stale"); + it("prunes stale worktree metadata after git removal failure", async () => { + const err = new Error("git remove failed") as Error & { stderr?: string; code?: string }; + err.stderr = "fatal: not a working tree registered in git metadata"; + err.code = "1"; + const gitRunner = vi.fn(async (args: string[]) => { + if (args[0] === "worktree" && args[1] === "remove" && args[2] === "--force") throw err; + return ""; + }); + + const { mergeRoot, events, logs } = await cleanup({ gitRunner }); + + expect(existsSync(mergeRoot)).toBe(false); + expect(gitRunner).toHaveBeenCalledWith(["worktree", "prune"], expect.any(String), { timeout: 30_000 }); + expect(logs.join("\n")).toContain("not a working tree registered"); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: false }) }), + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }), + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-prune", success: true }) }), + ])); + }); + + it("pruneExistingAiMergeWorktrees removes stale same-task directories from new and legacy roots", async () => { + const projectRoot = tempProjectRoot(); + const staleNew = localAiMergeDir(projectRoot, "fusion-ai-merge-fn-777-stale-new"); + const staleLegacyRepo = legacyRepoAiMergeDir(projectRoot, "fusion-ai-merge-fn-777-stale-legacy-repo"); + const staleLegacyTmp = tempAiMergeDir("fusion-ai-merge-fn-777-stale-tmp"); + for (const stale of [staleNew, staleLegacyRepo, staleLegacyTmp]) { + makeAge(stale, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000); + } + const canonicalStale = [staleNew, staleLegacyRepo, staleLegacyTmp].map((path) => realpathSync(path)); const { audit, events } = makeAudit(); const logs: string[] = []; - await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async (message: string) => { logs.push(message); }))).resolves.toBe(1); + await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async (message: string) => { logs.push(message); }))).resolves.toBe(3); - expect(existsSync(stale)).toBe(false); - expect(events).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ taskId: "FN-777", mergeRoot: realpathSync(tmpdir()) + "/fusion-ai-merge-fn-777-stale", phase: "pre-merge-prune", success: true }) }), - ])); + expect(existsSync(staleNew)).toBe(false); + expect(existsSync(staleLegacyRepo)).toBe(false); + expect(existsSync(staleLegacyTmp)).toBe(false); + for (const mergeRoot of canonicalStale) { + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ taskId: "FN-777", mergeRoot, phase: "pre-merge-prune", success: true }) }), + ])); + } + }); + + it("pruneExistingAiMergeWorktrees skips too-new same-task directories", async () => { + const fresh = tempAiMergeDir("fusion-ai-merge-fn-777-fresh"); + const { audit, events } = makeAudit(); + const logs: string[] = []; + + const projectRoot = tempProjectRoot(); + + await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async (message: string) => { logs.push(message); }))).resolves.toBe(0); + + expect(existsSync(fresh)).toBe(true); + expect(events).toEqual([]); + expect(logs.join("\n")).toContain("skipping too-new worktree"); }); it("pruneExistingAiMergeWorktrees skips directories for other tasks", async () => { const other = tempAiMergeDir("fusion-ai-merge-fn-778-stale"); const { audit, events } = makeAudit(); - await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(0); + const projectRoot = tempProjectRoot(); + + await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async () => undefined))).resolves.toBe(0); expect(existsSync(other)).toBe(true); expect(events).toEqual([]); }); - it("pruneExistingAiMergeWorktrees skips active-session paths", async () => { - const stale = tempAiMergeDir("fusion-ai-merge-fn-777-active"); - const canonical = realpathSync(stale); - activeSessionRegistry.registerPath(canonical, { taskId: "FN-777", kind: "executor", ownerKey: "FN-777" }); - const { audit, events } = makeAudit(); - - await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(0); - expect(existsSync(stale)).toBe(true); - expect(events).toEqual([]); - - activeSessionRegistry.unregisterPath(canonical); - await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(1); - expect(existsSync(stale)).toBe(false); - }); - - it("runAiMerge emits success cleanup audit events", async () => { + it("runAiMerge registers the clean-room worktree while merging and unregisters after", async () => { const { dir } = initRepoWithBranch(); const { store, audits } = makeStore(); + let observedMergeRoot = ""; + const mergeAgent = vi.fn(async (cwd: string) => { + observedMergeRoot = cwd; + expect(activeSessionRegistry.isPathActive(realpathSync(cwd))).toBe(true); + expect(activeSessionRegistry.isPathActive(cwd)).toBe(true); + await realMergeAgent()(cwd); + }); await runAiMerge(store, dir, "FN-1", { manual: true }, { - mergeAgent: realMergeAgent(), + mergeAgent, reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), }); + const expectedRoot = join(resolveWorktreesDir(dir, undefined), ".ai-merge"); + expect(observedMergeRoot).toContain("fusion-ai-merge-fn-1-"); + expect(observedMergeRoot.startsWith(expectedRoot)).toBe(true); + expect(observedMergeRoot.startsWith(resolveAiMergeRootPath(dir, undefined))).toBe(true); + expect(observedMergeRoot.startsWith(join(tmpdir(), "fusion-ai-merge-fn-1-"))).toBe(false); + expect(observedMergeRoot.startsWith(resolveLegacyAiMergeRootPath(dir))).toBe(false); + expect(observedMergeRoot.startsWith(resolveAiMergeRoot(dir))).toBe(true); + expect(activeSessionRegistry.pathsForTask("FN-1")).toEqual([]); const cleanupEvents = audits.filter((event) => event.mutationType === "merge:ai-worktree-cleanup"); expect(cleanupEvents).toEqual(expect.arrayContaining([ expect.objectContaining({ metadata: expect.objectContaining({ phase: "git-remove", success: true }) }), @@ -233,6 +355,7 @@ describe("AI merge temp worktree cleanup", () => { const taskId = "FN-777"; const { dir } = initRepoWithBranch(taskId); const orphan = tempAiMergeDir("fusion-ai-merge-fn-777-orphan"); + makeAge(orphan, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000); const { store, audits } = makeStore(taskId); await runAiMerge(store, dir, taskId, { manual: true }, { @@ -246,6 +369,31 @@ describe("AI merge temp worktree cleanup", () => { ])); }); + it("classifies a clean-room deleted mid-merge as transient", async () => { + const { dir } = initRepoWithBranch(); + const { store } = makeStore(); + let observedMergeRoot = ""; + + let thrown: unknown; + try { + await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: vi.fn(async (cwd: string) => { + observedMergeRoot = cwd; + rmSync(cwd, { recursive: true, force: true }); + throw Object.assign(new Error("spawn git ENOTDIR"), { code: "ENOTDIR" }); + }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + } catch (err: unknown) { + thrown = err; + } + + expect(observedMergeRoot.startsWith(resolveAiMergeRootPath(dir, undefined))).toBe(true); + expect(observedMergeRoot.startsWith(resolveLegacyAiMergeRootPath(dir))).toBe(false); + expect(String(thrown)).toMatch(/ENOENT|ENOTDIR|not a working tree/i); + expect(classifyTransientMergeError(String(thrown))).toBe("process-spawn-failure"); + }); + it("pre-merge prune failure does not abort merge", async () => { const { dir } = initRepoWithBranch(); const { store, logs } = makeStore(); diff --git a/packages/engine/src/__tests__/merger-ai-merge-body.test.ts b/packages/engine/src/__tests__/merger-ai-merge-body.test.ts index e31bdfd86d..4a39a22130 100644 --- a/packages/engine/src/__tests__/merger-ai-merge-body.test.ts +++ b/packages/engine/src/__tests__/merger-ai-merge-body.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../pi.js", () => ({ createFnAgent: vi.fn(), @@ -16,7 +16,14 @@ vi.mock("node:child_process", () => ({ import { composeMergeCommitBody, __testOnlyBuildDeterministicMergeMessage as buildDeterministicMergeMessage, + __testOnlyResolveSafeCommitBody as resolveSafeCommitBody, } from "../merger.js"; +import * as core from "@fusion/core"; +import { DEFAULT_SETTINGS } from "@fusion/core"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("composeMergeCommitBody", () => { const commitLog = "- feat: one"; @@ -58,6 +65,82 @@ describe("composeMergeCommitBody", () => { }); }); +describe("resolveSafeCommitBody", () => { + async function resolveWithSettings(settings: Partial<typeof DEFAULT_SETTINGS>) { + const summarySpy = vi.spyOn(core, "summarizeCommitBody").mockResolvedValue("- ai body"); + + await expect(resolveSafeCommitBody({ + rootDir: "/tmp/project", + taskId: "FN-6228", + branch: "fusion/FN-6228", + commitLog: "", + diffStat: "1 file changed", + settings: { + ...DEFAULT_SETTINGS, + useAiMergeCommitSummary: true, + ...settings, + }, + })).resolves.toBe("- ai body"); + + return summarySpy.mock.calls[0]; + } + + it("uses the project title summarizer lane before all fallbacks", async () => { + const call = await resolveWithSettings({ + titleSummarizerProvider: "project-title-provider", + titleSummarizerModelId: "project-title-model", + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + }); + + expect(call?.[2]).toBe("project-title-provider"); + expect(call?.[3]).toBe("project-title-model"); + }); + + it("uses title-summarizer global, planning, project default, then global default fallbacks", async () => { + await expect(resolveWithSettings({ + titleSummarizerGlobalProvider: "global-title-provider", + titleSummarizerGlobalModelId: "global-title-model", + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "global-title-provider", 3: "global-title-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + planningProvider: "planning-provider", + planningModelId: "planning-model", + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "planning-provider", 3: "planning-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + defaultProviderOverride: "project-default-provider", + defaultModelIdOverride: "project-default-model", + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "project-default-provider", 3: "project-default-model" }); + + vi.restoreAllMocks(); + await expect(resolveWithSettings({ + defaultProvider: "global-default-provider", + defaultModelId: "global-default-model", + })).resolves.toMatchObject({ 2: "global-default-provider", 3: "global-default-model" }); + }); +}); + describe("buildDeterministicMergeMessage", () => { const decodeArg = (arg: string) => arg.replace(/^-m\s+"/, "").replace(/"$/, "").replace(/\\(["\\$`])/g, "$1"); diff --git a/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts b/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts index 04e1d882fb..6075fa6f82 100644 --- a/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts +++ b/packages/engine/src/__tests__/merger-file-scope-invariant.test.ts @@ -49,21 +49,18 @@ function createMergeResult(): MergeResult { }; } +let stagedFilesReader: (cwd: string) => Promise<string[]> = vi.fn(async () => []); + +function mockStagedFiles(files: string[]) { + stagedFilesReader = vi.fn(async (_cwd: string) => files); +} + describe("assertSquashOverlapsFileScope", () => { beforeEach(() => { vi.clearAllMocks(); + mockStagedFiles([]); }); - function mockStagedFiles(files: string[]) { - mockedExecSync.mockImplementation((cmd: any) => { - const cmdStr = String(cmd); - if (cmdStr === "git diff --cached --name-only") { - return files.join("\n"); - } - return ""; - }); - } - it("passes without logging when no declared scope exists", async () => { const store = createInvariantStore([]); mockStagedFiles(["packages/engine/src/merger.ts"]); @@ -72,6 +69,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); @@ -86,6 +84,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); @@ -103,6 +102,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); }); @@ -115,6 +115,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).rejects.toMatchObject({ name: "FileScopeViolationError", @@ -132,6 +133,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); }); @@ -144,6 +146,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).rejects.toMatchObject({ name: "FileScopeViolationError", @@ -165,6 +168,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); }); @@ -178,6 +182,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); }); @@ -190,6 +195,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); @@ -214,6 +220,7 @@ describe("assertSquashOverlapsFileScope", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), })).resolves.toBeUndefined(); @@ -230,6 +237,7 @@ describe("assertSquashOverlapsFileScope", () => { describe("enforceSquashFileScopeInvariant audit emission", () => { beforeEach(() => { vi.clearAllMocks(); + mockStagedFiles(["packages/core/src/store.ts"]); }); it("emits run_audit event on file-scope violation but continues", async () => { @@ -245,6 +253,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), resetLabel: "file-scope invariant violation", auditor: auditor as any, @@ -281,6 +290,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), resetLabel: "file-scope invariant violation", auditor: auditor as any, @@ -302,6 +312,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), resetLabel: "file-scope invariant violation", auditor: auditor as any, @@ -328,6 +339,7 @@ describe("enforceSquashFileScopeInvariant audit emission", () => { store: store as never, taskId: "FN-4073", rootDir: "/tmp/root", + stagedFilesReader, task: await (store as any).getTask("FN-4073"), resetLabel: "file-scope invariant violation", })).resolves.toBeUndefined(); diff --git a/packages/engine/src/__tests__/merger-integration-worktree.test.ts b/packages/engine/src/__tests__/merger-integration-worktree.test.ts index 079f9837e1..91e5d923e4 100644 --- a/packages/engine/src/__tests__/merger-integration-worktree.test.ts +++ b/packages/engine/src/__tests__/merger-integration-worktree.test.ts @@ -12,6 +12,7 @@ import { activeSessionRegistry, executingTaskLock } from "../active-session-regi import * as branchAutocorrect from "../branch-autocorrect.js"; import { acquireReuseHandoff, + ensureUsableMergeIntegrationRoot, MergeHandoffRefusedError, probeIntegrationWorktreeState, releaseReuseHandoff, @@ -93,6 +94,87 @@ describe("resolveMergeIntegrationRoot", () => { }); }); +describe("ensureUsableMergeIntegrationRoot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("treats an empty reuse worktree sentinel as missing without probing git", async () => { + const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree"); + + await expect( + ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }), + ).resolves.toMatchObject({ + ok: false, + checked: "reuse-task-worktree", + reason: "missing-task-worktree", + }); + expect(classifySpy).not.toHaveBeenCalled(); + }); + + it("classifies absent reuse worktrees before they can be used as cwd", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ + ok: false, + classification: "missing", + reason: "worktree directory does not exist", + }); + + const result = await ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/dead-task-worktree", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }); + + expect(result).toMatchObject({ + ok: false, + checked: "reuse-task-worktree", + reason: "unusable-task-worktree", + classification: { classification: "missing" }, + }); + expect(worktreePool.classifyTaskWorktree).toHaveBeenCalledWith("/tmp/project-root", "/tmp/dead-task-worktree"); + }); + + it("classifies de-registered reuse worktrees before handoff", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ + ok: false, + classification: "unregistered", + reason: "not registered in git worktree list", + }); + + await expect( + ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/unregistered-task-worktree", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }), + ).resolves.toMatchObject({ + ok: false, + reason: "unusable-task-worktree", + classification: { classification: "unregistered" }, + }); + }); + + it("leaves healthy reuse roots unchanged", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true }); + const resolution = { mode: "reuse-task-worktree" as const, rootDir: "/tmp/task-worktree", branchName: "fusion/fn-5279" }; + + await expect( + ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }), + ).resolves.toEqual({ ok: true, resolution, checked: "reuse-task-worktree" }); + }); + + it("does not stat or git-probe projectRoot/default mode", async () => { + const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree"); + const resolution = { mode: "cwd-integration-branch" as const, rootDir: "/tmp/project-root", branchName: "fusion/fn-5279" }; + + await expect( + ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }), + ).resolves.toEqual({ ok: true, resolution, checked: false }); + expect(classifySpy).not.toHaveBeenCalled(); + }); +}); + describe("resolveIntegrationRemote", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/engine/src/__tests__/mission-execution-loop.test.ts b/packages/engine/src/__tests__/mission-execution-loop.test.ts index d3ac0545ef..3108d554c9 100644 --- a/packages/engine/src/__tests__/mission-execution-loop.test.ts +++ b/packages/engine/src/__tests__/mission-execution-loop.test.ts @@ -1146,7 +1146,7 @@ describe("MissionExecutionLoop", () => { })); }); - it("uses assigned agent runtime model ahead of task/settings for mission validation", async () => { + it("uses task/settings validator model ahead of assigned agent runtime model for mission validation", async () => { const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-AGENT", status: "in-progress" }); missionStore._setFeature(feature); missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature); @@ -1186,8 +1186,8 @@ describe("MissionExecutionLoop", () => { expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ sessionPurpose: "validation", - defaultProvider: "agent-provider", - defaultModelId: "agent-model", + defaultProvider: "task-validator", + defaultModelId: "task-validator-model", })); }); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 24ea03ded5..5ffa69f693 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -30,6 +30,7 @@ const readFileSyncMock = vi.fn((_path?: any) => "{}"); const realpathSyncNativeMock = vi.fn((path: PathLike) => String(path)); const readCustomProvidersMock = vi.fn(() => []); const packageManagerCwdCapture = vi.fn(); +const packageManagerSettingsCapture = vi.fn(); // Route async `exec` through the `execSync` mock so the promisify bridge works. // Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports @@ -107,10 +108,15 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ } }, DefaultPackageManager: class { + private readonly settingsManager: any; + constructor(options: any) { packageManagerCwdCapture(options?.cwd); + packageManagerSettingsCapture(options?.settingsManager); + this.settingsManager = options?.settingsManager; } async resolve() { + this.settingsManager.isProjectTrusted(); return packageManagerResolveMock(); } }, @@ -1272,6 +1278,32 @@ describe("createFnAgent", () => { expect(createAgentSessionMock).toHaveBeenCalledTimes(1); }); + it("exposes project trust on the read-only pi settings view", async () => { + const { createReadOnlyPiSettingsView } = await import("../pi.js"); + + const view = createReadOnlyPiSettingsView("/tmp", "/mock-agent-dir"); + + expect(() => view.isProjectTrusted()).not.toThrow(); + expect(view.isProjectTrusted()).toBe(true); + expect(typeof view.isProjectTrusted()).toBe("boolean"); + }); + + it("passes a project-trusted settings view through package-manager discovery", async () => { + const { createFnAgent } = await import("../pi.js"); + + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + }); + + const settingsView = packageManagerSettingsCapture.mock.calls.at(-1)?.[0]; + expect(settingsView).toEqual(expect.objectContaining({ isProjectTrusted: expect.any(Function) })); + expect(settingsView.isProjectTrusted()).toBe(true); + expect(packageManagerResolveMock).toHaveBeenCalled(); + expect(createAgentSessionMock).toHaveBeenCalledTimes(1); + }); + it("registers extension providers before resolving configured models", async () => { packageManagerResolveMock.mockResolvedValueOnce({ extensions: [{ enabled: true, path: "/extensions/zai-provider" }], diff --git a/packages/engine/src/__tests__/project-engine-manager.test.ts b/packages/engine/src/__tests__/project-engine-manager.test.ts index ac07296761..265e6bd686 100644 --- a/packages/engine/src/__tests__/project-engine-manager.test.ts +++ b/packages/engine/src/__tests__/project-engine-manager.test.ts @@ -432,8 +432,13 @@ describe("ProjectEngineManager", () => { }); describe("startReconciliation / stopReconciliation", () => { + async function flushReconciliationWork(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + } + beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.useFakeTimers(); }); afterEach(() => { @@ -577,9 +582,9 @@ describe("ProjectEngineManager", () => { // Start reconciliation (runs immediate tick which fails all 3) manager.startReconciliation(1000); - // Wait for the immediate tick to complete - await vi.advanceTimersByTimeAsync(100); - await new Promise((resolve) => setTimeout(resolve, 10)); // Let promises settle + // Wait for the immediate tick to complete without mixing real timers into + // this fake-timer block. + await flushReconciliationWork(); // After immediate tick: all should have failed expect(manager.getEngine("proj_aaa")).toBeUndefined(); @@ -588,7 +593,7 @@ describe("ProjectEngineManager", () => { // First scheduled tick (after 1000ms): should retry and succeed await vi.advanceTimersByTimeAsync(1000); - await new Promise((resolve) => setTimeout(resolve, 10)); // Let promises settle + await flushReconciliationWork(); expect(manager.getEngine("proj_aaa")).toBeDefined(); expect(manager.getEngine("proj_bbb")).toBeDefined(); diff --git a/packages/engine/src/__tests__/prompt-cache-integration.test.ts b/packages/engine/src/__tests__/prompt-cache-integration.test.ts index 9e2b1e7194..df33df7c93 100644 --- a/packages/engine/src/__tests__/prompt-cache-integration.test.ts +++ b/packages/engine/src/__tests__/prompt-cache-integration.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect } from "vitest"; +import { resolveAgentPrompt } from "@fusion/core"; import { buildPromptLayers, collapsePromptLayers, type SystemPromptLayers } from "../prompt-layers.js"; -import { REVIEWER_SYSTEM_PROMPT } from "../reviewer.js"; + +const DEFAULT_REVIEWER_PROMPT = resolveAgentPrompt("reviewer"); describe("cross-session prompt cache integration", () => { const MEMORY_INSTRUCTIONS = "\n## Memory\n\nUse fn_memory_search to look up relevant context."; function simulateReviewerSession(sessionIndex: number): SystemPromptLayers { return buildPromptLayers({ - basePrompt: REVIEWER_SYSTEM_PROMPT, + basePrompt: DEFAULT_REVIEWER_PROMPT, agentInstructions: `Session ${sessionIndex}: custom instructions that vary per agent.`, memorySection: MEMORY_INSTRUCTIONS, pluginContributions: sessionIndex % 2 === 0 @@ -42,8 +44,8 @@ describe("cross-session prompt cache integration", () => { } }); - it("stable prefix starts with REVIEWER_SYSTEM_PROMPT", () => { + it("stable prefix starts with the canonical default reviewer prompt", () => { const layers = simulateReviewerSession(0); - expect(layers.stable.startsWith(REVIEWER_SYSTEM_PROMPT)).toBe(true); + expect(layers.stable.startsWith(DEFAULT_REVIEWER_PROMPT)).toBe(true); }); }); diff --git a/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts b/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts new file mode 100644 index 0000000000..627bf689bc --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/ai-merge-cleanup-enoent-idempotent.test.ts @@ -0,0 +1,128 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { runAiMerge } from "../../merger-ai.js"; +import { hasGit } from "./_helpers.js"; + +const tracked = new Set<string>(); +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; + +afterAll(() => { + for (const dir of tracked) { + try { rmSync(dir, RM); } catch { /* best effort cleanup */ } + } +}); + +function git(cwd: string, args: string): string { + return execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function createRepo(taskId: string): { rootDir: string; branch: string } { + const branch = `fusion/${taskId.toLowerCase()}`; + const rootDir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-enoent-")); + tracked.add(rootDir); + git(rootDir, "init -q -b main"); + git(rootDir, 'config user.email "test@example.com"'); + git(rootDir, 'config user.name "Test User"'); + writeFileSync(join(rootDir, "README.md"), "# fixture\n"); + git(rootDir, "add README.md"); + git(rootDir, 'commit -q -m "chore: init"'); + git(rootDir, `checkout -q -b ${branch}`); + writeFileSync(join(rootDir, "feature.txt"), "feature work\n"); + git(rootDir, "add feature.txt"); + git(rootDir, 'commit -q -m "feat: task work"'); + git(rootDir, "checkout -q main"); + return { rootDir, branch }; +} + +function makeStore(taskId: string, branch: string) { + const task: any = { + id: taskId, + column: "in-review", + status: null, + branch, + baseBranch: "main", + worktree: null, + title: "AI merge cleanup ENOENT fixture", + steps: [{ title: "ready", status: "done" }], + }; + const audits: any[] = []; + const logs: string[] = []; + const store: any = { + getTask: vi.fn(async () => task), + getSettings: vi.fn(async () => ({ + autoMerge: true, + includeTaskIdInCommit: true, + commitAuthorEnabled: false, + merger: { mode: "ai", maxReviewPasses: 1 }, + })), + updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => { Object.assign(task, patch); return task; }), + moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }), + emit: vi.fn(), + logEntry: vi.fn(async (_id: string, message: string) => { logs.push(message); }), + appendAgentLog: vi.fn(async (_id: string, message: string) => { logs.push(message); }), + recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }), + }; + return { store, task, audits, logs }; +} + +function realMergeAgent(branch: string, onCwd?: (cwd: string) => void) { + return vi.fn(async (cwd: string) => { + onCwd?.(cwd); + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + execSync("git add -A", { cwd, stdio: "pipe" }); + execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" }); + }); +} + +describe("FN-6257 AI-merge cleanup ENOENT idempotency (real git)", () => { + it.skipIf(!hasGit)("finalizes done when the temp worktree vanishes after the squash lands", async () => { + const taskId = "FN-6257-RI"; + const { rootDir, branch } = createRepo(taskId); + const { store, task, audits } = makeStore(taskId, branch); + const originalRecordRunAuditEvent = store.recordRunAuditEvent; + let observedMergeRoot = ""; + let removedAfterConfirmedLand = false; + + store.recordRunAuditEvent = vi.fn(async (event: any) => { + const confirmedLandEvent = ( + event.mutationType === "merge:integration-ref-advance" && event.metadata?.succeeded === true + ) || ( + event.mutationType === "merge:ai-local-sync" && ["ff", "skipped-other-branch", "stash-ff-restore", "stash-ff-airesolved", "stash-ff-conflict"].includes(String(event.metadata?.outcome ?? "")) + ); + if (confirmedLandEvent && observedMergeRoot && !removedAfterConfirmedLand) { + removedAfterConfirmedLand = true; + rmSync(observedMergeRoot, RM); + } + await originalRecordRunAuditEvent(event); + }); + + const mainBefore = git(rootDir, "rev-parse main"); + + const result = await runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent: realMergeAgent(branch, (cwd) => { observedMergeRoot = cwd; }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(removedAfterConfirmedLand).toBe(true); + expect(result).toMatchObject({ ok: true, merged: true, mergeConfirmed: true }); + expect(git(rootDir, "rev-parse main")).not.toBe(mainBefore); + expect(task.column).toBe("done"); + expect(task.status ?? null).toBeNull(); + expect(task.error).toBeUndefined(); + expect(task.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(task.mergeDetails).toEqual(expect.objectContaining({ + commitSha: result.commitSha, + mergeConfirmed: true, + })); + expect(audits).toEqual(expect.arrayContaining([ + expect.objectContaining({ mutationType: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true }) }), + expect.objectContaining({ mutationType: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true, alreadyAbsent: true, idempotent: true }) }), + ])); + expect(audits).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ mutationType: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ success: false }) }), + ])); + }, 20_000); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/ai-merge-worktree-cleanup.test.ts b/packages/engine/src/__tests__/reliability-interactions/ai-merge-worktree-cleanup.test.ts new file mode 100644 index 0000000000..29161305d0 --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/ai-merge-worktree-cleanup.test.ts @@ -0,0 +1,302 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { DEFAULT_SETTINGS, TaskStore, type Settings } from "@fusion/core"; +import { cleanupAiMergeWorktree, resolveAiMergeRoot, runAiMerge } from "../../merger-ai.js"; +import { hasGit } from "./_helpers.js"; +import type { RunAuditor } from "../../run-audit.js"; + +const tracked = new Set<string>(); +const taskIds = new Set<string>(); +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; + +afterAll(() => { + for (const taskId of taskIds) removeTmpAiMergeDirs(taskId); + for (const dir of tracked) { + try { + rmSync(dir, RM); + } catch { + // best effort cleanup + } + } +}); + +function git(cwd: string, args: string): string { + return execSync(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function aiMergePrefix(taskId: string): string { + return `fusion-ai-merge-${taskId.toLowerCase()}-`; +} + +function tmpAiMergeDirs(taskId: string): string[] { + const prefix = aiMergePrefix(taskId); + return readdirSync(tmpdir()) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => join(tmpdir(), entry)); +} + +function localAiMergeDirs(rootDir: string, taskId: string): string[] { + const root = resolveAiMergeRoot(rootDir); + const prefix = aiMergePrefix(taskId); + return readdirSync(root) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => join(root, entry)); +} + +function removeTmpAiMergeDirs(taskId: string): void { + for (const dir of tmpAiMergeDirs(taskId)) { + try { + rmSync(dir, RM); + } catch { + // best effort cleanup + } + } +} + +function expectNoAiMergeWorktrees(rootDir: string, taskId: string): void { + expect(tmpAiMergeDirs(taskId), `legacy tmpdir entries for ${taskId}`).toEqual([]); + expect(localAiMergeDirs(rootDir, taskId), `repo-local AI merge entries for ${taskId}`).toEqual([]); + const worktrees = git(rootDir, "worktree list --porcelain"); + expect(worktrees).not.toContain(aiMergePrefix(taskId)); +} + +function makeAge(path: string, ageMs: number): void { + const old = new Date(Date.now() - ageMs); + utimesSync(path, old, old); +} + +function realMergeAgent(branch: string) { + return vi.fn(async (cwd: string) => { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + execSync("git add -A", { cwd, stdio: "pipe" }); + execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" }); + }); +} + +async function createFixture(label: string) { + const rootDir = mkdtempSync(join(tmpdir(), `fusion-ai-merge-cleanup-${label.toLowerCase()}-`)); + tracked.add(rootDir); + git(rootDir, "init -q -b main"); + git(rootDir, 'config user.email "test@example.com"'); + git(rootDir, 'config user.name "Test User"'); + writeFileSync(join(rootDir, "README.md"), `# ${label}\n`); + git(rootDir, "add README.md"); + git(rootDir, 'commit -q -m "chore: init"'); + + const store = new TaskStore(rootDir, undefined, { inMemoryDb: true }); + await store.init(); + const settings: Settings = { + ...DEFAULT_SETTINGS, + autoMerge: true, + includeTaskIdInCommit: true, + commitAuthorEnabled: false, + merger: { ...(DEFAULT_SETTINGS.merger ?? {}), mode: "ai", maxReviewPasses: 1 }, + } as Settings; + await store.updateSettings(settings); + + const created = await store.createTask({ + title: label, + description: "AI merge worktree cleanup fixture", + column: "in-review", + baseBranch: "main", + prompt: "## File Scope\n- packages/engine/src/**\n", + } as any); + const branch = `fusion/${created.id.toLowerCase()}`; + await store.updateTask(created.id, { + column: "in-review", + branch, + baseBranch: "main", + steps: [{ title: "ready", status: "done" }], + status: null, + } as any); + taskIds.add(created.id); + removeTmpAiMergeDirs(created.id); + + return { + rootDir, + store, + taskId: created.id, + branch, + cleanup: async () => { + removeTmpAiMergeDirs(created.id); + for (const dir of localAiMergeDirs(rootDir, created.id)) rmSync(dir, RM); + store.close(); + rmSync(rootDir, RM); + tracked.delete(rootDir); + }, + }; +} + +function commitTaskBranch(rootDir: string, branch: string, filename: string, contents: string): void { + git(rootDir, `checkout -q -b ${branch}`); + writeFileSync(join(rootDir, filename), contents); + git(rootDir, `add ${filename}`); + git(rootDir, `commit -q -m "feat: ${filename}"`); + git(rootDir, "checkout -q main"); +} + +function makeAudit() { + const events: any[] = []; + const audit: RunAuditor = { + git: vi.fn(async (event: any) => { events.push(event); }), + database: vi.fn(async () => undefined), + filesystem: vi.fn(async () => undefined), + sandbox: vi.fn(async () => undefined), + }; + return { audit, events }; +} + +describe("FN-6220 AI-merge worktree cleanup lifecycle (real git)", () => { + it.skipIf(!hasGit)("removes temp worktree after a successful AI land", async () => { + const fixture = await createFixture("success"); + const { rootDir, store, taskId, branch, cleanup } = fixture; + + try { + commitTaskBranch(rootDir, branch, "feature.txt", "feature work\n"); + + const result = await runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent: realMergeAgent(branch), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result).toMatchObject({ ok: true, merged: true }); + expectNoAiMergeWorktrees(rootDir, taskId); + } finally { + await cleanup(); + } + }, 20_000); + + it.skipIf(!hasGit)("removes temp worktree after an empty no-op AI merge", async () => { + const fixture = await createFixture("noop"); + const { rootDir, store, taskId, branch, cleanup } = fixture; + + try { + git(rootDir, `checkout -q -b ${branch}`); + git(rootDir, "checkout -q main"); + + const result = await runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent: vi.fn(async () => { + // Leave HEAD at the integration tip so mergeAndReview returns null. + }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result).toMatchObject({ ok: true, noOp: true, merged: false }); + expectNoAiMergeWorktrees(rootDir, taskId); + } finally { + await cleanup(); + } + }, 20_000); + + it.skipIf(!hasGit)("cleans each temp worktree before retrying after a concurrent advance", async () => { + const fixture = await createFixture("concurrent"); + const { rootDir, store, taskId, branch, cleanup } = fixture; + + try { + commitTaskBranch(rootDir, branch, "feature.txt", "feature work\n"); + git(rootDir, "checkout -q -b parking main"); + let attempts = 0; + const mergeRoots: string[] = []; + const mergeAgent = vi.fn(async (cwd: string) => { + attempts++; + mergeRoots.push(cwd); + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + execSync("git add -A", { cwd, stdio: "pipe" }); + execSync(`git commit -q -m "squash: feature attempt ${attempts}"`, { cwd, stdio: "pipe" }); + if (attempts === 1) { + const mainBefore = git(rootDir, "rev-parse refs/heads/main"); + const concurrentSha = git(rootDir, 'commit-tree refs/heads/main^{tree} -p refs/heads/main -m "chore: concurrent advance"'); + git(rootDir, `update-ref refs/heads/main ${concurrentSha} ${mainBefore}`); + } + }); + + const result = await runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent, + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(result).toMatchObject({ ok: true, merged: true }); + expect(attempts).toBe(2); + expect(mergeRoots).toHaveLength(2); + expect(mergeRoots.every((dir) => !existsSync(dir))).toBe(true); + expectNoAiMergeWorktrees(rootDir, taskId); + } finally { + await cleanup(); + } + }, 20_000); + + it.skipIf(!hasGit)("removes temp worktree when the merge agent throws", async () => { + const fixture = await createFixture("throws"); + const { rootDir, store, taskId, branch, cleanup } = fixture; + + try { + commitTaskBranch(rootDir, branch, "feature.txt", "feature work\n"); + + await expect(runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent: vi.fn(async () => { throw new Error("simulated merge failure"); }), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + })).rejects.toThrow("simulated merge failure"); + + expectNoAiMergeWorktrees(rootDir, taskId); + } finally { + await cleanup(); + } + }, 20_000); + + it.skipIf(!hasGit)("pre-merge prune removes an FN-6207-style directory whose git registration is already gone", async () => { + const fixture = await createFixture("orphan-dir"); + const { rootDir, store, taskId, branch, cleanup } = fixture; + + try { + commitTaskBranch(rootDir, branch, "feature.txt", "feature work\n"); + const orphanRoot = resolveAiMergeRoot(rootDir); + mkdirSync(orphanRoot, { recursive: true }); + const orphanDir = mkdtempSync(join(orphanRoot, aiMergePrefix(taskId))); + makeAge(orphanDir, 11 * 60_000); + expect(existsSync(orphanDir)).toBe(true); + + await runAiMerge(store, rootDir, taskId, { manual: true, allowDirtyLocalCheckoutSync: true }, { + mergeAgent: realMergeAgent(branch), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + expect(existsSync(orphanDir)).toBe(false); + expectNoAiMergeWorktrees(rootDir, taskId); + } finally { + await cleanup(); + } + }, 20_000); + + it.skipIf(!hasGit)("cleanup prunes a dangling git registration whose directory is already gone", async () => { + const fixture = await createFixture("dangling-registration"); + const { rootDir, taskId, cleanup } = fixture; + const { audit } = makeAudit(); + const logs: string[] = []; + + try { + const staleRoot = mkdtempSync(join(tmpdir(), aiMergePrefix(taskId))); + rmSync(staleRoot, RM); + git(rootDir, `worktree add --detach ${staleRoot} main`); + rmSync(staleRoot, RM); + expect(git(rootDir, "worktree list --porcelain")).toContain(staleRoot); + + await cleanupAiMergeWorktree({ + taskId, + mergeRoot: staleRoot, + projectRootDir: rootDir, + worktreeAdded: true, + audit, + log: vi.fn(async (message: string) => { logs.push(message); }), + }); + + expect(existsSync(staleRoot)).toBe(false); + expectNoAiMergeWorktrees(rootDir, taskId); + expect(logs.join("\n")).not.toContain("filesystem rm failed"); + } finally { + await cleanup(); + } + }, 20_000); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts b/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts new file mode 100644 index 0000000000..51de441fbe --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts @@ -0,0 +1,233 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../pi.js", () => ({ + createFnAgent: vi.fn(async () => ({ + prompt: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + })), + describeModel: vi.fn(() => "mock-provider/mock-model"), + promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<unknown> }, prompt: string) => { + await session.prompt(prompt); + }), + compactSessionContext: vi.fn(), +})); + +import type { Settings } from "@fusion/core"; +import { activeSessionRegistry, executingTaskLock } from "../../active-session-registry.js"; +import { aiMergeTask } from "../../merger.js"; +import { git, hasGit, makeReliabilityFixture } from "./_helpers.js"; + +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; + +async function setupReuseMergeFixture(opts: { + taskId: string; + fileName: string; + fileContent: string; + skipEnqueue?: boolean; +}): Promise<{ + rootDir: string; + store: Awaited<ReturnType<typeof makeReliabilityFixture>>["store"]; + taskId: string; + branch: string; + fixture: Awaited<ReturnType<typeof makeReliabilityFixture>>; + worktreeRoot: string; + worktreePath: string; +}> { + const fixture = await makeReliabilityFixture({ + taskId: opts.taskId, + settings: { + baseBranch: "master", + mergeIntegrationWorktree: "reuse-task-worktree", + worktreeRebaseRemote: "origin", + } as Partial<Settings>, + }); + const { rootDir, store, task } = fixture; + const actualTask = await store.getTask(task.id); + const branch = `fusion/${actualTask!.id.toLowerCase()}`; + const worktreeRoot = `${rootDir}-worktrees`; + const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase()); + + git(rootDir, "git branch -m main master"); + const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const })); + await store.updateTask(task.id, { + baseBranch: "master", + branch, + steps: completedSteps, + currentStep: completedSteps.length, + } as any); + await fixture.createBranch(branch); + await fixture.writeAndCommit(opts.fileName, opts.fileContent, `feat: add ${opts.taskId} merge content`); + await fixture.checkout("master"); + + await mkdir(worktreeRoot, { recursive: true }); + git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`); + await store.updateTask(task.id, { worktree: worktreePath, branch } as any); + if (!opts.skipEnqueue) { + store.enqueueMergeQueue(task.id); + } + + return { rootDir, store, taskId: task.id, branch, fixture, worktreeRoot, worktreePath }; +} + +async function cleanupFixture(fixture: Awaited<ReturnType<typeof makeReliabilityFixture>>, worktreeRoot: string): Promise<void> { + await fixture.cleanup(); + await rm(worktreeRoot, RM); +} + +describe("FN-6278 reliability interactions: merge runner cwd preflight", () => { + beforeEach(() => { + activeSessionRegistry.clear(); + executingTaskLock._clearForTest(); + }); + + it.skipIf(!hasGit)("reacquires before spawning git when the reuse worktree cwd vanished", async () => { + const { fixture, rootDir, store, taskId, branch, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-VANISHED", + fileName: "packages/engine/src/fn-6278-ri-vanished.ts", + fileContent: "export const vanishedReuseWorktree = true;\n", + }); + + try { + // Leave the git worktree registration stale but remove the filesystem cwd. + // Before FN-6278, the first merge-runner git spawn using this cwd threw + // `spawn git ENOENT` and could park the task failed after retry exhaustion. + await rm(worktreePath, RM); + + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.status ?? null).toBeNull(); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquired"); + expect(auditTypes).toContain("merge:reuse-fallback-new-worktree"); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + expect(auditTypes).not.toContain("MergeNonConflictFailure"); + + const freshAcquire = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquire"); + expect(freshAcquire?.metadata).toMatchObject({ + taskId, + reason: "unusable-task-worktree", + expectedBranch: branch, + priorWorktreePath: worktreePath, + diagnostics: { + requestedMode: "reuse-task-worktree", + classification: expect.objectContaining({ classification: "missing" }), + }, + }); + const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired"); + expect(acquired?.target).toBe(worktreePath); + expect(git(rootDir, "git ls-files")).toContain("packages/engine/src/fn-6278-ri-vanished.ts"); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("reacquires before spawning git when the reuse worktree is present but de-registered", async () => { + const { fixture, rootDir, store, taskId, branch, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-UNREGISTERED", + fileName: "packages/engine/src/fn-6278-ri-unregistered.ts", + fileContent: "export const unregisteredReuseWorktree = true;\n", + }); + const unregisteredPath = join(worktreeRoot, "present-but-unregistered"); + + try { + // Remove the valid linked worktree, then point the task at a different + // present directory that has git metadata but is not in `git worktree list`. + // This drives the distinct `classification: unregistered` preflight branch. + git(rootDir, `git worktree remove --force ${JSON.stringify(worktreePath)}`); + await mkdir(unregisteredPath, { recursive: true }); + await writeFile(join(unregisteredPath, ".git"), "gitdir: /tmp/fusion-unregistered-placeholder\n", "utf-8"); + await store.updateTask(taskId, { worktree: unregisteredPath, branch } as any); + + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.status ?? null).toBeNull(); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + const freshAcquire = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquire"); + expect(freshAcquire?.metadata).toMatchObject({ + taskId, + reason: "unusable-task-worktree", + expectedBranch: branch, + priorWorktreePath: unregisteredPath, + diagnostics: { + requestedMode: "reuse-task-worktree", + classification: expect.objectContaining({ classification: "unregistered" }), + }, + }); + expect(git(rootDir, "git ls-files")).toContain("packages/engine/src/fn-6278-ri-unregistered.ts"); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("leaves a healthy reuse worktree on the normal handoff path", async () => { + const { fixture, rootDir, store, taskId, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-HEALTHY", + fileName: "packages/engine/src/fn-6278-ri-healthy.ts", + fileContent: "export const healthyReuseWorktree = true;\n", + }); + + try { + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + expect(auditTypes).not.toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).not.toContain("merge:reuse-fallback-new-worktree"); + const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired"); + expect(acquired?.target).toBe(worktreePath); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("still surfaces genuine handoff failures after a healthy cwd preflight", async () => { + const { fixture, rootDir, store, taskId, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-ACTIVE-BINDING", + fileName: "packages/engine/src/fn-6278-ri-active-binding.ts", + fileContent: "export const nonRecoverableHandoffFailure = true;\n", + }); + activeSessionRegistry.registerPath(worktreePath, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" }); + + try { + await expect(aiMergeTask(store, rootDir, taskId)).rejects.toMatchObject({ + name: "MergeHandoffRefusedError", + gate: "active-session-binding", + }); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(taskAfter?.column).toBe("in-review"); + expect(taskAfter?.status ?? null).not.toBe("failed"); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(auditTypes).toContain("merge:reuse-handoff-refused"); + expect(auditTypes).not.toContain("merge:reuse-worktree-fresh-acquire"); + const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused"); + expect(refused?.metadata).toMatchObject({ gate: "active-session-binding" }); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/worktree-remove-non-empty-recovery.real-git.test.ts b/packages/engine/src/__tests__/reliability-interactions/worktree-remove-non-empty-recovery.real-git.test.ts new file mode 100644 index 0000000000..70c25dd310 --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/worktree-remove-non-empty-recovery.real-git.test.ts @@ -0,0 +1,139 @@ +import { access, chmod, mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { NativeWorktreeBackend, RemovalReason, removeWorktree } from "../../worktree-backend.js"; +import { git, hasGit } from "./_helpers.js"; + +async function pathExists(path: string): Promise<boolean> { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe.skipIf(!hasGit)("reliability interactions: worktree remove non-empty recovery", () => { + const roots: string[] = []; + let originalPath: string | undefined; + let originalFailPath: string | undefined; + + afterEach(async () => { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalFailPath === undefined) { + delete process.env.FUSION_FAIL_GIT_WORKTREE_REMOVE_PATH; + } else { + process.env.FUSION_FAIL_GIT_WORKTREE_REMOVE_PATH = originalFailPath; + } + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; + }); + + async function setupRepo(prefix = "fusion-remove-non-empty-") { + const root = await mkdtemp(join(tmpdir(), prefix)); + roots.push(root); + git(root, "git init -b main"); + git(root, 'git config user.email "test@example.com"'); + git(root, 'git config user.name "Test User"'); + await writeFile(join(root, "README.md"), "# repo\n", "utf-8"); + git(root, "git add README.md"); + git(root, 'git commit -m "init"'); + return root; + } + + async function createWorktree(root: string, name: string, branch: string): Promise<string> { + const worktreePath = join(root, ".worktrees", name); + git(root, `git worktree add -b ${JSON.stringify(branch)} ${JSON.stringify(worktreePath)}`); + return worktreePath; + } + + async function installGitRemoveFailureShim( + targetPath: string, + stderr = "error: failed to delete '$4': Directory not empty", + ): Promise<void> { + const realGit = git(process.cwd(), "command -v git"); + const shimDir = await mkdtemp(join(tmpdir(), "fusion-fake-git-")); + roots.push(shimDir); + const shimPath = join(shimDir, "git"); + await writeFile( + shimPath, + `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "remove" ] && [ "$3" = "--force" ] && [ "$4" = "$FUSION_FAIL_GIT_WORKTREE_REMOVE_PATH" ]; then\n echo ${JSON.stringify(stderr)} >&2\n exit 1\nfi\nexec ${JSON.stringify(realGit)} "$@"\n`, + "utf-8", + ); + await chmod(shimPath, 0o755); + originalPath = process.env.PATH; + originalFailPath = process.env.FUSION_FAIL_GIT_WORKTREE_REMOVE_PATH; + process.env.PATH = `${shimDir}${process.env.PATH ? `:${process.env.PATH}` : ""}`; + process.env.FUSION_FAIL_GIT_WORKTREE_REMOVE_PATH = targetPath; + } + + async function expectWorktreeRemoved(root: string, worktreePath: string): Promise<void> { + expect(await pathExists(worktreePath)).toBe(false); + const porcelain = git(root, "git worktree list --porcelain"); + expect(porcelain).not.toContain(`worktree ${worktreePath}`); + expect(porcelain).not.toContain(`worktree ${await realpath(dirname(worktreePath)).catch(() => dirname(worktreePath))}/${worktreePath.split("/").pop()}`); + } + + it("removes and prunes a worktree with untracked-only content when git remove reports Directory not empty", async () => { + const root = await setupRepo(); + const worktreePath = await createWorktree(root, "fn-untracked", "fusion/fn-untracked"); + const resolvedWorktreePath = await realpath(worktreePath); + await mkdir(join(worktreePath, "dist"), { recursive: true }); + await writeFile(join(worktreePath, "dist", "artifact.txt"), "artifact\n", "utf-8"); + await installGitRemoveFailureShim(worktreePath); + const events: string[] = []; + + await removeWorktree({ + rootDir: root, + worktreePath, + settings: {}, + reason: RemovalReason.ExecutorDispose, + force: true, + audit: { git: async (event) => void events.push(event.type) }, + }); + + await expectWorktreeRemoved(root, resolvedWorktreePath); + expect(events).toContain("worktree:remove-fallback"); + expect(events).toContain("worktree:admin-entry-pruned"); + expect(events).toContain("worktree:remove"); + }); + + it("removes and prunes a worktree with nested-git content when native removal falls back", async () => { + const root = await setupRepo(); + const worktreePath = await createWorktree(root, "fn-nested", "fusion/fn-nested"); + const resolvedWorktreePath = await realpath(worktreePath); + const nestedRepo = join(worktreePath, "node_modules", "inner-repo"); + await mkdir(nestedRepo, { recursive: true }); + git(nestedRepo, "git init -b main"); + await writeFile(join(nestedRepo, "package.json"), "{}\n", "utf-8"); + await installGitRemoveFailureShim(worktreePath); + + await new NativeWorktreeBackend().remove({ rootDir: root, worktreePath }); + + await expectWorktreeRemoved(root, resolvedWorktreePath); + }); + + it("preserves native already-missing validation-failed behavior", async () => { + const root = await setupRepo(); + const worktreePath = await createWorktree(root, "fn-missing", "fusion/fn-missing"); + await rm(worktreePath, { recursive: true, force: true }); + await installGitRemoveFailureShim(worktreePath, "fatal: validation failed, cannot remove working tree"); + + await expect(new NativeWorktreeBackend().remove({ rootDir: root, worktreePath })).rejects.toThrow(/validation failed/i); + }); + + it("still rethrows non-recoverable native removal failures", async () => { + const root = await setupRepo(); + const notAWorktreePath = join(root, "not-a-worktree"); + await mkdir(notAWorktreePath); + + await expect(new NativeWorktreeBackend().remove({ rootDir: root, worktreePath: notAWorktreePath })).rejects.toThrow(); + expect(await pathExists(notAWorktreePath)).toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/worktrunk-worktree-removal.test.ts b/packages/engine/src/__tests__/reliability-interactions/worktrunk-worktree-removal.test.ts index c93459d8f2..04f2827379 100644 --- a/packages/engine/src/__tests__/reliability-interactions/worktrunk-worktree-removal.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/worktrunk-worktree-removal.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import type { Settings, TaskStore, Task } from "@fusion/core"; import { cleanupOrphanedWorktrees } from "../../worktree-pool.js"; @@ -22,6 +22,26 @@ vi.mock("node:fs", async (importOriginal) => { return { ...actual, existsSync: existsSpy, readdirSync: readdirSpy }; }); + +function mockWorktreeRemoveFailure(postMergePath: string, porcelainOutput: string): void { + execSpy.mockImplementation((cmd: string, _opts: unknown, cb: (err: any, stdout: string, stderr: string) => void) => { + if (cmd.includes("git worktree remove")) { + const stderr = `fatal: validation failed, cannot remove working tree: '${postMergePath}/.git' is not a .git file, error code 2`; + cb(Object.assign(new Error(stderr), { stderr, status: 2 }), "", stderr); + return; + } + if (cmd === "git worktree prune") { + cb(null, "", ""); + return; + } + if (cmd === "git worktree list --porcelain") { + cb(null, porcelainOutput, ""); + return; + } + cb(null, "", ""); + }); +} + function storeForSelfHealing(settings: Partial<Settings>, task: Partial<Task>): TaskStore & EventEmitter { const emitter = new EventEmitter(); return Object.assign(emitter, { @@ -41,6 +61,10 @@ describe("reliability interactions: worktrunk worktree removal routing", () => { existsSpy.mockReturnValue(true); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("merger post-merge cleanup calls worktrunk backend remove and avoids native git remove", async () => { const removeSpy = vi.spyOn(WorktrunkWorktreeBackend.prototype, "remove").mockResolvedValue(undefined); @@ -52,6 +76,36 @@ describe("reliability interactions: worktrunk worktree removal routing", () => { expect(execSpy.mock.calls.some((call) => String(call[0]).includes("git worktree remove"))).toBe(false); }); + it("merger post-merge cleanup logs harmless classified temp residue when porcelain is absent after prune", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const postMergePath = "/repo/.worktrees/post-merge-FN-343-abcd1234"; + mockWorktreeRemoveFailure(postMergePath, "worktree /repo\nbranch refs/heads/main\n"); + + await mergerTestHooks.removePostMergeWorktree("/repo", postMergePath, "FN-343", {}); + + expect(execSpy.mock.calls.map((call) => String(call[0]))).toEqual([ + `git worktree remove --force "${postMergePath}"`, + "git worktree prune", + "git worktree list --porcelain", + ]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("post-merge worktree cleanup classified harmless"), + ); + }); + + it("merger post-merge cleanup keeps still-registered temp worktree failures visible", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const postMergePath = "/repo/.worktrees/post-merge-FN-343-abcd1234"; + mockWorktreeRemoveFailure(postMergePath, `worktree /repo\nbranch refs/heads/main\n\nworktree ${postMergePath}\nbranch refs/heads/fusion/fn-343\n`); + + await mergerTestHooks.removePostMergeWorktree("/repo", postMergePath, "FN-343", {}); + + expect(execSpy.mock.calls.map((call) => String(call[0]))).toContain("git worktree list --porcelain"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`failed to remove post-merge worktree ${postMergePath}`), + ); + }); + it("self-healing recover path calls worktrunk backend remove and not native remove", async () => { const removeSpy = vi.spyOn(WorktrunkWorktreeBackend.prototype, "remove").mockResolvedValue(undefined); const task = { diff --git a/packages/engine/src/__tests__/research-orchestrator.test.ts b/packages/engine/src/__tests__/research-orchestrator.test.ts index d75187ff72..a0ae6e2438 100644 --- a/packages/engine/src/__tests__/research-orchestrator.test.ts +++ b/packages/engine/src/__tests__/research-orchestrator.test.ts @@ -113,6 +113,35 @@ describe("ResearchOrchestrator", () => { expect(status.phase).toBe("completed"); }); + it("normalizes dashboard string provider configs before searching", async () => { + const { store, stepRunner } = createHarness(); + const orchestrator = new ResearchOrchestrator({ + store: store as never, + stepRunner: stepRunner as never, + maxConcurrentRuns: 2, + }); + + const run = store.createRun({ + query: "dashboard research", + providerConfig: { + providers: ["web-search", "page-fetch", "llm-synthesis"], + maxResults: 3, + }, + }); + + const completed = await orchestrator.startRun(run.id, "dashboard research"); + expect(completed.status).toBe("completed"); + expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2); + expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(1, "dashboard research", "web-search", undefined, expect.anything()); + expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(2, "dashboard research", "page-fetch", undefined, expect.anything()); + expect(store.addEvent).toHaveBeenCalledWith( + run.id, + expect.objectContaining({ + message: "Search with web-search started", + }), + ); + }); + it("cancels a running run", async () => { const { store, stepRunner } = createHarness(); stepRunner.runSourceQuery.mockImplementation( diff --git a/packages/engine/src/__tests__/reviewer-prompt-single-source.test.ts b/packages/engine/src/__tests__/reviewer-prompt-single-source.test.ts new file mode 100644 index 0000000000..64a448cc1b --- /dev/null +++ b/packages/engine/src/__tests__/reviewer-prompt-single-source.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + BUILTIN_CODING_WORKFLOW_IR, + resolveAgentPrompt, + resolveSeamPromptFromIr, + type WorkflowIr, +} from "@fusion/core"; + +vi.mock("../pi.js", () => ({ + createFnAgent: vi.fn(), + describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"), + promptWithFallback: vi.fn(async (session, prompt, options) => { + if (options === undefined) { + await session.prompt(prompt); + } else { + await session.prompt(prompt, options); + } + }), +})); + +import { reviewStep } from "../reviewer.js"; +import { createFnAgent } from "../pi.js"; + +const mockedCreateFnAgent = vi.mocked(createFnAgent); + +function createMockSession(reviewText = "### Verdict: APPROVE\n### Summary\nLooks good.") { + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn().mockImplementation((cb: any) => { + cb({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: reviewText }, + }); + }), + dispose: vi.fn(), + }, + } as any; +} + +function createStore(workflowId = "builtin:coding", customIr?: WorkflowIr) { + return { + getSettings: vi.fn().mockResolvedValue({}), + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId, stepIds: [] }), + getWorkflowDefinition: vi.fn().mockImplementation(async (id: string) => { + if (customIr && id === workflowId) return { ir: customIr }; + return undefined; + }), + } as any; +} + +async function captureReviewerSystemPrompt(options: Parameters<typeof reviewStep>[7] = {}) { + mockedCreateFnAgent.mockResolvedValue(createMockSession()); + await reviewStep( + "/tmp/worktree", + "FN-6235", + 1, + "Review prompt source", + "plan", + "# Plan", + undefined, + options, + ); + return mockedCreateFnAgent.mock.calls[0][0].systemPrompt as string; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("reviewer prompt single source", () => { + it("does not reintroduce an engine reviewer policy constant", () => { + const reviewerSource = readFileSync( + resolve(fileURLToPath(new URL("..", import.meta.url)), "reviewer.ts"), + "utf8", + ); + + expect(reviewerSource).not.toMatch(/export const REVIEWER_SYSTEM_PROMPT\s*=/); + expect(reviewerSource).not.toMatch(/export const [A-Z_]*REVIEWER[A-Z_]*SYSTEM_PROMPT\s*=/); + }); + + it("keeps builtin coding review seam byte-identical to the default reviewer prompt", () => { + expect(resolveSeamPromptFromIr(BUILTIN_CODING_WORKFLOW_IR, "review")).toBe(resolveAgentPrompt("reviewer")); + }); + + it("uses the builtin coding IR review-node prompt when no user override is set", async () => { + const systemPrompt = await captureReviewerSystemPrompt({ store: createStore() }); + + expect(systemPrompt).toBe(resolveSeamPromptFromIr(BUILTIN_CODING_WORKFLOW_IR, "review")); + }); + + it("uses a selected custom workflow review-node prompt", async () => { + const customIr: WorkflowIr = { + version: "v1", + name: "custom-reviewer", + nodes: [ + { id: "start", kind: "start" }, + { id: "review", kind: "prompt", config: { seam: "review", prompt: "custom workflow reviewer prompt" } }, + ], + edges: [], + }; + + const systemPrompt = await captureReviewerSystemPrompt({ store: createStore("WF-review", customIr) }); + + expect(systemPrompt).toBe("custom workflow reviewer prompt"); + }); + + it("preserves reviewer user-override precedence over workflow IR prompts", async () => { + const customIr: WorkflowIr = { + version: "v1", + name: "custom-reviewer", + nodes: [ + { id: "review", kind: "prompt", config: { seam: "review", prompt: "workflow prompt should not win" } }, + ], + edges: [], + }; + + const systemPrompt = await captureReviewerSystemPrompt({ + store: createStore("WF-review", customIr), + agentPrompts: { + templates: [{ + id: "custom-reviewer", + name: "Custom Reviewer", + description: "Project reviewer override", + role: "reviewer", + prompt: "user override reviewer prompt", + }], + roleAssignments: { reviewer: "custom-reviewer" }, + }, + }); + + expect(systemPrompt).toBe("user override reviewer prompt"); + }); + + it("falls back to a non-empty default reviewer prompt when no store is provided", async () => { + const systemPrompt = await captureReviewerSystemPrompt(); + + expect(systemPrompt).toBe(resolveAgentPrompt("reviewer")); + expect(systemPrompt.trim().length).toBeGreaterThan(0); + }); + + it.each(["plan", "code", "spec"] as const)("uses the same resolved base prompt for %s reviews", async (reviewType) => { + mockedCreateFnAgent.mockResolvedValue(createMockSession()); + await reviewStep( + "/tmp/worktree", + "FN-6235", + 1, + "Review prompt source", + reviewType, + "# Prompt", + undefined, + { store: createStore() }, + ); + + expect(mockedCreateFnAgent.mock.calls[0][0].systemPrompt).toBe(resolveAgentPrompt("reviewer")); + }); +}); diff --git a/packages/engine/src/__tests__/reviewer.test.ts b/packages/engine/src/__tests__/reviewer.test.ts index 13f5f1e15f..f1c9d09124 100644 --- a/packages/engine/src/__tests__/reviewer.test.ts +++ b/packages/engine/src/__tests__/reviewer.test.ts @@ -12,9 +12,12 @@ vi.mock("../pi.js", () => ({ }), })); -import { reviewStep, REVIEWER_SYSTEM_PROMPT } from "../reviewer.js"; +import { resolveAgentPrompt } from "@fusion/core"; +import { reviewStep } from "../reviewer.js"; import { createFnAgent, promptWithFallback } from "../pi.js"; +const DEFAULT_REVIEWER_PROMPT = resolveAgentPrompt("reviewer"); + const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedPromptWithFallback = vi.mocked(promptWithFallback); const CONTEXT_LIMIT_ERROR = "exceeded model token limit: 262144 (requested: 262879)"; @@ -293,28 +296,55 @@ describe("reviewStep — spec review type", () => { describe("FN-5928 surface-enumeration review-gate wording", () => { it("requires spec reviews to block missing or incomplete surface enumeration for bug-fix specs", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("**Surface enumeration:**"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("Missing or incomplete coverage is a blocking REVISE"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("desktop + mobile breakpoints/platforms"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("shared hooks/components/modules/helpers"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("bug-fix specs and UI-affordance add/remove specs"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("**Surface enumeration:**"); + expect(DEFAULT_REVIEWER_PROMPT).toMatch( + /For bug-fix specs and UI-affordance add\/remove specs, is `## Surface Enumeration` present[\s\S]*Missing or incomplete coverage is a blocking REVISE\./, + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain("desktop + mobile breakpoints/platforms"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("shared hooks/components/modules/helpers"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("bug-fix specs and UI-affordance add/remove specs"); }); it("requires code reviews to reject repro-only regression tests for bug fixes", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("single-surface-only test"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("doesn't verify the invariant across the spec's enumerated surfaces"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("Keep enforcing FN-5893 for bug fixes"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("FN-5787/FN-5789/FN-5803"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("FN-5797/FN-5875/FN-5919"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("FN-5751"); + expect(DEFAULT_REVIEWER_PROMPT).toMatch( + /For bug fixes, apply FN-5893 strictly: if the regression test only reproduces the reported case instead of asserting the invariant across the spec's `## Surface Enumeration` surfaces, issue REVISE\./, + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain("single-surface-only test"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("doesn't verify the invariant across the spec's enumerated surfaces"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("Keep enforcing FN-5893 for bug fixes"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("FN-5787/FN-5789/FN-5803"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("FN-5797/FN-5875/FN-5919"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("FN-5751"); + }); + + it("requires spec reviews to block bug-class specs missing symptom verification", () => { + expect(DEFAULT_REVIEWER_PROMPT).toContain("**Symptom verification:**"); + expect(DEFAULT_REVIEWER_PROMPT).toMatch( + /For bug-class\/bug-fix specs only, is `## Symptom Verification` present and complete with \*\*Original symptom\*\*, \*\*Exact reproduction\*\*, and \*\*Assertion it is gone\*\*\?/, + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain( + "A bug-class spec whose final verification only checks green build/tests without reproducing the original failure and asserting it no longer occurs is a blocking REVISE under FN-5893", + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain( + "Missing, empty, or incomplete `## Symptom Verification` is a blocking REVISE for bug-class specs", + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain("feature/docs/non-bug specs are not required to carry it"); + }); + + it("requires code reviews to reject green-build-only symptom acceptance for bug fixes", () => { + expect(DEFAULT_REVIEWER_PROMPT).toMatch( + /For bug-class\/bug-fix specs, also enforce symptom-based acceptance:[\s\S]*final verification only checks green build\/tests without reproducing the original failure condition and asserting it no longer occurs, issue REVISE\./, + ); + expect(DEFAULT_REVIEWER_PROMPT).toContain("lacks **Original symptom**, **Exact reproduction**, or **Assertion it is gone**"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("Do not require `## Symptom Verification` for feature/docs/non-bug specs"); }); it("requires spec/code reviews to enforce surface enumeration for UI-affordance add/remove tasks", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("leftover shells after removal"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("For bug fixes and UI-affordance add/remove changes"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("UI-affordance removals"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("For UI-affordance add/remove changes, apply the same surface-enumeration strictness"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("FN-6115/FN-6118/FN-6123"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("leftover shells after removal"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("For bug fixes and UI-affordance add/remove changes"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("UI-affordance removals"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("For UI-affordance add/remove changes, apply the same surface-enumeration strictness"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("FN-6115/FN-6118/FN-6123"); }); it("demonstrates the gate firing on a single-component UI-removal spec", () => { @@ -322,11 +352,11 @@ describe("FN-5928 surface-enumeration review-gate wording", () => { "## Mission\nRemove the workflow-row chevron from WorkflowRow.tsx only."; expect(singleComponentRemovalSpec).toContain("WorkflowRow.tsx only"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("searches for ALL components rendering the affordance"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("not just the one the user pointed at"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("leftover shells after removal"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("empty button shells"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("Issue REVISE when coverage stops at the single reported surface"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("searches for ALL components rendering the affordance"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("not just the one the user pointed at"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("leftover shells after removal"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("empty button shells"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("Issue REVISE when coverage stops at the single reported surface"); }); }); @@ -849,24 +879,24 @@ describe("reviewStep — validator model overrides", () => { }); }); -describe("REVIEWER_SYSTEM_PROMPT", () => { +describe("default reviewer prompt", () => { it("includes subtask breakdown criterion in spec review", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("Subtask breakdown"); - expect(REVIEWER_SYSTEM_PROMPT).toContain( + expect(DEFAULT_REVIEWER_PROMPT).toContain("Subtask breakdown"); + expect(DEFAULT_REVIEWER_PROMPT).toContain( "12+ implementation steps", ); }); it("biases the reviewer toward keeping tasks whole", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("The bar for splitting is high"); - expect(REVIEWER_SYSTEM_PROMPT).toContain( + expect(DEFAULT_REVIEWER_PROMPT).toContain("The bar for splitting is high"); + expect(DEFAULT_REVIEWER_PROMPT).toContain( "Default position:** do NOT flag undersplit", ); - expect(REVIEWER_SYSTEM_PROMPT).toContain("12+ implementation steps"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("12+ implementation steps"); }); it("downgrades borderline undersplit findings to non-blocking suggestions", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain( + expect(DEFAULT_REVIEWER_PROMPT).toContain( "Suggestions** section instead of REVISE", ); }); @@ -874,25 +904,25 @@ describe("REVIEWER_SYSTEM_PROMPT", () => { it("instructs planner to use fn_task_create for genuinely oversized tasks", () => { // The reviewer's REVISE feedback must explicitly direct the planner to // create child tasks via fn_task_create rather than just flagging the issue. - expect(REVIEWER_SYSTEM_PROMPT).toContain("fn_task_create"); - expect(REVIEWER_SYSTEM_PROMPT).toContain( + expect(DEFAULT_REVIEWER_PROMPT).toContain("fn_task_create"); + expect(DEFAULT_REVIEWER_PROMPT).toContain( "create 2–5 child tasks", ); - expect(REVIEWER_SYSTEM_PROMPT).toContain( + expect(DEFAULT_REVIEWER_PROMPT).toContain( "Not write a parent PROMPT.md", ); }); it("includes user comment coverage criterion in spec review format", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("User comment coverage"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("missing coverage is a blocking REVISE"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("User comment coverage"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("missing coverage is a blocking REVISE"); }); it("includes worktree boundary guidance for code reviews", () => { - expect(REVIEWER_SYSTEM_PROMPT).toContain("Worktree Boundary Review"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("assigned task worktree"); - expect(REVIEWER_SYSTEM_PROMPT).toContain("blocking REVISE"); - expect(REVIEWER_SYSTEM_PROMPT).toContain(".fusion/memory/"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("Worktree Boundary Review"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("assigned task worktree"); + expect(DEFAULT_REVIEWER_PROMPT).toContain("blocking REVISE"); + expect(DEFAULT_REVIEWER_PROMPT).toContain(".fusion/memory/"); }); }); diff --git a/packages/engine/src/__tests__/sandbox/bubblewrap-backend.test.ts b/packages/engine/src/__tests__/sandbox/bubblewrap-backend.test.ts index 3471b2826f..829c12cad9 100644 --- a/packages/engine/src/__tests__/sandbox/bubblewrap-backend.test.ts +++ b/packages/engine/src/__tests__/sandbox/bubblewrap-backend.test.ts @@ -65,11 +65,17 @@ describe("BubblewrapBackend", () => { expect(nativeStub.run).toHaveBeenCalled(); }); - it( - "attempts bwrap execution when available", - async () => { - detectMock.mockResolvedValue({ available: true, path: "bwrap" }); - const backend = new BubblewrapBackend(); + it("attempts bwrap execution when available", async () => { + detectMock.mockResolvedValue({ available: true, path: "/usr/bin/test-bwrap" }); + const runBwrap = vi.fn(async (): Promise<SandboxRunResult> => ({ + stdout: "hello\n", + stderr: "", + exitCode: 0, + signal: null, + timedOut: false, + bufferExceeded: false, + })); + const backend = new BubblewrapBackend(undefined, runBwrap); await backend.prepare({ allowNetwork: true }); const result = await backend.run("echo hello", { @@ -79,11 +85,14 @@ describe("BubblewrapBackend", () => { encoding: "utf-8", }); - expect(result).toHaveProperty("stdout"); - expect(result).toHaveProperty("stderr"); - }, - 10_000, - ); + expect(result.stdout).toBe("hello\n"); + expect(runBwrap).toHaveBeenCalledOnce(); + const [command, args] = runBwrap.mock.calls[0]; + expect(command).toBe("/usr/bin/test-bwrap"); + expect(args.at(-3)).toBe("/bin/sh"); + expect(args.at(-2)).toBe("-lc"); + expect(args.at(-1)).toBe("echo hello"); + }); it.skipIf(process.platform !== "linux" || !hasBwrap)("runs real bubblewrap hello integration", async () => { vi.doUnmock("../../sandbox/bubblewrap-detect.js"); diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index 64dafc8180..80396cfb06 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -679,6 +679,114 @@ describe("Scheduler", () => { expect(schedulerLog.log).toHaveBeenCalledWith(expect.stringContaining("no reservable slot")); }); + it("FN-6292: does not let an in-progress task with unmet deps block its own dependency by file-scope lease", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = new Map<string, Task>([ + ["FN-H", createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] })], + ["FN-D", createMockTask({ id: "FN-D", column: "todo", dependencies: [] })], + ]); + const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing task ${taskId}`); + const updated = { ...current, column } as Task; + tasks.set(taskId, updated); + return updated; + }); + const updateTask = vi.fn(async (taskId: string, updates: Partial<Task>) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing task ${taskId}`); + const updated = { ...current, ...updates } as Task; + tasks.set(taskId, updated); + return updated; + }); + const store = createMockStore({ + listTasks: vi.fn(async () => [...tasks.values()]), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true }), + parseFileScopeFromPrompt: vi.fn(async () => ["packages/engine/src/scheduler.ts"]), + updateTask, + moveTask, + }); + + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + await scheduler.schedule(); + + expect(updateTask).not.toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); + expect(moveTask).toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); + }); + + it("FN-6292: workflow-column hold sweep does not lease unmet-dependency in-progress holders", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = new Map<string, Task>([ + ["FN-H", createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] })], + ["FN-D", createMockTask({ id: "FN-D", column: "todo", dependencies: [] })], + ]); + const updateTask = vi.fn(async (taskId: string, updates: Partial<Task>) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing task ${taskId}`); + const updated = { ...current, ...updates } as Task; + tasks.set(taskId, updated); + return updated; + }); + const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing task ${taskId}`); + const updated = { ...current, column } as Task; + tasks.set(taskId, updated); + return updated; + }); + const store = createMockStore({ + listTasks: vi.fn(async () => [...tasks.values()]), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 10, + maxWorktrees: 10, + groupOverlappingFiles: true, + experimentalFeatures: { workflowColumns: true }, + }), + parseFileScopeFromPrompt: vi.fn(async () => ["packages/engine/src/scheduler.ts"]), + updateTask, + moveTask, + }); + + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + await scheduler.schedule(); + + expect(updateTask).not.toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); + expect(moveTask).toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); + }); + + it("FN-6292: keeps file-scope leases for in-progress tasks whose dependencies are met", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = [ + createMockTask({ id: "FN-DEP", column: "done" }), + createMockTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-DEP"] }), + createMockTask({ id: "FN-D", column: "todo", dependencies: [] }), + ]; + const updateTask = vi.fn().mockResolvedValue(undefined); + const moveTask = vi.fn().mockResolvedValue(undefined); + const store = createMockStore({ + listTasks: vi.fn().mockResolvedValue(tasks), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: true }), + parseFileScopeFromPrompt: vi.fn(async (taskId: string) => taskId === "FN-DEP" ? [] : ["packages/engine/src/scheduler.ts"]), + updateTask, + moveTask, + }); + + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + await scheduler.schedule(); + + expect(updateTask).toHaveBeenCalledWith("FN-D", { status: "queued", blockedBy: null, overlapBlockedBy: "FN-H" }); + expect(moveTask).not.toHaveBeenCalledWith("FN-D", "in-progress", expect.anything()); + }); + it("holds workflow-column releases when file scopes overlap active work", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); diff --git a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts index 2fea48a4be..274ab45740 100644 --- a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts +++ b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts @@ -119,7 +119,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( const store = createStore(tasks); const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); - await (manager as any).runMaintenance(); + await (manager as any).recoverAlreadyMergedReviewTasks(); const task = tasks.get("FN-TEST-1")!; expect(task.column).toBe("done"); @@ -129,10 +129,9 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( expect(task.mergeDetails?.mergeConfirmed).toBe(true); expect(existsSync(worktreePath)).toBe(false); expect(git(repo, "git worktree list")).not.toContain(worktreePath); - // FN-5256: reconcileTaskWorktreeMetadata now normalizes via realpath, so the - // (formerly false-stale) macOS realpath mismatch no longer triggers an extra - // worktree-metadata-cleared audit event for this in-review task. - expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(2); + // Exercise only the already-merged recovery path here. Assert the recovery + // audit events by type rather than exact total count so unrelated + // environment-specific audit noise cannot re-flake this real-git test. expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith( expect.objectContaining({ domain: "database", @@ -140,6 +139,13 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( target: "FN-TEST-1", }), ); + expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + domain: "database", + mutationType: "task:auto-recover-completion-fanout", + target: "FN-TEST-1", + }), + ); }); it( @@ -290,9 +296,9 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( const store = createStore(tasks); const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); - await (manager as any).runMaintenance(); + await (manager as any).recoverAlreadyMergedReviewTasks(); const firstRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("Auto-finalized from in-review/paused")).length; - await (manager as any).runMaintenance(); + await (manager as any).recoverAlreadyMergedReviewTasks(); const secondRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("Auto-finalized from in-review/paused")).length; expect(firstRecoveryLogs).toBe(1); diff --git a/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts b/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts index 2dfe8144b3..4e7ae3e79d 100644 --- a/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts +++ b/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts @@ -20,6 +20,7 @@ import type { TaskStore } from "@fusion/core"; import { SelfHealingManager } from "../self-healing.js"; import * as worktreePool from "../worktree-pool.js"; import { StuckTaskDetector, type DisposableSession } from "../stuck-task-detector.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; function createStore(settings: Record<string, unknown>): TaskStore & EventEmitter { const emitter = new EventEmitter() as TaskStore & EventEmitter; @@ -46,6 +47,7 @@ describe("self-healing idle-worktree sweeps skip resume-eligible CLI session wor afterEach(() => { rmSync(rootDir, { recursive: true, force: true }); + activeSessionRegistry.clear(); vi.restoreAllMocks(); }); @@ -85,6 +87,42 @@ describe("self-healing idle-worktree sweeps skip resume-eligible CLI session wor expect(cleaned).toBe(1); }); + it("cleanupOrphans skips a worktree backing a live (active-session) executor session", async () => { + // FN-4811/FN-5065 regression: a registered idle worktree whose task transiently + // sits in "done" (so scanIdleWorktrees lists it) must NOT be reaped while a live + // executor/merger/step session is still bound to it — that yanks the checkout out + // from under in-flight work ("removed before the work is done"). + const store = createStore({ recycleWorktrees: false }); + vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath]); + const removeSpy = vi.spyOn(worktreePool, "removeWorktree").mockResolvedValue(undefined as never); + + activeSessionRegistry.registerPath(reservedPath, { taskId: "FN-1", kind: "executor", ownerKey: "owner-1" }); + + // No isWorktreeResumeReserved seam — protection comes solely from the active session. + const manager = new SelfHealingManager(store, { rootDir }); + const cleaned = await (manager as any).cleanupOrphans(); + + const removed = removeSpy.mock.calls.map((c) => (c[0] as { worktreePath: string }).worktreePath); + expect(removed).toEqual([freePath]); + expect(cleaned).toBe(1); + }); + + it("enforceWorktreeCap skips a worktree backing a live (active-session) executor session", async () => { + mkdirSync(join(worktreesDir, "wt-extra")); + const store = createStore({ maxWorktrees: 1, recycleWorktrees: false }); + vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath, join(worktreesDir, "wt-extra")]); + const removeSpy = vi.spyOn(worktreePool, "removeWorktree").mockResolvedValue(undefined as never); + + activeSessionRegistry.registerPath(reservedPath, { taskId: "FN-1", kind: "executor", ownerKey: "owner-1" }); + + const manager = new SelfHealingManager(store, { rootDir }); + await (manager as any).enforceWorktreeCap(); + + const removed = removeSpy.mock.calls.map((c) => (c[0] as { worktreePath: string }).worktreePath); + expect(removed).not.toContain(reservedPath); + expect(removed).toContain(freePath); + }); + it("without the seam predicate, both worktrees are reaped (no behavior change)", async () => { const store = createStore({ recycleWorktrees: false }); vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath]); diff --git a/packages/engine/src/__tests__/self-healing-reattach-orphaned-executions.test.ts b/packages/engine/src/__tests__/self-healing-reattach-orphaned-executions.test.ts new file mode 100644 index 0000000000..c725dabab4 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-reattach-orphaned-executions.test.ts @@ -0,0 +1,217 @@ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Agent, AgentHeartbeatRun, AgentStore, Task } from "@fusion/core"; + +import { SelfHealingManager } from "../self-healing.js"; + +const ORPHANED_EXECUTION_RECOVERY_GRACE_MS = 60_000; +const ORPHANED_WITH_WORKTREE_GRACE_MS = 300_000; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeWorktree(): string { + const dir = mkdtempSync(join(tmpdir(), "fn-6336-reattach-")); + tempDirs.push(dir); + return dir; +} + +function isoAge(ms: number): string { + return new Date(Date.now() - ms).toISOString(); +} + +function makeTask(overrides: Partial<Task> = {}): Task { + return { + id: "FN-1", + title: "assigned execution", + description: "assigned execution", + column: "in-progress", + status: "in-progress", + lineageId: "lineage-1", + branch: "fusion/fn-1", + worktree: makeWorktree(), + assignedAgentId: "agent-1", + paused: false, + steps: [{ title: "execute", status: "in-progress" }], + createdAt: isoAge(ORPHANED_WITH_WORKTREE_GRACE_MS + 10_000), + updatedAt: isoAge(ORPHANED_WITH_WORKTREE_GRACE_MS + 10_000), + ...overrides, + } as Task; +} + +function makeAgent(id = "agent-1"): Agent { + return { + id, + name: id, + role: "executor", + state: "active", + createdAt: isoAge(120_000), + updatedAt: isoAge(120_000), + metadata: {}, + } as Agent; +} + +function makeActiveRun(agentId = "agent-1"): AgentHeartbeatRun { + return { + id: "run-1", + agentId, + status: "active", + startedAt: new Date().toISOString(), + } as AgentHeartbeatRun; +} + +function buildManager({ + tasks, + agents = [makeAgent()], + activeRuns = new Map<string, AgentHeartbeatRun | null>(), + hasActiveAgentExecution = () => false, + globalPause = false, + enginePaused = false, + executingTaskIds = new Set<string>(), +}: { + tasks: Task[]; + agents?: Agent[]; + activeRuns?: Map<string, AgentHeartbeatRun | null>; + hasActiveAgentExecution?: (agentId: string) => boolean; + globalPause?: boolean; + enginePaused?: boolean; + executingTaskIds?: Set<string>; +}) { + const resumeAssignedTaskForAgent = vi.fn(async () => undefined); + const recordRunAuditEvent = vi.fn(async () => undefined); + const store = { + getSettings: vi.fn(async () => ({ globalPause, enginePaused })), + listTasks: vi.fn(async () => tasks), + recordRunAuditEvent, + } as any; + const agentStore = { + getAgent: vi.fn(async (agentId: string) => agents.find((agent) => agent.id === agentId) ?? null), + getActiveHeartbeatRun: vi.fn(async (agentId: string) => activeRuns.get(agentId) ?? null), + } as unknown as AgentStore; + + const manager = new SelfHealingManager(store, { + rootDir: "/tmp/fn-6336-project", + agentStore, + getExecutingTaskIds: () => executingTaskIds, + hasActiveAgentExecution, + resumeAssignedTaskForAgent, + }); + + return { manager, resumeAssignedTaskForAgent, agentStore, store, recordRunAuditEvent }; +} + +describe("FN-6336: reattach orphaned assigned in-progress executions", () => { + it("re-dispatches an orphaned assigned task past worktree grace via the assigned-agent seam", async () => { + const task = makeTask(); + const { manager, resumeAssignedTaskForAgent, recordRunAuditEvent } = buildManager({ tasks: [task] }); + + const recovered = await manager.reattachOrphanedAssignedExecutions(); + + expect(recovered).toBe(1); + expect(resumeAssignedTaskForAgent).toHaveBeenCalledTimes(1); + expect(resumeAssignedTaskForAgent).toHaveBeenCalledWith("agent-1"); + expect(recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + domain: "database", + mutationType: "task:reattach-orphaned-execution", + target: "FN-1", + })); + manager.stop(); + }); + + it("uses the shorter grace when no task worktree exists", async () => { + const task = makeTask({ worktree: undefined, updatedAt: isoAge(ORPHANED_EXECUTION_RECOVERY_GRACE_MS + 1_000) }); + const { manager, resumeAssignedTaskForAgent } = buildManager({ tasks: [task] }); + + await expect(manager.reattachOrphanedAssignedExecutions()).resolves.toBe(1); + + expect(resumeAssignedTaskForAgent).toHaveBeenCalledOnce(); + manager.stop(); + }); + + it("does not reattach a task that is still within the longer worktree grace window", async () => { + const task = makeTask({ updatedAt: isoAge(ORPHANED_WITH_WORKTREE_GRACE_MS - 1_000) }); + const { manager, resumeAssignedTaskForAgent } = buildManager({ tasks: [task] }); + + await expect(manager.reattachOrphanedAssignedExecutions()).resolves.toBe(0); + + expect(resumeAssignedTaskForAgent).not.toHaveBeenCalled(); + manager.stop(); + }); + + it.each([ + ["an active heartbeat run exists", { activeRuns: new Map([["agent-1", makeActiveRun()]]) }, {}], + ["an active agent execution exists", { hasActiveAgentExecution: (agentId: string) => agentId === "agent-1" }, {}], + ["the task is within the no-worktree grace window", {}, { worktree: undefined, updatedAt: isoAge(ORPHANED_EXECUTION_RECOVERY_GRACE_MS - 1_000) }], + ["the task is paused", {}, { paused: true }], + ["the project is globally paused", { globalPause: true }, {}], + ["the engine is paused", { enginePaused: true }, {}], + ["the task is soft-deleted", {}, { deletedAt: new Date().toISOString() }], + ["task work is already complete", {}, { steps: [{ title: "execute", status: "done" }] }], + ["the executor is already executing the task", { executingTaskIds: new Set(["FN-1"]) }, {}], + ["the task has no assigned agent", {}, { assignedAgentId: undefined }], + ["the assigned agent is missing", { agents: [] }, {}], + ] as const)("does not reattach when %s", async (_name, managerOverrides, taskOverrides) => { + const { manager, resumeAssignedTaskForAgent } = buildManager({ tasks: [makeTask(taskOverrides as Partial<Task>)], ...managerOverrides }); + + await expect(manager.reattachOrphanedAssignedExecutions()).resolves.toBe(0); + + expect(resumeAssignedTaskForAgent).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("deduplicates multiple orphaned tasks sharing the same assigned agent", async () => { + const first = makeTask({ id: "FN-1", lineageId: "lineage-1" }); + const second = makeTask({ id: "FN-2", lineageId: "lineage-2", branch: "fusion/fn-2" }); + const { manager, resumeAssignedTaskForAgent, recordRunAuditEvent } = buildManager({ tasks: [first, second] }); + + await expect(manager.reattachOrphanedAssignedExecutions()).resolves.toBe(1); + + expect(resumeAssignedTaskForAgent).toHaveBeenCalledTimes(1); + expect(resumeAssignedTaskForAgent).toHaveBeenCalledWith("agent-1"); + expect(recordRunAuditEvent).toHaveBeenCalledTimes(2); + manager.stop(); + }); + + it("only considers in-progress tasks and ignores review, done, todo, triage, and archived tasks", async () => { + const tasks = [ + makeTask({ id: "FN-review", column: "in-review" }), + makeTask({ id: "FN-done", column: "done" }), + makeTask({ id: "FN-todo", column: "todo" }), + makeTask({ id: "FN-triage", column: "triage" }), + makeTask({ id: "FN-archived", column: "archived" }), + ] as Task[]; + const { manager, resumeAssignedTaskForAgent } = buildManager({ tasks }); + + await expect(manager.reattachOrphanedAssignedExecutions()).resolves.toBe(0); + + expect(resumeAssignedTaskForAgent).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("is registered after agent and stale-run recovery in startup and periodic self-healing loops", () => { + const source = readFileSync("src/self-healing.ts", "utf8"); + const startup = source.slice(source.indexOf("async runStartupRecovery"), source.indexOf(" stop(): void")); + const periodicStart = source.lastIndexOf("recover-ghost-review"); + const periodicEnd = source.indexOf("reconcile-task-worktree-metadata", periodicStart); + const periodic = source.slice(periodicStart, periodicEnd); + + for (const block of [startup, periodic]) { + const orphanedAgents = block.indexOf("recover-orphaned-agents"); + const staleRuns = block.indexOf("recover-stale-heartbeat-runs"); + const reattach = block.indexOf("reattach-orphaned-assigned-executions"); + expect(orphanedAgents).toBeGreaterThanOrEqual(0); + expect(staleRuns).toBeGreaterThan(orphanedAgents); + expect(reattach).toBeGreaterThan(staleRuns); + } + }); +}); diff --git a/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts b/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts index 94ae335786..b705784d53 100644 --- a/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts +++ b/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; const osState = vi.hoisted(() => ({ tempRoot: "" })); const fsState = vi.hoisted(() => ({ failRmPath: "", rmCalls: [] as string[] })); -const childState = vi.hoisted(() => ({ execCalls: [] as string[] })); +const childState = vi.hoisted(() => ({ execCalls: [] as string[], execStdout: "" })); vi.mock("node:os", async () => { const actual = await vi.importActual<typeof import("node:os")>("node:os"); @@ -37,7 +37,7 @@ vi.mock("node:child_process", async () => { childState.execCalls.push(command); const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; queueMicrotask(() => { - if (typeof callback === "function") callback(null, "", ""); + if (typeof callback === "function") callback(null, childState.execStdout, ""); }); return {} as ReturnType<typeof actual.exec>; }), @@ -45,19 +45,21 @@ vi.mock("node:child_process", async () => { }); import { activeSessionRegistry } from "../active-session-registry.js"; -import { SelfHealingManager } from "../self-healing.js"; +import { DONE_TASK_TEMP_WORKTREE_GRACE_MS, MIN_TEMP_WORKTREE_REAP_AGE_MS, SelfHealingManager, STALE_TEMP_MERGE_WORKTREE_MS } from "../self-healing.js"; +import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "../worktree-paths.js"; const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; let sandboxRoot = ""; let projectRoot = ""; beforeEach(() => { - sandboxRoot = mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-sandbox-")); - projectRoot = mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-project-")); + sandboxRoot = realpathSync(mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-sandbox-"))); + projectRoot = realpathSync(mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-project-"))); osState.tempRoot = sandboxRoot; fsState.failRmPath = ""; fsState.rmCalls = []; childState.execCalls = []; + childState.execStdout = ""; activeSessionRegistry.clear(); }); @@ -67,6 +69,7 @@ afterEach(() => { fsState.failRmPath = ""; fsState.rmCalls = []; childState.execCalls = []; + childState.execStdout = ""; for (const dir of [sandboxRoot, projectRoot]) { try { rmSync(dir, RM); } catch { /* best effort */ } } @@ -77,6 +80,7 @@ function makeStore(settings: Record<string, unknown> = {}, getTask: () => Promis const store: any = { getSettings: vi.fn(async () => ({ ...settings })), getTask: vi.fn(getTask), + listTasks: vi.fn(async () => []), recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }), }; return { store, audits }; @@ -94,6 +98,18 @@ function tempMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36). return dir; } +function localMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).slice(2)}`): string { + const dir = join(resolveAiMergeRootPath(projectRoot, undefined), name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function legacyRepoMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).slice(2)}`): string { + const dir = join(resolveLegacyAiMergeRootPath(projectRoot), name); + mkdirSync(dir, { recursive: true }); + return dir; +} + function makeAge(path: string, ageMs: number): void { const old = new Date(Date.now() - ageMs); utimesSync(path, old, old); @@ -115,6 +131,25 @@ function missingTask(): () => Promise<any> { return async () => { throw new Error("Task FN-999 not found"); }; } +function transientErrorTask(): () => Promise<any> { + return async () => { throw new Error("SQLITE_BUSY: database is locked"); }; +} + +function gitWorktreeList(names: string[]): string { + return [ + `worktree ${projectRoot}`, + "HEAD abc123", + "branch refs/heads/main", + "", + ...names.flatMap((name) => [ + `worktree ${join(projectRoot, ".worktrees", name)}`, + "HEAD def456", + `branch refs/heads/fusion/${name}`, + "", + ]), + ].join("\n"); +} + async function sweep(manager: SelfHealingManager): Promise<number> { return await (manager as any).cleanupStaleTempMergeWorktrees(); } @@ -123,6 +158,40 @@ function sweepAudits(audits: any[]) { return audits.filter((event) => event.mutationType === "worktree:tempdir-sweep"); } +describe("SelfHealingManager worktrees-dir sweeps", () => { + it("excludes the .ai-merge container from unregistered-orphan reap while removing genuine orphans", async () => { + const worktreesDir = join(projectRoot, ".worktrees"); + const aiMergeContainer = join(worktreesDir, ".ai-merge"); + const orphan = join(worktreesDir, "half-built"); + mkdirSync(aiMergeContainer, { recursive: true }); + mkdirSync(orphan, { recursive: true }); + const { manager } = makeManager({ recycleWorktrees: true }); + + await expect((manager as any).reapUnregisteredOrphans()).resolves.toBe(1); + + expect(existsSync(aiMergeContainer)).toBe(true); + expect(existsSync(orphan)).toBe(false); + expect(fsState.rmCalls).toContain(orphan); + expect(fsState.rmCalls).not.toContain(aiMergeContainer); + }); + + it("excludes the .ai-merge container from cap enforcement while removing genuine idle worktrees", async () => { + const worktreesDir = join(projectRoot, ".worktrees"); + const aiMergeContainer = join(worktreesDir, ".ai-merge"); + const idle = join(worktreesDir, "idle-wt"); + mkdirSync(aiMergeContainer, { recursive: true }); + mkdirSync(idle, { recursive: true }); + childState.execStdout = gitWorktreeList(["idle-wt"]); + const { manager } = makeManager({ maxWorktrees: 0 }); + + await expect((manager as any).enforceWorktreeCap()).resolves.toBeUndefined(); + + expect(existsSync(aiMergeContainer)).toBe(true); + expect(childState.execCalls.some((command) => command.includes(".ai-merge"))).toBe(false); + expect(childState.execCalls.some((command) => command.includes("idle-wt"))).toBe(true); + }); +}); + describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { it("removes stale fusion-ai-merge directories and emits success audits", async () => { const stale = tempMergeDir(); @@ -137,6 +206,38 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { ])); }); + it("removes stale AI merge directories from new and legacy repo-local roots", async () => { + const staleNew = localMergeDir("fusion-ai-merge-fn-1-localstale"); + const staleLegacy = legacyRepoMergeDir("fusion-ai-merge-fn-1-legacystale"); + makeStale(staleNew); + makeStale(staleLegacy); + const { manager, audits } = makeManager(); + + await expect(sweep(manager)).resolves.toBe(2); + + expect(existsSync(staleNew)).toBe(false); + expect(existsSync(staleLegacy)).toBe(false); + expect(sweepAudits(audits)).toEqual(expect.arrayContaining([ + expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(resolveAiMergeRootPath(projectRoot, undefined)) + "/fusion-ai-merge-fn-1-localstale", success: true, reason: "stale" }) }), + expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(resolveLegacyAiMergeRootPath(projectRoot)) + "/fusion-ai-merge-fn-1-legacystale", success: true, reason: "stale" }) }), + ])); + }); + + it("defers active worktrees-dir AI merge directories", async () => { + const stale = localMergeDir("fusion-ai-merge-fn-1-localactive"); + makeStale(stale); + const canonical = realpathSync(stale); + activeSessionRegistry.registerPath(canonical, { taskId: "FN-1", kind: "ai-merge", ownerKey: "ai-merge:FN-1" }); + const { manager, audits } = makeManager(); + + await expect(sweep(manager)).resolves.toBe(0); + + expect(existsSync(stale)).toBe(true); + expect(sweepAudits(audits)).toEqual(expect.arrayContaining([ + expect.objectContaining({ metadata: expect.objectContaining({ path: canonical, success: false, reason: "active-session" }) }), + ])); + }); + it("skips directories younger than the staleness threshold", async () => { const fresh = tempMergeDir(); const { manager } = makeManager(); @@ -150,7 +251,7 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { const stale = tempMergeDir(); makeStale(stale); const canonical = realpathSync(stale); - activeSessionRegistry.registerPath(canonical, { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" }); + activeSessionRegistry.registerPath(canonical, { taskId: "FN-1", kind: "ai-merge", ownerKey: "ai-merge:FN-1" }); const { manager, audits } = makeManager(); await expect(sweep(manager)).resolves.toBe(0); @@ -231,18 +332,54 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { ])); }); - it("removes worktree for deleted task immediately", async () => { - const fresh = tempMergeDir("fusion-ai-merge-fn-999-deletedtask"); + it("keeps fresh worktree for deleted task until minimum age floor", async () => { + const fresh = tempMergeDir("fusion-ai-merge-fn-999-deletedtaskfresh"); + makeAge(fresh, MIN_TEMP_WORKTREE_REAP_AGE_MS - 1_000); + const { manager, audits } = makeManager({}, missingTask()); + + await expect(sweep(manager)).resolves.toBe(0); + + expect(existsSync(fresh)).toBe(true); + expect(sweepAudits(audits)).toEqual([]); + }); + + it("removes worktree for deleted task after minimum age floor", async () => { + const stale = tempMergeDir("fusion-ai-merge-fn-999-deletedtaskstale"); + makeAge(stale, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000); const { manager, audits } = makeManager({}, missingTask()); await expect(sweep(manager)).resolves.toBe(1); - expect(existsSync(fresh)).toBe(false); + expect(existsSync(stale)).toBe(false); expect(sweepAudits(audits)).toEqual(expect.arrayContaining([ expect.objectContaining({ metadata: expect.objectContaining({ success: true, reason: "deleted-task" }) }), ])); }); + it("keeps fresh worktree on transient task lookup error", async () => { + const fresh = tempMergeDir("fusion-ai-merge-fn-999-lookuperrorfresh"); + makeAge(fresh, MIN_TEMP_WORKTREE_REAP_AGE_MS - 1_000); + const { manager, audits } = makeManager({}, transientErrorTask()); + + await expect(sweep(manager)).resolves.toBe(0); + + expect(existsSync(fresh)).toBe(true); + expect(sweepAudits(audits)).toEqual([]); + }); + + it("removes worktree on transient task lookup error only after full stale gate", async () => { + const stale = tempMergeDir("fusion-ai-merge-fn-999-lookuperrorstale"); + makeAge(stale, STALE_TEMP_MERGE_WORKTREE_MS + 1_000); + const { manager, audits } = makeManager({}, transientErrorTask()); + + await expect(sweep(manager)).resolves.toBe(1); + + expect(existsSync(stale)).toBe(false); + expect(sweepAudits(audits)).toEqual(expect.arrayContaining([ + expect.objectContaining({ metadata: expect.objectContaining({ success: true, reason: "lookup-error" }) }), + ])); + }); + it("keeps worktree for in-progress task within 2h gate", async () => { const fresh = tempMergeDir("fusion-ai-merge-fn-999-inprogressfresh"); const { manager } = makeManager({}, taskWithColumn("in-progress")); @@ -267,7 +404,7 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { it("keeps fresh worktree for done task within grace period", async () => { const fresh = tempMergeDir("fusion-ai-merge-fn-999-donefresh"); - makeAge(fresh, 5 * 60 * 1000); + makeAge(fresh, DONE_TASK_TEMP_WORKTREE_GRACE_MS - 1_000); const { manager } = makeManager({}, taskWithColumn("done")); await expect(sweep(manager)).resolves.toBe(0); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 3aa958cb62..a0e82fd0a1 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -115,8 +115,8 @@ import { SelfHealingManager, isBranchAheadOfBase, MAX_AUTO_MERGE_RETRIES } from import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider } from "@fusion/core"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js"; @@ -180,6 +180,8 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E archiveTaskAndCleanup: vi.fn().mockResolvedValue({} as Task), walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }), listTasks: vi.fn().mockResolvedValue([]), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), createTask: vi.fn().mockResolvedValue({ id: "FN-RESCUE", lineageId: "lin-rescue" }), recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), getRunAuditEvents: vi.fn().mockReturnValue([]), @@ -465,7 +467,6 @@ describe("SelfHealingManager", () => { expect(store.updateTask).toHaveBeenLastCalledWith("FN-001", expect.objectContaining({ stuckKillCount: 7, paused: false, - userPaused: false, pausedReason: null, status: "queued", })); @@ -476,6 +477,38 @@ describe("SelfHealingManager", () => { ); }); + it("leaves user-paused incomplete stuck-loop exhaustion paused and unrequeued", async () => { + (store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ + id: "FN-001", + column: "in-progress", + stuckKillCount: 6, + paused: true, + userPaused: true, + steps: [ + { name: "Preflight", status: "done" }, + { name: "Delivery", status: "in-progress" }, + ], + } as unknown as Task); + + manager.start(); + + const result = await manager.checkStuckBudget("FN-001", "loop"); + + expect(result).toBe(false); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.handoffToReview).not.toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-001", + "STUCK_KILL: skipped stuck-budget recovery for loop because the task is user-paused; leaving paused.", + ); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ + paused: false, + userPaused: false, + status: "queued", + })); + }); + it("falls back to executor requeue when todo parking fails", async () => { (store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", @@ -1943,6 +1976,107 @@ describe("SelfHealingManager", () => { mgr.stop(); }); + it("recovers spawn ENOTDIR process-spawn failures", async () => { + const transientStore = setupTransientRecoveryStore({ + tasks: [ + { + id: "FN-6210", + column: "in-review", + paused: false, + status: "failed", + mergeRetries: 3, + error: "spawn ENOTDIR", + mergeDetails: undefined, + }, + ], + }); + const requeueForAutoMerge = vi.fn(); + const mgr = new SelfHealingManager(transientStore, { + rootDir: "/tmp/test-project", + requeueForAutoMerge, + }); + + const recovered = await mgr.recoverTransientMergeFailures(); + + expect(recovered).toBe(1); + expect(requeueForAutoMerge).toHaveBeenCalledTimes(1); + expect(requeueForAutoMerge).toHaveBeenCalledWith("FN-6210"); + const updateCalls = (transientStore.updateTask as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, Partial<Task>]>; + const recoveryCall = updateCalls.find((call) => call[0] === "FN-6210" && call[1].status === null); + expect(recoveryCall).toBeDefined(); + expect(recoveryCall![1].mergeRetries).toBe(0); + expect(recoveryCall![1].error).toBeNull(); + expect((recoveryCall![1] as { mergeDetails?: { transientRecoveryCount?: number } }).mergeDetails?.transientRecoveryCount).toBe(1); + + mgr.stop(); + }); + + it("recovers spawn git ENOENT process-spawn failures", async () => { + const transientStore = setupTransientRecoveryStore({ + tasks: [ + { + id: "FN-6210-ENOENT", + column: "in-review", + paused: false, + status: "failed", + mergeRetries: 3, + error: "spawn git ENOENT", + mergeDetails: { transientRecoveryCount: 1 }, + }, + ], + }); + const requeueForAutoMerge = vi.fn(); + const mgr = new SelfHealingManager(transientStore, { + rootDir: "/tmp/test-project", + requeueForAutoMerge, + }); + + const recovered = await mgr.recoverTransientMergeFailures(); + + expect(recovered).toBe(1); + expect(requeueForAutoMerge).toHaveBeenCalledTimes(1); + expect(requeueForAutoMerge).toHaveBeenCalledWith("FN-6210-ENOENT"); + const updateCalls = (transientStore.updateTask as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, Partial<Task>]>; + const recoveryCall = updateCalls.find((call) => call[0] === "FN-6210-ENOENT" && call[1].status === null); + expect(recoveryCall).toBeDefined(); + expect(recoveryCall![1].mergeRetries).toBe(0); + expect(recoveryCall![1].error).toBeNull(); + expect((recoveryCall![1] as { mergeDetails?: { transientRecoveryCount?: number } }).mergeDetails?.transientRecoveryCount).toBe(2); + + mgr.stop(); + }); + + it("parks process-spawn failures once the transient recovery budget is exhausted", async () => { + const transientStore = setupTransientRecoveryStore({ + tasks: [ + { + id: "FN-spawn-exhausted", + column: "in-review", + paused: false, + status: "failed", + mergeRetries: 3, + error: "spawn ENOTDIR", + mergeDetails: { transientRecoveryCount: 2 }, + }, + ], + }); + const requeueForAutoMerge = vi.fn(); + const mgr = new SelfHealingManager(transientStore, { + rootDir: "/tmp/test-project", + requeueForAutoMerge, + }); + + const recovered = await mgr.recoverTransientMergeFailures(); + + expect(recovered).toBe(0); + expect(requeueForAutoMerge).not.toHaveBeenCalled(); + const updateCalls = (transientStore.updateTask as ReturnType<typeof vi.fn>).mock.calls as unknown as Array<[string, Partial<Task>]>; + const markerCall = updateCalls.find((call) => call[0] === "FN-spawn-exhausted" && typeof call[1].error === "string" && (call[1].error as string).includes("[transient-recovery-budget-exhausted]")); + expect(markerCall).toBeDefined(); + + mgr.stop(); + }); + it("recovers same-SHA spurious concurrent-advance failures (pre-FN-5627 legacy)", async () => { const transientStore = setupTransientRecoveryStore({ tasks: [ @@ -6840,6 +6974,129 @@ describe("FN-4538 overlapBlockedBy self-healing", () => { manager.stop(); }); + it("FN-6276: clearStaleBlockedBy logs unchanged active overlap blocker only once across passes", async () => { + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, overlapBlocker]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + + expect(store.updateTask).toHaveBeenCalledTimes(3); + expect(store.updateTask).toHaveBeenNthCalledWith(1, "FN-TARGET", { blockedBy: null, status: "queued" }); + expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(1); + manager.stop(); + }); + + it("FN-6276: clearStaleBlockedBy logs again when active overlap blocker changes", async () => { + const overlapBlockerA = makeTask("FN-ACTIVE-A", { column: "in-progress" }); + const overlapBlockerB = makeTask("FN-ACTIVE-B", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE-A", + dependencies: [], + }); + const store = makeStore([target, overlapBlockerA, overlapBlockerB]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + + await manager.clearStaleBlockedBy(); + target.overlapBlockedBy = "FN-ACTIVE-B"; + await manager.clearStaleBlockedBy(); + + expect(store.logEntry).toHaveBeenCalledWith( + "FN-TARGET", + "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE-A", + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-TARGET", + "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE-B", + ); + expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter((call) => call[0] === "FN-TARGET" && String(call[1]).includes("preserved queued status"))).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: clearStaleBlockedBy resets preserved queued memo after blocker resolves", async () => { + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, overlapBlocker]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + overlapBlocker.column = "done"; + await manager.clearStaleBlockedBy(); + target.status = null; + target.overlapBlockedBy = null; + await manager.clearStaleBlockedBy(); + target.status = "queued"; + target.overlapBlockedBy = "FN-ACTIVE"; + overlapBlocker.column = "in-progress"; + await manager.clearStaleBlockedBy(); + + expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: stop clears preserved queued memo so next pass logs again", async () => { + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: undefined, + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, overlapBlocker]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered: preserved queued status — still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + manager.stop(); + await manager.clearStaleBlockedBy(); + + expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(2); + manager.stop(); + }); + + it("FN-6276: FN-5488 stale blockedBy overlap-preservation log is idempotent", async () => { + const staleBlocker = makeTask("FN-DONE", { column: "done" }); + const overlapBlocker = makeTask("FN-ACTIVE", { column: "in-progress" }); + const target = makeTask("FN-TARGET", { + column: "todo", + status: "queued", + blockedBy: "FN-DONE", + overlapBlockedBy: "FN-ACTIVE", + dependencies: [], + }); + const store = makeStore([target, staleBlocker, overlapBlocker]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + const message = "Auto-recovered (FN-5488): preserved queued status — blocker=FN-DONE blockerStatus=none reason=blocker-done; still blocked by file scope overlap with FN-ACTIVE"; + + await manager.clearStaleBlockedBy(); + await manager.clearStaleBlockedBy(); + + expect(store.updateTask).toHaveBeenCalledTimes(2); + expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter((call) => call[0] === "FN-TARGET" && call[1] === message)).toHaveLength(1); + manager.stop(); + }); + it("FN-4538: clearStaleBlockedBy clears overlapBlockedBy when overlap blocker is done", async () => { const overlapBlocker = makeTask("FN-DONE", { column: "done" }); const target = makeTask("FN-TARGET", { @@ -7937,8 +8194,9 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => { let manager: SelfHealingManager; beforeEach(() => { + vi.useRealTimers(); store = createMockStore({ - getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false } as any), + getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, integrationBranch: "main" } as any), }); manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); mockedIsUsableTaskWorktree.mockResolvedValue(true); @@ -8088,37 +8346,51 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => { }); it("preserves dirty worktree as recovery patch before unrecoverable escalation", async () => { - const fixtureRoot = await mkdtemp(join(tmpdir(), "fn-4476-self-heal-")); - manager = new SelfHealingManager(store, { rootDir: fixtureRoot }); + manager.stop(); + const fixtureRoot = mkdtempSync(join(tmpdir(), "fn-4476-self-heal-")); + const autoRecoveryDispatcher = { + dispatch: vi.fn().mockResolvedValue({ + action: "pause", + rationale: "test-pause", + auditMetadata: {}, + legacyPausedReason: "branch-conflict-unrecoverable", + }), + } as any; + manager = new SelfHealingManager(store, { rootDir: fixtureRoot, autoRecoveryDispatcher }); + vi.spyOn(manager as any, "tryReanchorForeignOnlyContamination").mockResolvedValue(false); - (store.listTasks as any) - .mockResolvedValueOnce([{ id: "FN-504", checkedOutBy: null, branch: "fusion/fn-504", worktree: "/tmp/fn-504" }]) - .mockResolvedValueOnce([]); - vi.spyOn(branchConflictModule, "inspectBranchConflict").mockRejectedValueOnce(new Error("boom")); - mockedExecSync.mockImplementation((cmd: any) => { - const command = String(cmd); - if (command === "git status --porcelain") { - return Buffer.from(" M src/file.ts\n"); - } - if (command === "git diff HEAD --binary") { - return Buffer.from("diff --git a/src/file.ts b/src/file.ts\n"); - } - return Buffer.from(""); - }); + try { + (store.listTasks as any) + .mockResolvedValueOnce([{ id: "FN-504", checkedOutBy: null, branch: "fusion/fn-504", worktree: "/tmp/fn-504" }]) + .mockResolvedValueOnce([]); + vi.spyOn(branchConflictModule, "inspectBranchConflict").mockRejectedValueOnce(new Error("boom")); + mockedExecSync.mockImplementation((cmd: any) => { + const command = String(cmd); + if (command === "git status --porcelain") { + return Buffer.from(" M src/file.ts\n"); + } + if (command === "git diff HEAD --binary") { + return Buffer.from("diff --git a/src/file.ts b/src/file.ts\n"); + } + return Buffer.from(""); + }); - const recovered = await manager.reclaimSelfOwnedBranchConflicts(); - expect(recovered).toBe(0); - expect(store.handoffToReview).toHaveBeenCalledWith("FN-504", expect.objectContaining({ - evidence: expect.objectContaining({ reason: "branch-conflict-unrecoverable-repromote" }), - })); + const recovered = await manager.reclaimSelfOwnedBranchConflicts(); + expect(recovered).toBe(0); + expect(autoRecoveryDispatcher.dispatch).toHaveBeenCalledTimes(1); + expect(store.handoffToReview).toHaveBeenCalledWith("FN-504", expect.objectContaining({ + evidence: expect.objectContaining({ reason: "branch-conflict-unrecoverable-repromote" }), + })); - const recoveryDir = join(fixtureRoot, ".fusion", "recovery"); - const files = await readdir(recoveryDir); - const patchName = files.find((entry) => entry.startsWith("fn-504-") && entry.endsWith(".patch")); - expect(patchName).toBeTruthy(); - const patchContent = await readFile(join(recoveryDir, patchName ?? ""), "utf-8"); - expect(patchContent).toContain("diff --git"); - await rm(fixtureRoot, { recursive: true, force: true }); + const recoveryDir = join(fixtureRoot, ".fusion", "recovery"); + const files = readdirSync(recoveryDir); + const patchName = files.find((entry) => entry.startsWith("fn-504-") && entry.endsWith(".patch")); + expect(patchName).toBeTruthy(); + expect(files).toContain(patchName); + expect(mockedExecSync).toHaveBeenCalledWith("git diff HEAD --binary", expect.any(Object)); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } }); it("escalates unrecoverable reclaim failures to in-review failed", async () => { @@ -8771,4 +9043,172 @@ describe("FN-5335 triple-proof no-action unit coverage", () => { expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "task:finalize-no-op-review-no-action" })); }); + describe("reconcileDependencyBlockingLeases — FN-6292", () => { + const makeTask = (overrides: Partial<Task>): Task => ({ + id: "FN-T", + description: "test", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-06-12T00:00:00.000Z", + updatedAt: "2026-06-12T00:00:00.000Z", + prompt: "", + ...overrides, + } as Task); + + const setup = (initialTasks: Task[], scopes: Record<string, string[]>, settings: Partial<Settings> = {}) => { + const tasks = new Map(initialTasks.map((task) => [task.id, task])); + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, taskStuckTimeoutMs: 1_000, ...settings } as any), + listTasks: vi.fn(async () => [...tasks.values()]), + parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? []), + moveTask: vi.fn(async (taskId: string, column: Task["column"]) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing ${taskId}`); + const updated = { ...current, column } as Task; + tasks.set(taskId, updated); + return updated; + }), + updateTask: vi.fn(async (taskId: string, updates: Partial<Task>) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing ${taskId}`); + const updated = { ...current, ...updates } as Task; + if (updates.overlapBlockedBy === null) updated.overlapBlockedBy = undefined; + tasks.set(taskId, updated); + return updated; + }), + }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + return { store, manager, tasks }; + }; + + it("rebounds a stale dependency-blocking lease and is idempotent", async () => { + const { store, manager, tasks } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"], worktree: "/tmp/wt-h" }), + makeTask({ id: "FN-D", column: "todo", status: "queued", overlapBlockedBy: "FN-H" }), + ], { + "FN-H": ["packages/engine/src/scheduler.ts"], + "FN-D": ["packages/engine/src/scheduler.ts"], + }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, stalenessMs: 10_000, reason: "test", metadata: {} }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(1); + expect(store.moveTask).toHaveBeenCalledWith("FN-H", "todo", expect.objectContaining({ + preserveProgress: true, + preserveWorktree: true, + preserveResumeState: true, + moveSource: "engine", + recoveryRehome: true, + })); + expect(tasks.get("FN-H")?.userPaused).not.toBe(true); + expect(store.updateTask).toHaveBeenCalledWith("FN-D", { overlapBlockedBy: null, status: null }); + expect(store.logEntry).toHaveBeenCalledWith("FN-H", expect.stringContaining("FN-6292")); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:reconcile-dependency-blocking-lease", + target: "FN-H", + })); + + vi.clearAllMocks(); + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.moveTask).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("rebounds an overlapping todo dependency even before a stale blocker marker is stamped", async () => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + makeTask({ id: "FN-D", column: "todo" }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, stalenessMs: 10_000, reason: "test", metadata: {} }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(1); + expect(store.moveTask).toHaveBeenCalledWith("FN-H", "todo", expect.objectContaining({ moveSource: "engine", recoveryRehome: true })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-D", { overlapBlockedBy: null, status: null }); + manager.stop(); + }); + + it("does not rebound met dependencies", async () => { + const { store, manager } = setup([ + makeTask({ id: "FN-D", column: "done" }), + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, metadata: {} }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.moveTask).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("does not rebound non-overlapping unmet dependencies without a stale blocker marker", async () => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + makeTask({ id: "FN-D", column: "todo" }), + ], { "FN-H": ["a.ts"], "FN-D": ["b.ts"] }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, metadata: {} }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.moveTask).not.toHaveBeenCalled(); + manager.stop(); + }); + + it.each([{ userPaused: true }, { paused: true }])("does not rebound operator-paused holders: %o", async (pauseState) => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"], ...pauseState }), + makeTask({ id: "FN-D", column: "todo", overlapBlockedBy: "FN-H" }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: true, metadata: {} }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.moveTask).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("uses merge-shadow dependency marker options during deadlock scans", async () => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + makeTask({ id: "FN-D", column: "todo", overlapBlockedBy: "FN-H" }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }, { mergeRequestContractShadowEnabled: true }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: false, stalenessMs: 0, reason: "test", metadata: {} }); + vi.mocked(store.getCompletionHandoffAcceptedMarker as any).mockReturnValue({ acceptedAt: "2026-06-12T00:00:00.000Z" }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith("FN-D"); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:reconcile-dependency-blocking-lease-no-action", + target: "FN-H", + })); + manager.stop(); + }); + + it.each([{ globalPause: true }, { enginePaused: true }])("short-circuits while paused: %o", async (pausedSettings) => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + makeTask({ id: "FN-D", column: "todo", overlapBlockedBy: "FN-H" }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }, pausedSettings); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.listTasks).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + manager.stop(); + }); + + it("emits no-action audit and does not rebound when triple-proof fails", async () => { + const { store, manager } = setup([ + makeTask({ id: "FN-H", column: "in-progress", dependencies: ["FN-D"] }), + makeTask({ id: "FN-D", column: "todo", overlapBlockedBy: "FN-H" }), + ], { "FN-H": ["a.ts"], "FN-D": ["a.ts"] }); + vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: false, stalenessMs: 0, reason: "active", metadata: { reason: "active" } }); + + await expect(manager.reconcileDependencyBlockingLeases()).resolves.toBe(0); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "task:reconcile-dependency-blocking-lease-no-action", + target: "FN-H", + })); + manager.stop(); + }); + }); + }); diff --git a/packages/engine/src/__tests__/spec-validation-external-integration-evidence.test.ts b/packages/engine/src/__tests__/spec-validation-external-integration-evidence.test.ts index 46fcc4211e..5bd5ad25c4 100644 --- a/packages/engine/src/__tests__/spec-validation-external-integration-evidence.test.ts +++ b/packages/engine/src/__tests__/spec-validation-external-integration-evidence.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vitest"; import { detectExternalIntegrationEvidenceGaps } from "../spec-validation/external-integration-evidence.js"; +const fn6349EvidenceBlock = `## Mission +Validate released third-party external integration. + +## External Integration Evidence +This task installs and runs the released third-party-distributed Fusion CLI (\`@runfusion/fusion\`) from the public npm registry. Provenance (verified via \`npm view @runfusion/fusion\` on 2026-06-13): + +- Canonical upstream repo URL: https://github.com/Runfusion/Fusion +- Docs / homepage URL: https://github.com/Runfusion/Fusion#readme (npm package page: https://www.npmjs.com/package/@runfusion/fusion); in-repo author guide \`docs/plugins/external-authoring.md\` +- Release / download URL: https://registry.npmjs.org/@runfusion/fusion/-/fusion-0.41.0.tgz +- Binary / CLI name: \`fn\` (provided by the published \`@runfusion/fusion\` package; also invokable via \`npx @runfusion/fusion@latest\`) +- Checksum (dist.integrity for 0.41.0): \`sha512-y8BSeK3XUgcE7ceTrz6F/zWQidaiADVgHSHHWKRzwjyR40xeUc8i5ZSolGd1zL/K9AxrBSkRErimkW1xqb/EBw==\` (marker: \`upstream-pending-verification\` if a newer release ships before validation) + +## Steps +- Install and run the released third-party external integration. +`; + describe("detectExternalIntegrationEvidenceGaps", () => { it("returns empty findings when prompt has no external integration signals", () => { const prompt = `# Task\n## Mission\nRefactor retry budget counters in scheduler.\n## Steps\n- Update store logic.`; @@ -24,6 +40,41 @@ describe("detectExternalIntegrationEvidenceGaps", () => { expect(detectExternalIntegrationEvidenceGaps({ promptContent: prompt })).toEqual([]); }); + it("accepts FN-6349 labeled evidence in a dedicated external integration evidence section", () => { + expect(detectExternalIntegrationEvidenceGaps({ promptContent: fn6349EvidenceBlock })).toEqual([]); + }); + + it("accepts concrete labeled markdown evidence with backtick-wrapped URLs and sha256 digest", () => { + const prompt = `## Mission\nInstall third-party external CLI from an upstream release.\n\n## External-Integration Evidence\n- Canonical upstream repo: \`https://github.com/acme/tooling\`\n- Docs/homepage: \`https://docs.acme.test/tooling\`\n- Release/download: \`https://downloads.acme.test/tooling/tooling-1.2.3.tar.gz\`\n- Binary/CLI name: \`ac\`\n- Checksum: sha256-deadbeef\n\n## Steps\n- Download, probe, and run the external binary.`; + + expect(detectExternalIntegrationEvidenceGaps({ promptContent: prompt })).toEqual([]); + }); + + it("accepts inline labeled evidence in pre-existing scanned sections", () => { + const prompt = `## Mission\nAdd third-party external tool install flow.\n\n## Context to Read First\n- Canonical upstream repo URL: https://github.com/acme/tooling\n- Docs URL: https://docs.acme.test/tooling\n- Release URL: https://github.com/acme/tooling/releases/download/v1.0.0/tooling.tgz\n- CLI name: \`ac\`\n- Checksum: upstream-pending-verification\n\n## Steps\n- Install, probe, and run the external binary.`; + + expect(detectExternalIntegrationEvidenceGaps({ promptContent: prompt })).toEqual([]); + }); + + it("still requires checksum evidence when the FN-6349 block omits checksum and source markers", () => { + const prompt = fn6349EvidenceBlock.replace( + /- Checksum \(dist\.integrity for 0\.41\.0\):.*\n/, + "- Checksum (dist.integrity for 0.41.0):\n", + ); + + const findings = detectExternalIntegrationEvidenceGaps({ promptContent: prompt }); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]?.missing).toContain("checksum-or-source-of-truth-evidence"); + }); + + it("still requires an artifact URL and a backticked CLI name", () => { + const prompt = `## Mission\nAdd third-party external CLI install flow.\n\n## External Integration Evidence\n- Canonical upstream repo URL: https://github.com/acme/tooling\n- Docs / homepage URL: https://docs.acme.test/tooling\n- Release / download URL:\n- Binary / CLI name: ac\n- Checksum: sha512-deadbeef\n\n## Steps\n- Download and probe the external binary.`; + + const findings = detectExternalIntegrationEvidenceGaps({ promptContent: prompt }); + expect(findings.length).toBeGreaterThan(0); + expect(findings[0]?.missing).toEqual(expect.arrayContaining(["release-or-download-url", "binary-or-cli-name"])); + }); + it("treats duplicate-segment github URLs as missing canonical evidence", () => { const duplicateRepo = ["foo", "foo"].join("/"); const prompt = `## Mission\nExternal tool install.\n## Steps\n- download release from https://github.com/${duplicateRepo}/releases/latest/download/foo.tgz\n- run and probe \`foo\``; diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index daf33448d0..369fd35932 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -986,6 +986,7 @@ function makeMockSession(promptFn?: () => Promise<void>) { prompt: promptFn ?? vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), + steer: vi.fn().mockResolvedValue(undefined), model: { provider: "mock", id: "mock-model" }, }; } @@ -1021,6 +1022,45 @@ describe("StepSessionExecutor", () => { vi.useRealTimers(); }); + describe("steering", () => { + it("steers every active step session and continues after per-session failures", async () => { + const task = makeTaskDetail(); + const executor = new StepSessionExecutor({ + taskDetail: task, + worktreePath: "/project/.worktrees/main", + rootDir: "/project", + settings: makeSettings(), + pluginRunner: undefined, + } as any); + const steerOne = vi.fn().mockResolvedValue(undefined); + const steerTwo = vi.fn().mockRejectedValue(new Error("disconnected")); + const steerThree = vi.fn().mockResolvedValue(undefined); + + (executor as any).activeSessions.set(0, { + dispose: vi.fn(), + abortBash: vi.fn(), + steer: steerOne, + }); + (executor as any).activeSessions.set(1, { + dispose: vi.fn(), + abortBash: vi.fn(), + steer: steerTwo, + }); + (executor as any).activeSessions.set(2, { + dispose: vi.fn(), + abortBash: vi.fn(), + steer: steerThree, + }); + + await executor.steerActiveSessions("new guidance"); + + expect(steerOne).toHaveBeenCalledWith("new guidance"); + expect(steerTwo).toHaveBeenCalledWith("new guidance"); + expect(steerThree).toHaveBeenCalledWith("new guidance"); + expect(getStepSessionLogger().warn).toHaveBeenCalledWith(expect.stringContaining("Failed to steer active session for step 1")); + }); + }); + describe("sequential execution", () => { it("forwards taskEnv into step session creation", async () => { const prompt = makeStepPrompt("FN-001", 1); diff --git a/packages/engine/src/__tests__/transient-merge-error-classifier.test.ts b/packages/engine/src/__tests__/transient-merge-error-classifier.test.ts new file mode 100644 index 0000000000..1b5001c67d --- /dev/null +++ b/packages/engine/src/__tests__/transient-merge-error-classifier.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyTransientMergeError } from "../transient-merge-error-classifier.js"; + +describe("classifyTransientMergeError", () => { + it("returns null for empty or missing errors", () => { + expect(classifyTransientMergeError(null)).toBeNull(); + expect(classifyTransientMergeError(undefined)).toBeNull(); + expect(classifyTransientMergeError("")).toBeNull(); + }); + + it("classifies process spawn cwd failures without over-matching bare errno prose", () => { + expect(classifyTransientMergeError("spawn ENOTDIR")).toBe("process-spawn-failure"); + expect(classifyTransientMergeError("spawn git ENOENT")).toBe("process-spawn-failure"); + expect(classifyTransientMergeError("spawn ENOENT")).toBe("process-spawn-failure"); + expect(classifyTransientMergeError("Bash tool failed: spawn node ENOTDIR while starting merge verification")) + .toBe("process-spawn-failure"); + expect(classifyTransientMergeError("fatal: '/var/folders/x/fusion-ai-merge-fn-1-abc' is not a working tree")) + .toBe("process-spawn-failure"); + + expect(classifyTransientMergeError("ENOTDIR while reading packages/cli/package.json")) + .toBeNull(); + expect(classifyTransientMergeError("User noted ENOENT in a comment, but no process was spawned")) + .toBeNull(); + expect(classifyTransientMergeError("Verification failed: cannot find module './missing-file.js'")) + .toBeNull(); + }); + + it("keeps existing transient merge classes stable", () => { + expect(classifyTransientMergeError("Merge handoff refused (lease-handoff-failed): target-not-queued")) + .toBe("lease-handoff-target-not-queued"); + + expect(classifyTransientMergeError( + "Integration branch main advanced concurrently (expected 5b5da2c24fa006b46139ce4566b764126c6b84ca, observed 5b5da2c24fa006b46139ce4566b764126c6b84ca) while applying 283b290aec527f9ba4244f2935700a2823dd106b", + )).toBe("spurious-concurrent-advance-same-sha"); + }); + + it("does not classify genuine concurrent advances with different SHAs", () => { + expect(classifyTransientMergeError( + "Integration branch main advanced concurrently (expected aaa1111aaa1111aaa1111aaa1111aaa1111aaaa, observed bbb2222bbb2222bbb2222bbb2222bbb2222bbbb) while applying ccc3333ccc3333ccc3333ccc3333ccc3333cccc", + )).toBeNull(); + }); +}); diff --git a/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts b/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts index 1aa1ddf387..7e7c39f2a2 100644 --- a/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts +++ b/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { - FAST_TRIAGE_SYSTEM_PROMPT, - TRIAGE_SYSTEM_PROMPT, - TriageProcessor, -} from "../triage.js"; +import { builtinSeamPrompt, resolveAgentPrompt } from "@fusion/core"; +import { TriageProcessor } from "../triage.js"; import { createTriageDuplicateScenario } from "./fixtures/triage-duplicate-scenario.js"; const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({ @@ -23,16 +20,20 @@ vi.mock("../pi.js", () => ({ vi.mock("@fusion/core", async (importOriginal) => { const { createEngineCoreMock } = await import("../test/mockCore.js"); - return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), { - resolveAgentPrompt: vi.fn().mockReturnValue(null), + const original = await importOriginal<typeof import("@fusion/core")>(); + return createEngineCoreMock(() => Promise.resolve(original), { + resolveAgentPrompt: vi.fn(original.resolveAgentPrompt), }); }); +const TRIAGE_POLICY_PROMPT = resolveAgentPrompt("triage"); +const FAST_PLANNING_PROMPT = builtinSeamPrompt("planning-fast"); + /** * FN-4726 / FN-4734 / FN-4741: triage created repeated duplicate tasks after equivalent * work had already landed. FN-4774 fixed this by (1) exposing fn_task_search in triage, - * (2) guiding TRIAGE_SYSTEM_PROMPT to search done/archived before creating, and - * (3) preserving that guidance in FAST_TRIAGE_SYSTEM_PROMPT. FN-4815 pins this contract. + * (2) guiding the canonical triage policy prompt to search done/archived before creating, and + * (3) preserving that guidance in FAST_PLANNING_PROMPT. FN-4815 pins this contract. */ describe("FN-4815 triage duplicate-search regression", () => { it("toolset contract: createTriageTools includes fn_task_search", () => { @@ -50,17 +51,17 @@ describe("FN-4815 triage duplicate-search regression", () => { }); it("standard prompt guidance keeps duplicate-search instructions", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("Duplicate check"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("fn_task_search"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("including done and archived tasks"); - expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(TRIAGE_SYSTEM_PROMPT)).toBe(true); + expect(TRIAGE_POLICY_PROMPT).toContain("Duplicate check"); + expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); + expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks"); + expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(TRIAGE_POLICY_PROMPT)).toBe(true); }); it("fast prompt guidance keeps duplicate-search instructions", () => { - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("Duplicate check"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_task_search"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("For any likely match in `done` or `archived`"); - expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(FAST_TRIAGE_SYSTEM_PROMPT)).toBe(true); + expect(FAST_PLANNING_PROMPT).toContain("Duplicate check"); + expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); + expect(FAST_PLANNING_PROMPT).toContain("For any likely match in `done` or `archived`"); + expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(FAST_PLANNING_PROMPT)).toBe(true); }); it("end-to-end duplicate discovery via fixture shows done match before create", async () => { diff --git a/packages/engine/src/__tests__/triage-fast-mode-workflow-variant.test.ts b/packages/engine/src/__tests__/triage-fast-mode-workflow-variant.test.ts new file mode 100644 index 0000000000..181071e9ab --- /dev/null +++ b/packages/engine/src/__tests__/triage-fast-mode-workflow-variant.test.ts @@ -0,0 +1,219 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core"; +import { + BUILTIN_CODING_WORKFLOW_IR, + builtinSeamPrompt, + renderTriagePolicyPlaceholders, + resolvePlanningPromptFromIr, +} from "@fusion/core"; +import { TriageProcessor } from "../triage.js"; + +const { mockReviewStep, mockCreateFnAgent, mockPromptWithFallback } = vi.hoisted(() => ({ + mockReviewStep: vi.fn(), + mockCreateFnAgent: vi.fn(), + mockPromptWithFallback: vi.fn(), +})); + +vi.mock("../reviewer.js", () => ({ + reviewStep: mockReviewStep, +})); + +vi.mock("../pi.js", () => ({ + createFnAgent: mockCreateFnAgent, + describeModel: vi.fn().mockReturnValue("mock-model"), + promptWithFallback: mockPromptWithFallback, +})); + +vi.mock("@fusion/core", async (importOriginal) => { + const { createEngineCoreMock } = await import("../test/mockCore.js"); + const original = await importOriginal<typeof import("@fusion/core")>(); + return createEngineCoreMock(() => Promise.resolve(original), { + resolveAgentPrompt: vi.fn(original.resolveAgentPrompt), + }); +}); + +function createTask(overrides: Partial<Task> = {}): Task { + return { + id: "FN-6236-T", + description: "Fast workflow variant regression", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function createDetail(task: Task): TaskDetail { + return { + ...task, + prompt: "", + attachments: [], + comments: [], + } as TaskDetail; +} + +function createStore(task: Task, settings: Partial<Settings> = {}, overrides: Partial<TaskStore> = {}): TaskStore { + return { + getTask: vi.fn().mockResolvedValue(createDetail(task)), + listTasks: vi.fn().mockResolvedValue([]), + createTask: vi.fn(), + moveTask: vi.fn(), + updateTask: vi.fn().mockResolvedValue(undefined), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + ...settings, + } as Settings), + updateSettings: vi.fn(), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getAgentLogs: vi.fn().mockResolvedValue([]), + addSteeringComment: vi.fn(), + parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]), + parseStepsFromPrompt: vi.fn().mockResolvedValue([]), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), + getWorkflowSettingValues: vi.fn().mockResolvedValue({}), + getWorkflowSettingsProjectId: vi.fn().mockReturnValue("default"), + on: vi.fn(), + emit: vi.fn(), + ...overrides, + } as unknown as TaskStore; +} + +function mockSession(capture: { basePrompt?: string; customTools?: any[] } = {}) { + mockCreateFnAgent.mockImplementationOnce(async (opts: any) => { + capture.basePrompt = opts.systemPromptLayers?.stable ?? opts.systemPrompt; + capture.customTools = opts.customTools; + return { + session: { + state: {}, + sessionManager: { getLeafId: vi.fn().mockReturnValue(null) }, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + navigateTree: vi.fn(), + __customTools: opts.customTools, + }, + }; + }); +} + +async function captureBasePrompt(task: Task, store: TaskStore): Promise<string> { + const capture: { basePrompt?: string } = {}; + mockSession(capture); + mockPromptWithFallback.mockResolvedValueOnce(undefined); + + await new TriageProcessor(store, "/tmp/root").specifyTask(task); + return capture.basePrompt ?? ""; +} + +async function runReviewSpec(task: Task, store: TaskStore, rootDir: string): Promise<void> { + mockSession(); + mockPromptWithFallback.mockImplementationOnce(async (session: any) => { + const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); + await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true }); + await writeFile(promptPath, "# Task: FN-6236\n\n## Mission\n\nVerify fast policy.\n", "utf8"); + const reviewSpec = session.__customTools.find((tool: any) => tool.name === "fn_review_spec"); + await reviewSpec.execute(); + }); + + await new TriageProcessor(store, rootDir).specifyTask(task); +} + +const renderedFastPlanningPrompt = renderTriagePolicyPlaceholders(builtinSeamPrompt("planning-fast"), {}); +const renderedStandardPlanningPrompt = renderTriagePolicyPlaceholders( + resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR)!, + {}, +); + +describe("fast-mode workflow variant resolution", () => { + let tempRoots: string[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + mockReviewStep.mockResolvedValue({ verdict: "APPROVE", summary: "ok", review: "" }); + }); + + afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))); + tempRoots = []; + }); + + it("resolves fast tasks to the lean planning-fast workflow prompt", async () => { + const task = createTask({ id: "FN-6236-FAST-PROMPT", executionMode: "fast" }); + const store = createStore(task); + + await expect(captureBasePrompt(task, store)).resolves.toBe(renderedFastPlanningPrompt); + }); + + it("resolves standard tasks to the standard workflow planning prompt", async () => { + const task = createTask({ id: "FN-6236-STANDARD-PROMPT", executionMode: "standard" }); + const store = createStore(task); + + const basePrompt = await captureBasePrompt(task, store); + + expect(basePrompt).toBe(renderedStandardPlanningPrompt); + expect(basePrompt).not.toBe(renderedFastPlanningPrompt); + }); + + it("auto-approves fast tasks without invoking the reviewer", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-fast-")); + tempRoots.push(rootDir); + const task = createTask({ id: "FN-6236-FAST-REVIEW", executionMode: "fast" }); + const store = createStore(task); + + await runReviewSpec(task, store, rootDir); + + expect(mockReviewStep).not.toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith(task.id, "Spec review: APPROVE (auto-approve spec)"); + }); + + it("invokes the reviewer for standard tasks without autoApproveSpec", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-standard-")); + tempRoots.push(rootDir); + const task = createTask({ id: "FN-6236-STANDARD-REVIEW", executionMode: "standard" }); + const store = createStore(task); + + await runReviewSpec(task, store, rootDir); + + expect(mockReviewStep).toHaveBeenCalledTimes(1); + }); + + it("auto-approves standard tasks when the workflow setting is enabled", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn-6236-setting-")); + tempRoots.push(rootDir); + const task = createTask({ id: "FN-6236-SETTING-REVIEW", executionMode: "standard" }); + const store = createStore(task, { autoApproveSpec: true }); + + await runReviewSpec(task, store, rootDir); + + expect(mockReviewStep).not.toHaveBeenCalled(); + expect(store.logEntry).toHaveBeenCalledWith(task.id, "Spec review: APPROVE (auto-approve spec)"); + }); + + it("preserves user triage prompt override precedence over the fast variant", async () => { + const task = createTask({ id: "FN-6236-OVERRIDE", executionMode: "fast" }); + const overridePrompt = "custom fast override prompt"; + const store = createStore(task, { + agentPrompts: { + templates: [{ id: "custom-triage", name: "Custom", role: "triage", prompt: overridePrompt }], + roleAssignments: { triage: "custom-triage" }, + }, + } as Partial<Settings>); + + await expect(captureBasePrompt(task, store)).resolves.toBe(overridePrompt); + }); +}); diff --git a/packages/engine/src/__tests__/triage-pause-abort.test.ts b/packages/engine/src/__tests__/triage-pause-abort.test.ts new file mode 100644 index 0000000000..48486e2be3 --- /dev/null +++ b/packages/engine/src/__tests__/triage-pause-abort.test.ts @@ -0,0 +1,237 @@ +import "./executor-test-helpers.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Settings, Task, TaskStore } from "@fusion/core"; + +import { TriageProcessor } from "../triage.js"; +import { resetExecutorMocks } from "./executor-test-helpers.js"; + +type Listener = (...args: any[]) => void; + +function createEventedStore(overrides: Record<string, any> = {}) { + const listeners = new Map<string, Set<Listener>>(); + const store = { + getSettings: vi.fn().mockResolvedValue({ pollIntervalMs: 60_000, maxConcurrent: 1, maxWorktrees: 1, autoMerge: true }), + listTasks: vi.fn().mockResolvedValue([]), + updateTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn().mockResolvedValue(undefined), + on: vi.fn((event: string, listener: Listener) => { + const set = listeners.get(event) ?? new Set<Listener>(); + set.add(listener); + listeners.set(event, set); + }), + off: vi.fn((event: string, listener: Listener) => { + listeners.get(event)?.delete(listener); + }), + ...overrides, + } as any; + + return { + store, + emit(event: string, ...args: any[]) { + for (const listener of listeners.get(event) ?? []) { + listener(...args); + } + }, + }; +} + +function createFinalizeStore(overrides: Partial<TaskStore> = {}): TaskStore { + return { + listTasks: vi.fn().mockResolvedValue([]), + getTask: vi.fn().mockResolvedValue(createTask()), + getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings), + parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]), + parseStepsFromPrompt: vi.fn().mockResolvedValue([]), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + updateTask: vi.fn().mockResolvedValue(undefined), + moveTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + deleteTask: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + off: vi.fn(), + ...overrides, + } as unknown as TaskStore; +} + +function createTask(overrides: Partial<Task> = {}): Task { + return { + id: "FN-PAUSE-1", + title: "Paused planning task", + description: "desc", + column: "triage", + status: "planning", + dependencies: [], + steps: [], + currentStep: 0, + log: [{ timestamp: new Date().toISOString(), action: "Spec review: APPROVE" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +describe("TriageProcessor per-task pause aborts", () => { + beforeEach(() => { + resetExecutorMocks(); + vi.clearAllMocks(); + }); + + it("does not start planning work for an already-paused triage task", async () => { + const task = createTask({ id: "FN-PAUSE-START", paused: true, status: null }); + const { store } = createEventedStore({ listTasks: vi.fn().mockResolvedValue([task]) }); + const processor = new TriageProcessor(store, "/tmp/root"); + const specifyTask = vi.spyOn(processor as any, "specifyTask").mockResolvedValue(undefined); + + (processor as any).running = true; + await (processor as any).poll(); + + expect(specifyTask).not.toHaveBeenCalled(); + expect((processor as any).processing.has(task.id)).toBe(false); + }); + + it("aborts and disposes an active specify session on task:updated pause without moving to todo", async () => { + const { store, emit } = createEventedStore(); + const stuckTaskDetector = { untrackTask: vi.fn() }; + const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector } as any); + const abort = vi.fn().mockResolvedValue(undefined); + const dispose = vi.fn(); + + processor.start(); + (processor as any).activeSessions.set("FN-PAUSE-2", { abort, dispose }); + + emit("task:updated", { id: "FN-PAUSE-2", paused: true }); + await Promise.resolve(); + + expect(abort).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect((processor as any).activeSessions.has("FN-PAUSE-2")).toBe(false); + expect((processor as any).pauseAborted.has("FN-PAUSE-2")).toBe(true); + expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-PAUSE-2"); + expect(store.moveTask).not.toHaveBeenCalled(); + + processor.stop(); + }); + + it("treats userPaused task updates as pause aborts", async () => { + const { store, emit } = createEventedStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + const abort = vi.fn().mockResolvedValue(undefined); + const dispose = vi.fn(); + + processor.start(); + (processor as any).activeSessions.set("FN-USER-PAUSE", { abort, dispose }); + + emit("task:updated", { id: "FN-USER-PAUSE", userPaused: true }); + await Promise.resolve(); + + expect(abort).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect((processor as any).pauseAborted.has("FN-USER-PAUSE")).toBe(true); + + processor.stop(); + }); + + it("does not abort on non-paused updates or paused ids with no active session", () => { + const { store, emit } = createEventedStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + const abort = vi.fn().mockResolvedValue(undefined); + const dispose = vi.fn(); + + processor.start(); + (processor as any).activeSessions.set("FN-ACTIVE", { abort, dispose }); + + expect(() => emit("task:updated", { id: "FN-ACTIVE", paused: false })).not.toThrow(); + expect(() => emit("task:updated", { id: "FN-MISSING", paused: true })).not.toThrow(); + + expect(abort).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + expect((processor as any).activeSessions.has("FN-ACTIVE")).toBe(true); + + processor.stop(); + }); + + it("detaches the task:updated pause listener on stop", () => { + const { store, emit } = createEventedStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + const abort = vi.fn().mockResolvedValue(undefined); + const dispose = vi.fn(); + + processor.start(); + (processor as any).activeSessions.set("FN-PAUSE-STOP", { abort, dispose }); + processor.stop(); + const abortCallsAfterStop = abort.mock.calls.length; + const disposeCallsAfterStop = dispose.mock.calls.length; + + emit("task:updated", { id: "FN-PAUSE-STOP", paused: true }); + + expect(abort).toHaveBeenCalledTimes(abortCallsAfterStop); + expect(dispose).toHaveBeenCalledTimes(disposeCallsAfterStop); + }); +}); + +describe("TriageProcessor paused finalization guard", () => { + beforeEach(() => { + resetExecutorMocks(); + vi.clearAllMocks(); + }); + + it("does not move an approved task to todo when the re-read task is paused", async () => { + const task = createTask({ id: "FN-FINALIZE-PAUSED" }); + const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, paused: true }) }); + const processor = new TriageProcessor(store, "/tmp/root"); + + await (processor as any).finalizeApprovedTask( + task, + "# Task: FN-FINALIZE-PAUSED\n\n## File Scope\n- packages/engine/src/triage.ts\n", + { requirePlanApproval: false } as Settings, + ); + + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).toHaveBeenLastCalledWith(task.id, { status: null }); + expect(store.logEntry).toHaveBeenCalledWith( + task.id, + "Specification approved but task is paused — leaving in triage, will resume on unpause", + ); + }); + + it("does not move to awaiting-approval when the re-read task is userPaused", async () => { + const task = createTask({ id: "FN-FINALIZE-USER-PAUSED" }); + const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, userPaused: true }) }); + const processor = new TriageProcessor(store, "/tmp/root"); + + await (processor as any).finalizeApprovedTask( + task, + "# Task: FN-FINALIZE-USER-PAUSED\n\n## File Scope\n- packages/engine/src/triage.ts\n", + { requirePlanApproval: true } as Settings, + ); + + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({ status: "awaiting-approval" })); + expect(store.updateTask).toHaveBeenLastCalledWith(task.id, { status: null }); + }); + + it("keeps the unpaused approved-spec happy path moving to todo", async () => { + const task = createTask({ id: "FN-FINALIZE-HAPPY" }); + const store = createFinalizeStore({ getTask: vi.fn().mockResolvedValue({ ...task, paused: false, userPaused: false }) }); + const processor = new TriageProcessor(store, "/tmp/root"); + + await (processor as any).finalizeApprovedTask( + task, + "# Task: FN-FINALIZE-HAPPY\n\n## File Scope\n- packages/engine/src/triage.ts\n", + { requirePlanApproval: false } as Settings, + ); + + expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo"); + }); + + it("does not recover an approved planning task while it is paused", async () => { + const task = createTask({ id: "FN-RECOVER-PAUSED", paused: true }); + const store = createFinalizeStore(); + const processor = new TriageProcessor(store, "/tmp/root"); + + await expect(processor.recoverApprovedTask(task)).resolves.toBe(false); + + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts new file mode 100644 index 0000000000..1be76c1fd1 --- /dev/null +++ b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Settings, Task, TaskDetail, TaskStore, WorkflowIr } from "@fusion/core"; +import { + BUILTIN_CODING_WORKFLOW_IR, + builtinSeamPrompt, + renderTriagePolicyPlaceholders, + resolveAgentPrompt, + resolvePlanningPromptFromIr, +} from "@fusion/core"; +import { TriageProcessor } from "../triage.js"; + +const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({ + mockReviewStep: vi.fn(), + mockCreateFnAgent: vi.fn(), +})); + +vi.mock("../reviewer.js", () => ({ + reviewStep: mockReviewStep, +})); + +vi.mock("../pi.js", () => ({ + createFnAgent: mockCreateFnAgent, + describeModel: vi.fn().mockReturnValue("mock-model"), + promptWithFallback: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@fusion/core", async (importOriginal) => { + const { createEngineCoreMock } = await import("../test/mockCore.js"); + const original = await importOriginal<typeof import("@fusion/core")>(); + return createEngineCoreMock(() => Promise.resolve(original), { + resolveAgentPrompt: vi.fn(original.resolveAgentPrompt), + }); +}); + +function createTask(overrides: Partial<Task> = {}): Task { + return { + id: "FN-6232-T", + description: "Triage planning prompt test", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function createDetail(task: Task): TaskDetail { + return { + ...task, + prompt: "", + attachments: [], + comments: [], + } as TaskDetail; +} + +function createStore(task: Task, overrides: Partial<TaskStore> = {}, settings: Partial<Settings> = {}): TaskStore { + return { + getTask: vi.fn().mockResolvedValue(createDetail(task)), + listTasks: vi.fn().mockResolvedValue([]), + createTask: vi.fn(), + moveTask: vi.fn(), + updateTask: vi.fn().mockResolvedValue(undefined), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + ...settings, + } as Settings), + updateSettings: vi.fn(), + logEntry: vi.fn().mockResolvedValue(undefined), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + getAgentLogs: vi.fn().mockResolvedValue([]), + addSteeringComment: vi.fn(), + parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]), + parseStepsFromPrompt: vi.fn().mockResolvedValue([]), + parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), + getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined), + getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + emit: vi.fn(), + ...overrides, + } as unknown as TaskStore; +} + +async function captureBasePrompt(task: Task, store: TaskStore): Promise<string> { + let captured = ""; + mockCreateFnAgent.mockImplementationOnce(async (opts: any) => { + captured = opts.systemPromptLayers?.stable ?? opts.systemPrompt; + return { + session: { + state: {}, + sessionManager: { getLeafId: vi.fn().mockReturnValue(null) }, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }; + }); + + await new TriageProcessor(store, "/tmp/root").specifyTask(task); + return captured; +} + +const canonicalPlanningPrompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR)!; +const renderedCanonicalPlanningPrompt = renderTriagePolicyPlaceholders(canonicalPlanningPrompt, {}); +const renderedDefaultTriagePrompt = renderTriagePolicyPlaceholders(resolveAgentPrompt("triage"), {}); +const renderedFastPlanningPrompt = renderTriagePolicyPlaceholders(builtinSeamPrompt("planning-fast"), {}); + +describe("triage planning prompt single source", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the built-in workflow IR planning prompt in standard mode", async () => { + const task = createTask({ id: "FN-6232-BUILTIN", executionMode: "standard" }); + const store = createStore(task, { + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), + }); + + await expect(captureBasePrompt(task, store)).resolves.toBe(renderedCanonicalPlanningPrompt); + }); + + it("uses the built-in workflow IR planning prompt when no workflow is selected", async () => { + const task = createTask({ id: "FN-6232-NO-SELECTION", executionMode: "standard" }); + const store = createStore(task); + + await expect(captureBasePrompt(task, store)).resolves.toBe(renderedCanonicalPlanningPrompt); + }); + + it("preserves user triage prompt override precedence", async () => { + const task = createTask({ id: "FN-6232-OVERRIDE", executionMode: "standard" }); + const overridePrompt = "custom triage override prompt"; + const store = createStore(task, {}, { + agentPrompts: { + templates: [{ id: "custom-triage", name: "Custom", role: "triage", prompt: overridePrompt }], + roleAssignments: { triage: "custom-triage" }, + }, + } as Partial<Settings>); + + await expect(captureBasePrompt(task, store)).resolves.toBe(overridePrompt); + }); + + it("keeps fast mode on the planning-fast workflow prompt", async () => { + const task = createTask({ id: "FN-6232-FAST", executionMode: "fast" }); + const store = createStore(task); + + await expect(captureBasePrompt(task, store)).resolves.toBe(renderedFastPlanningPrompt); + }); + + it("uses a selected custom workflow planning prompt", async () => { + const task = createTask({ id: "FN-6232-CUSTOM", executionMode: "standard" }); + const customPrompt = "custom workflow planning prompt"; + const customIr: WorkflowIr = { + version: "v1", + name: "custom-workflow", + nodes: [{ id: "planning", kind: "prompt", config: { seam: "planning", prompt: customPrompt } }], + edges: [], + }; + const store = createStore(task, { + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "WF-custom", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: customIr }), + }); + + await expect(captureBasePrompt(task, store)).resolves.toBe(customPrompt); + }); + + it("fails soft to the default triage prompt when workflow resolution cannot provide a planning prompt", async () => { + const task = createTask({ id: "FN-6232-FAIL-SOFT", executionMode: "standard" }); + const store = createStore(task, { + getTaskWorkflowSelection: vi.fn(() => { + throw new Error("selection unavailable"); + }), + }); + + await expect(captureBasePrompt(task, store)).resolves.toBe(renderedDefaultTriagePrompt); + }); +}); diff --git a/packages/engine/src/__tests__/triage-review-spec-external-integration.test.ts b/packages/engine/src/__tests__/triage-review-spec-external-integration.test.ts index a96c59197a..a513c4f198 100644 --- a/packages/engine/src/__tests__/triage-review-spec-external-integration.test.ts +++ b/packages/engine/src/__tests__/triage-review-spec-external-integration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -62,6 +62,9 @@ const mockTaskDetail: TaskDetail = { }; describe("triage fn_review_spec external integration evidence", () => { + beforeEach(() => { + mockReviewStep.mockReset(); + }); it("short-circuits to REVISE when evidence is incomplete", async () => { const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-")); try { @@ -98,6 +101,41 @@ describe("triage fn_review_spec external integration evidence", () => { } }); + it("calls reviewer when dedicated labeled evidence section is complete", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-labeled-ok-")); + try { + const taskId = "FN-5321"; + const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`; + await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true }); + await writeFile( + join(rootDir, promptPath), + "## Mission\nValidate released third-party external integration.\n\n## External Integration Evidence\n- Canonical upstream repo URL: https://github.com/Runfusion/Fusion\n- Docs / homepage URL: https://github.com/Runfusion/Fusion#readme (npm package page: https://www.npmjs.com/package/@runfusion/fusion)\n- Release / download URL: https://registry.npmjs.org/@runfusion/fusion/-/fusion-0.41.0.tgz\n- Binary / CLI name: `fn`\n- Checksum (dist.integrity for 0.41.0): `sha512-y8BSeK3XUgcE7ceTrz6F/zWQidaiADVgHSHHWKRzwjyR40xeUc8i5ZSolGd1zL/K9AxrBSkRErimkW1xqb/EBw==` (marker: `upstream-pending-verification`)\n\n## Steps\n- Install, download, probe, and run the released external binary.\n", + ); + + mockReviewStep.mockResolvedValueOnce({ verdict: "APPROVE", summary: "ok", review: "" }); + const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) }); + const processor = new TriageProcessor(store, rootDir); + const verdictRef = { current: null as any }; + const tool = (processor as any).createReviewSpecTool( + taskId, + promptPath, + { current: null }, + { current: null }, + verdictRef, + { current: "" }, + {}, + false, + ); + + const result = await tool.execute({}); + expect(result.content[0]?.text).toBe("APPROVE"); + expect(verdictRef.current).toBe("APPROVE"); + expect(mockReviewStep).toHaveBeenCalledTimes(1); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + it("calls reviewer when evidence is complete", async () => { const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-ok-")); try { diff --git a/packages/engine/src/__tests__/triage-threshold-settings.test.ts b/packages/engine/src/__tests__/triage-threshold-settings.test.ts new file mode 100644 index 0000000000..85bbfe63be --- /dev/null +++ b/packages/engine/src/__tests__/triage-threshold-settings.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + BUILTIN_CODING_WORKFLOW_IR, + renderTriagePolicyPlaceholders, + resolveEffectiveSettingsById, + resolvePlanningPromptFromIr, + TaskStore, +} from "@fusion/core"; + +const cleanupDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + cleanupDirs.push(dir); + return dir; +} + +afterEach(() => { + while (cleanupDirs.length) { + rmSync(cleanupDirs.pop()!, { recursive: true, force: true }); + } +}); + +function builtinPlanningPrompt(): string { + const prompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR); + if (!prompt) throw new Error("builtin:coding planning prompt missing"); + return prompt; +} + +describe("triage threshold workflow settings", () => { + it("renders behavior-equivalent defaults into the built-in planning prompt", () => { + const rendered = renderTriagePolicyPlaceholders(builtinPlanningPrompt(), {}); + + expect(rendered).toContain("MORE THAN 7 implementation steps"); + expect(rendered).toContain("MORE THAN 3 different packages/modules"); + expect(rendered).toContain("9 or more"); + expect(rendered).toContain("12 or more"); + expect(rendered).toContain("20 or more entries"); + expect(rendered).toContain("at or above 30 items"); + expect(rendered).toContain("S (<2h), M (2-4h), L (4-8h). Split if XL (8h+)"); + expect(rendered).toContain("Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report"); + expect(rendered).toContain("prefer `builtin:quick-fix`"); + expect(rendered).toContain("`builtin:coding` is the default"); + expect(rendered).not.toContain("{{"); + }); + + it("reflects stored workflow overrides in effective settings and rendered prompt", async () => { + const rootDir = makeTempDir("fn-6233-triage-root-"); + const globalDir = makeTempDir("fn-6233-triage-global-"); + const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + try { + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { triageSubtaskStepThreshold: 3 }); + + const effective = await resolveEffectiveSettingsById(store, "builtin:coding", projectId); + expect(effective.triageSubtaskStepThreshold).toBe(3); + + const rendered = renderTriagePolicyPlaceholders(builtinPlanningPrompt(), effective); + expect(rendered).toContain("MORE THAN 3 implementation steps"); + expect(rendered).not.toContain("MORE THAN 7 implementation steps"); + expect(rendered).not.toContain("{{"); + } finally { + store.close(); + } + }); + + it("keeps migrated threshold numbers out of the triage prompt assembly code path", async () => { + const source = await readFile(new URL("../triage.ts", import.meta.url), "utf8"); + const promptAssembly = source.slice( + source.indexOf("const workflowPlanningPrompt"), + source.indexOf("const triageSystemPromptFinal"), + ); + + expect(promptAssembly).toContain("renderTriagePolicyPlaceholders"); + expect(promptAssembly).not.toMatch(/\b(?:7|9|12|20|30)\b/); + expect(promptAssembly).not.toMatch(/builtin:quick-fix|builtin:coding/); + expect(promptAssembly).not.toMatch(/Decide|Evaluate|Verify|Confirm|Audit|Review whether|Investigate and report/); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index ccbdfd44a5..18b9e4b7ed 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1,9 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core"; +import { builtinSeamPrompt, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; import { TriageProcessor, - TRIAGE_SYSTEM_PROMPT, - FAST_TRIAGE_SYSTEM_PROMPT, buildSpecificationPrompt, readAttachmentContents, computeUserCommentFingerprint, @@ -21,6 +20,10 @@ const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn(), })); +const TRIAGE_POLICY_PROMPT = resolveAgentPrompt("triage"); +const FAST_PLANNING_PROMPT = builtinSeamPrompt("planning-fast"); +const RENDERED_TRIAGE_POLICY_PROMPT = renderTriagePolicyPlaceholders(TRIAGE_POLICY_PROMPT, {}); + vi.mock("../reviewer.js", () => ({ reviewStep: mockReviewStep, })); @@ -33,8 +36,9 @@ vi.mock("../pi.js", () => ({ vi.mock("@fusion/core", async (importOriginal) => { const { createEngineCoreMock } = await import("../test/mockCore.js"); - return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), { - resolveAgentPrompt: vi.fn().mockReturnValue(null), + const original = await importOriginal<typeof import("@fusion/core")>(); + return createEngineCoreMock(() => Promise.resolve(original), { + resolveAgentPrompt: vi.fn(original.resolveAgentPrompt), }); }); @@ -331,7 +335,7 @@ describe("buildSpecificationPrompt", () => { ); expect(prompt).toContain("## Subtask Consideration"); - expect(prompt).toContain("more than 10 implementation steps"); + expect(prompt).toContain("MORE THAN 7 implementation steps"); expect(prompt).toContain("GOOD TO SPLIT"); expect(prompt).not.toContain("## Subtask Breakdown Requested"); }); @@ -593,55 +597,56 @@ describe("buildSpecificationPrompt", () => { }); }); -describe("TRIAGE_SYSTEM_PROMPT", () => { +describe("canonical triage policy prompt", () => { it("does not include unconditional research guidance", () => { - expect(TRIAGE_SYSTEM_PROMPT).not.toContain("fn_research_run"); - expect(TRIAGE_SYSTEM_PROMPT).not.toContain("Keep research bounded"); + expect(TRIAGE_POLICY_PROMPT).not.toContain("fn_research_run"); + expect(TRIAGE_POLICY_PROMPT).not.toContain("Keep research bounded"); }); it("requires specs to keep lint, tests, build, and typecheck green even outside initial file scope", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Run lint check"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Run project typecheck if available"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Lint passing"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Typecheck passing (if available)"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Specs must instruct executors to fix lint failures and quality-gate failures directly"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Refuse necessary fixes just because they touch files outside the initial File Scope"); + expect(TRIAGE_POLICY_PROMPT).toContain("If keeping lint/tests/build/typecheck green requires edits outside the initial File Scope"); + expect(TRIAGE_POLICY_PROMPT).toContain("Run lint check"); + expect(TRIAGE_POLICY_PROMPT).toContain("Run project typecheck if available"); + expect(TRIAGE_POLICY_PROMPT).toContain("Lint passing"); + expect(TRIAGE_POLICY_PROMPT).toContain("Typecheck passing (if available)"); + expect(TRIAGE_POLICY_PROMPT).toContain("Specs must instruct executors to fix lint failures and quality-gate failures directly"); + expect(TRIAGE_POLICY_PROMPT).toContain("Refuse necessary fixes just because they touch files outside the initial File Scope"); }); it("includes task-artifact location guidance for forensic/reconciliation tasks", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("Task Artifact Location"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/"); - expect(TRIAGE_SYSTEM_PROMPT).toContain(".fusion/fusion.db"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("project root"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("forensic"); + expect(TRIAGE_POLICY_PROMPT).toContain("Task Artifact Location"); + expect(TRIAGE_POLICY_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/"); + expect(TRIAGE_POLICY_PROMPT).toContain(".fusion/fusion.db"); + expect(TRIAGE_POLICY_PROMPT).toContain("project root"); + expect(TRIAGE_POLICY_PROMPT).toContain("forensic"); }); }); -describe("TRIAGE_SYSTEM_PROMPT", () => { +describe("canonical triage policy prompt", () => { it("includes proactive M/L subtask breakdown guidance", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain( + expect(TRIAGE_POLICY_PROMPT).toContain( "## Proactive Subtask Breakdown for M/L Tasks", ); - expect(TRIAGE_SYSTEM_PROMPT).toContain( + expect(TRIAGE_POLICY_PROMPT).toContain( "Even when `breakIntoSubtasks` is not set to `true`", ); - expect(TRIAGE_SYSTEM_PROMPT).toContain( + expect(TRIAGE_POLICY_PROMPT).toContain( "Size S tasks should NOT be split", ); }); - it("includes explicit subtask breakdown thresholds", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("more than 10 implementation steps"); - expect(TRIAGE_SYSTEM_PROMPT).toContain( - "more than 5 different packages/modules", + it("includes explicit rendered subtask breakdown thresholds", () => { + expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("MORE THAN 7 implementation steps"); + expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain( + "MORE THAN 3 different packages/modules", ); + expect(TRIAGE_POLICY_PROMPT).toContain("MORE THAN {{triageSubtaskStepThreshold}} implementation steps"); }); it("biases toward keeping tasks whole and acknowledges coordination overhead", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("Default to keeping the task whole"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Coordination overhead"); - expect(TRIAGE_SYSTEM_PROMPT).toContain( + expect(TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole"); + expect(TRIAGE_POLICY_PROMPT).toContain("Coordination overhead"); + expect(TRIAGE_POLICY_PROMPT).toContain( "7-10 focused steps within a coherent scope is fine as one unit", ); }); @@ -655,8 +660,8 @@ describe("FN-5893 invariant regression wording", () => { it("requires invariant-level regression coverage in standard, fast, and core triage prompts", () => { for (const prompt of [ - TRIAGE_SYSTEM_PROMPT, - FAST_TRIAGE_SYSTEM_PROMPT, + TRIAGE_POLICY_PROMPT, + FAST_PLANNING_PROMPT, corePromptSource, ]) { expect(prompt).toContain("invariant across all known surfaces"); @@ -668,11 +673,13 @@ describe("FN-5893 invariant regression wording", () => { } }); - it("requires a Surface Enumeration section and blocking REVISE guidance for bug-fix specs", () => { - for (const prompt of [TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) { + it("requires a Surface Enumeration section and proves missing sections are blocking REVISEs for bug-fix specs", () => { + const missingSectionRevisePattern = + /For bug fixes and UI-affordance add\/remove tasks, the spec MUST include a `## Surface Enumeration` section\. During self-review via `fn_review_spec\(\)`, treat a missing section on a bug-fix or UI-affordance add\/remove spec as a blocking REVISE\./; + + for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) { expect(prompt).toContain("## Surface Enumeration"); - expect(prompt).toContain("spec MUST include a `## Surface Enumeration` section"); - expect(prompt).toContain("blocking REVISE"); + expect(prompt).toMatch(missingSectionRevisePattern); expect(prompt).toContain("docs/testing.md"); expect(prompt).toContain("duplicate / populated data states"); expect(prompt).toContain("shared hooks/components/modules/helpers"); @@ -692,7 +699,7 @@ describe("FN-5893 invariant regression wording", () => { }); it("requires implementation-step testing guidance to enumerate invariant surfaces in standard and fast prompts", () => { - for (const prompt of [TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) { + for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) { expect(prompt).toContain( "Run targeted tests for changed files, asserting the invariant across all known surfaces", ); @@ -702,8 +709,23 @@ describe("FN-5893 invariant regression wording", () => { } }); + it("defines the FN-6229 Symptom Verification contract in standard and fast prompts", () => { + for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) { + expect(prompt).toContain("## Symptom Verification"); + expect(prompt).toContain("Use the exact heading `## Symptom Verification`"); + expect(prompt).toContain("**Original symptom** — what the user/issue reported was broken"); + expect(prompt).toContain("**Exact reproduction** — the precise steps, inputs, fixture, or automated repro that triggered the failure"); + expect(prompt).toContain("**Assertion it is gone**"); + expect(prompt).toContain("final verification must reproduce that original failure condition and assert it no longer occurs"); + expect(prompt).toContain("Green build/tests alone are insufficient"); + expect(prompt).toContain("symptom-based acceptance"); + expect(prompt).toContain("bug-class/bug-fix tasks"); + expect(prompt).toContain("feature/docs/non-bug tasks do not need this section"); + } + }); + it("requires Surface Enumeration for UI-affordance add/remove tasks regardless of review-level analysis", () => { - for (const prompt of [TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) { + for (const prompt of [TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) { expect(prompt).toContain("bug-fix tasks and UI-affordance add/remove tasks"); expect(prompt).toContain("every component that renders the affordance"); expect(prompt).toContain("searching the codebase for the icon/class/testid"); @@ -711,7 +733,7 @@ describe("FN-5893 invariant regression wording", () => { expect(prompt).toContain("empty buttons"); } - expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Review Level"); + expect(FAST_PLANNING_PROMPT).not.toContain("## Review Level"); }); it("pins the canonical docs checklist heading", () => { @@ -726,19 +748,19 @@ describe("FN-5893 invariant regression wording", () => { }); describe("fast-mode triage", () => { - it("exports a lean FAST_TRIAGE_SYSTEM_PROMPT", () => { - expect(typeof FAST_TRIAGE_SYSTEM_PROMPT).toBe("string"); - expect(FAST_TRIAGE_SYSTEM_PROMPT.length).toBeGreaterThan(0); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("This task is running in **fast mode**"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_review_spec()"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Review Level"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Triage subtask breakdown"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("## Proactive Subtask Breakdown"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("Frontend UX Criteria"); + it("exports a lean FAST_PLANNING_PROMPT", () => { + expect(typeof FAST_PLANNING_PROMPT).toBe("string"); + expect(FAST_PLANNING_PROMPT.length).toBeGreaterThan(0); + expect(FAST_PLANNING_PROMPT).toContain("This task is running in **fast mode**"); + expect(FAST_PLANNING_PROMPT).toContain("fn_review_spec()"); + expect(FAST_PLANNING_PROMPT).not.toContain("## Review Level"); + expect(FAST_PLANNING_PROMPT).not.toContain("## Triage subtask breakdown"); + expect(FAST_PLANNING_PROMPT).not.toContain("## Proactive Subtask Breakdown"); + expect(FAST_PLANNING_PROMPT).not.toContain("Frontend UX Criteria"); }); it("documents workflow routing in standard and fast prompts", () => { - for (const prompt of [TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) { + for (const prompt of [RENDERED_TRIAGE_POLICY_PROMPT, FAST_PLANNING_PROMPT]) { expect(prompt).toContain("## Workflow Routing"); expect(prompt).toContain("fn_workflow_list"); expect(prompt).toContain("fn_workflow_select"); @@ -750,14 +772,14 @@ describe("fast-mode triage", () => { }); it("includes task-artifact location guidance for forensic/reconciliation tasks", () => { - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("Task Artifact Location"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain(".fusion/fusion.db"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("project root"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("forensic"); + expect(FAST_PLANNING_PROMPT).toContain("Task Artifact Location"); + expect(FAST_PLANNING_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/"); + expect(FAST_PLANNING_PROMPT).toContain(".fusion/fusion.db"); + expect(FAST_PLANNING_PROMPT).toContain("project root"); + expect(FAST_PLANNING_PROMPT).toContain("forensic"); }); - it("selects FAST_TRIAGE_SYSTEM_PROMPT for fast tasks", async () => { + it("selects FAST_PLANNING_PROMPT for fast tasks", async () => { const task = createTriageTask({ id: "FN-FAST-001", executionMode: "fast" }); const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }), @@ -937,7 +959,7 @@ describe("fast-mode triage", () => { expect(mockReviewStep).not.toHaveBeenCalled(); expect(verdictRef.current).toBe("APPROVE"); expect(result.content[0]?.text).toBe("APPROVE"); - expect(store.logEntry).toHaveBeenCalledWith(taskId, "Spec review: APPROVE (auto, fast mode)"); + expect(store.logEntry).toHaveBeenCalledWith(taskId, "Spec review: APPROVE (auto-approve spec)"); } finally { await cleanupTriageFixtureRoot(rootDir); } @@ -996,7 +1018,7 @@ describe("fast-mode triage", () => { expect(mockReviewStep).not.toHaveBeenCalled(); expect(store.moveTask).toHaveBeenCalledWith("FN-FAST-004", "todo"); - expect(store.logEntry).toHaveBeenCalledWith("FN-FAST-004", "Spec review: APPROVE (auto, fast mode)"); + expect(store.logEntry).toHaveBeenCalledWith("FN-FAST-004", "Spec review: APPROVE (auto-approve spec)"); } finally { await cleanupTriageFixtureRoot(rootDir); } @@ -1764,10 +1786,14 @@ describe("approved triage recovery", () => { }); it("includes decision-only noCommitsExpected heuristic instructions in system prompts", () => { - expect(TRIAGE_SYSTEM_PROMPT).toContain("**No commits expected:** true"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Decide whether FN-XYZ needs a fix"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("Investigate FN-XYZ and fix if needed"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("**No commits expected:** true"); + expect(TRIAGE_POLICY_PROMPT).toContain("**No commits expected:** true"); + expect(TRIAGE_POLICY_PROMPT).toContain("Decide whether FN-XYZ needs a fix"); + expect(TRIAGE_POLICY_PROMPT).toContain("Assign ready implementation task to active owner, or record no-route state"); + expect(TRIAGE_POLICY_PROMPT).toContain("operational routing/coordination"); + expect(TRIAGE_POLICY_PROMPT).toContain("Investigate FN-XYZ and fix if needed"); + expect(TRIAGE_POLICY_PROMPT).toContain("Investigate and fix routing if needed"); + expect(FAST_PLANNING_PROMPT).toContain("**No commits expected:** true"); + expect(FAST_PLANNING_PROMPT).toContain("operational routing/coordination"); }); it("preserves imported GitHub issue titles during planning recovery", async () => { @@ -3067,7 +3093,7 @@ describe("taskCreate tool model inheritance", () => { expect(capturedArgs.systemPrompt).toContain("agent ID: agent-007"); }); - it("prefers assigned-agent runtime model and falls back when incomplete", async () => { + it("prefers planning settings model ahead of assigned-agent runtime model", async () => { const completeRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-1", assignedAgentId: "agent-model-complete" }); const incompleteRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-2", assignedAgentId: "agent-model-incomplete" }); @@ -3135,7 +3161,7 @@ describe("taskCreate tool model inheritance", () => { const completeCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-1"); const fallbackCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-2"); - expect(completeCall).toMatchObject({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }); + expect(completeCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" }); expect(fallbackCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" }); }); @@ -4394,33 +4420,33 @@ describe("FN-4774 regression: triage duplicate detection over done/archived task }); // Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md - it("TRIAGE_SYSTEM_PROMPT guides agents to search done/archived before creating", () => { + it("canonical triage policy prompt guides agents to search done/archived before creating", () => { // Standard prompt mentions fn_task_search in duplicate-check guidance - expect(TRIAGE_SYSTEM_PROMPT).toContain("fn_task_search"); + expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); // The tool bullet list explicitly states it covers done and archived - expect(TRIAGE_SYSTEM_PROMPT).toContain("including done and archived tasks"); + expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks"); // Duplicate-check section co-locates fn_task_search with done/archived references - expect(TRIAGE_SYSTEM_PROMPT).toContain("done"); - expect(TRIAGE_SYSTEM_PROMPT).toContain("archived"); + expect(TRIAGE_POLICY_PROMPT).toContain("done"); + expect(TRIAGE_POLICY_PROMPT).toContain("archived"); // Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived expect( /Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test( - TRIAGE_SYSTEM_PROMPT, + TRIAGE_POLICY_PROMPT, ), ).toBe(true); }); // Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md - it("FAST_TRIAGE_SYSTEM_PROMPT guides agents to search done/archived before creating", () => { + it("FAST_PLANNING_PROMPT guides agents to search done/archived before creating", () => { // Fast prompt mentions fn_task_search - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("fn_task_search"); + expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); // Duplicate-check section references done and archived - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("done"); - expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("archived"); + expect(FAST_PLANNING_PROMPT).toContain("done"); + expect(FAST_PLANNING_PROMPT).toContain("archived"); // Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived expect( /Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test( - FAST_TRIAGE_SYSTEM_PROMPT, + FAST_PLANNING_PROMPT, ), ).toBe(true); }); diff --git a/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts b/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts index 169e515163..ea4f76eacb 100644 --- a/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts @@ -391,9 +391,18 @@ describe("WorkflowGraphExecutor traversal", () => { expect(order).toEqual(["b", "c"]); }); - it("builtin coding workflow ir exposes expected lifecycle nodes", () => { + it("builtin coding workflow ir exposes expected lifecycle and merge-policy nodes", () => { expect(BUILTIN_CODING_WORKFLOW_IR.nodes.map((node) => node.id)).toEqual( - expect.arrayContaining(["start", "execute", "review", "merge", "end"]), + expect.arrayContaining([ + "start", + "execute", + "review", + "merge-gate", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "end", + ]), ); }); diff --git a/packages/engine/src/__tests__/workflow-graph-merge-region-collapse.test.ts b/packages/engine/src/__tests__/workflow-graph-merge-region-collapse.test.ts new file mode 100644 index 0000000000..6b634fd9b9 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-graph-merge-region-collapse.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskDetail, WorkflowIr, WorkflowIrNodeKind } from "@fusion/core"; +import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core"; + +import { WorkflowGraphExecutor } from "../workflow-graph-executor.js"; +import type { WorkflowLegacySeams } from "../workflow-node-handlers.js"; + +const task = { id: "FN-6294" } as TaskDetail; +const settings = { experimentalFeatures: { workflowGraphExecutor: true } }; + +const mergeRegionEntries: Array<{ id: string; kind: WorkflowIrNodeKind }> = [ + { id: "merge-gate", kind: "merge-gate" }, + { id: "merge-attempt", kind: "merge-attempt" }, + { id: "merge-manual-hold", kind: "manual-merge-hold" }, + { id: "merge-retry", kind: "retry-backoff" }, + { id: "recovery-router", kind: "recovery-router" }, + { id: "branch-group-member-integration", kind: "branch-group-member-integration" }, + { id: "branch-group-promotion", kind: "branch-group-promotion" }, +]; +const rawMergeRegionNodeIds = mergeRegionEntries.map((entry) => entry.id); + +function createSeams(overrides: Partial<WorkflowLegacySeams> = {}): WorkflowLegacySeams { + return { + planning: async () => ({ outcome: "success" }), + execute: async () => ({ outcome: "success" }), + workflowStep: async () => ({ outcome: "success" }), + review: async () => ({ outcome: "success" }), + merge: async () => ({ outcome: "success" }), + schedule: async () => ({ outcome: "success" }), + ...overrides, + }; +} + +function expectNoRawMergeRegionVisits(visitedNodeIds: string[]) { + for (const rawNodeId of rawMergeRegionNodeIds) { + expect(visitedNodeIds).not.toContain(rawNodeId); + } +} + +function irEnteringMergeRegionAt(entryId: string): WorkflowIr { + return { + ...BUILTIN_CODING_WORKFLOW_IR, + edges: BUILTIN_CODING_WORKFLOW_IR.edges.map((edge) => + edge.from === "review" && edge.to === "merge-gate" && edge.condition === "success" + ? { ...edge, to: entryId } + : edge, + ), + }; +} + +describe("WorkflowGraphExecutor merge-region collapse", () => { + it("collapses the built-in merge-policy region to one legacy merge seam", async () => { + const calls: string[] = []; + const merge = vi.fn(async () => { + calls.push("merge"); + return { outcome: "success" as const }; + }); + const executor = new WorkflowGraphExecutor({ seams: createSeams({ merge }) }); + + const result = await executor.run(task, settings, BUILTIN_CODING_WORKFLOW_IR); + + expect(result.outcome).toBe("success"); + expect(merge).toHaveBeenCalledOnce(); + expect(calls).toEqual(["merge"]); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]); + expect(result.context["node:merge:outcome"]).toBe("success"); + expectNoRawMergeRegionVisits(result.visitedNodeIds); + }); + + it("routes legacy merge seam failures to a failure terminal without visiting raw merge primitives", async () => { + const merge = vi.fn(async () => ({ outcome: "failure" as const, value: "FileScopeViolationError" })); + const executor = new WorkflowGraphExecutor({ seams: createSeams({ merge }) }); + + const result = await executor.run(task, settings, BUILTIN_CODING_WORKFLOW_IR); + + expect(result.outcome).toBe("failure"); + expect(merge).toHaveBeenCalledOnce(); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]); + expect(result.context["node:merge:outcome"]).toBe("failure"); + expect(result.context["node:merge:value"]).toBe("FileScopeViolationError"); + expectNoRawMergeRegionVisits(result.visitedNodeIds); + }); + + it("does not collapse to merge when review fails before the merge-policy region", async () => { + const merge = vi.fn(async () => ({ outcome: "success" as const })); + const executor = new WorkflowGraphExecutor({ + seams: createSeams({ + review: async () => ({ outcome: "failure", value: "manual-merge-required" }), + merge, + }), + }); + + const result = await executor.run(task, settings, BUILTIN_CODING_WORKFLOW_IR); + + expect(result.outcome).toBe("failure"); + expect(merge).not.toHaveBeenCalled(); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]); + expect(result.visitedNodeIds).not.toContain("merge"); + expectNoRawMergeRegionVisits(result.visitedNodeIds); + }); + + it.each(mergeRegionEntries)( + "treats $kind as a merge-region boundary when entered directly", + async ({ id }) => { + const merge = vi.fn(async () => ({ outcome: "success" as const })); + const executor = new WorkflowGraphExecutor({ seams: createSeams({ merge }) }); + + const result = await executor.run(task, settings, irEnteringMergeRegionAt(id)); + + expect(result.outcome).toBe("success"); + expect(merge).toHaveBeenCalledOnce(); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]); + expectNoRawMergeRegionVisits(result.visitedNodeIds); + }, + ); +}); diff --git a/packages/engine/src/__tests__/workflow-policy-ownership-map.test.ts b/packages/engine/src/__tests__/workflow-policy-ownership-map.test.ts new file mode 100644 index 0000000000..75477ec0e8 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-policy-ownership-map.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const DOC_PATH = resolve(__dirname, "../../../../docs/workflow-policy-ownership-map.md"); + +const REQUIRED_SOURCE_FILES = [ + "packages/engine/src/project-engine.ts", + "packages/engine/src/scheduler.ts", + "packages/engine/src/self-healing.ts", + "packages/engine/src/merger.ts", + "packages/engine/src/merger-ai.ts", + "packages/engine/src/merger-integration-worktree.ts", + "packages/engine/src/group-merge-coordinator.ts", + "packages/engine/src/transient-merge-error-classifier.ts", + "packages/engine/src/retry-with-backoff.ts", + "packages/engine/src/rate-limit-retry.ts", + "packages/core/src/store.ts", + "packages/core/src/task-merge.ts", + "packages/core/src/retry-summary.ts", + "packages/core/src/manual-retry-reset.ts", + "packages/core/src/builtin-coding-workflow-ir.ts", + "packages/core/src/builtin-stepwise-coding-workflow-ir.ts", + "packages/core/src/builtin-pr-workflow-ir.ts", + "packages/dashboard/app/components/TaskCard.tsx", +] as const; + +const REQUIRED_POLICY_SURFACES = [ + "Auto-merge queue enqueue and dequeue", + "Merge checkout, integration, conflict resolution, squash, finalize", + "Branch-group member integration and group promotion", + "Dependency satisfaction treats `in-review` as satisfied", + "Active scope leases include unmerged `in-review` worktrees", + "Manual retry reset", + "Recover mergeable in-review tasks", + "Completion handoff limbo recovery", + "Transient merge failure recovery", + "Already-landed and no-op finalization", + "Built-in default workflow definitions", + "Dashboard task-card merge/retry/stall badges", +] as const; + +describe("workflow policy ownership map", () => { + const doc = readFileSync(DOC_PATH, "utf-8"); + + it("classifies every required policy surface from the workflow-owned merge plan", () => { + for (const surface of REQUIRED_POLICY_SURFACES) { + expect(doc, `missing ownership surface: ${surface}`).toContain(surface); + } + }); + + it("anchors the map to the production source files that own merge, retry, scheduling, and projection today", () => { + for (const file of REQUIRED_SOURCE_FILES) { + expect(doc, `missing source file: ${file}`).toContain(file); + } + }); + + it("records the migration dispositions needed for later deletion gates", () => { + for (const disposition of [ + "`substrate`", + "`workflow-policy`", + "`capability`", + "`compat-projection`", + "`delete-after-cutover`", + ]) { + expect(doc).toContain(disposition); + } + + expect(doc).toContain("## Deletion Gates"); + expect(doc).toContain("No production caller may start checkout, branch integration, squash, or finalize"); + expect(doc).toContain("Task-level retry and merge fields are compatibility summaries"); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts index 789023f47a..42cc9aa1c3 100644 --- a/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts +++ b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts @@ -8,9 +8,11 @@ import { workflowExtensionRegistryId, type Task, type TaskDetail, + type WorkflowWorkItem, type WorkflowIr, } from "@fusion/core"; import { TaskExecutor } from "../executor.js"; +import { claimDueWorkflowWorkItem } from "../workflow-work-scheduler.js"; describe("workflow work-engine dispatch", () => { afterEach(() => { @@ -82,3 +84,71 @@ describe("workflow work-engine dispatch", () => { expect(store.updateTask).not.toHaveBeenCalled(); }); }); + +describe("workflow work scheduler claims", () => { + function workItem(input: Partial<WorkflowWorkItem> & Pick<WorkflowWorkItem, "id" | "taskId" | "nodeId">): WorkflowWorkItem { + return { + runId: "run-1", + kind: "task", + state: "runnable", + attempt: 0, + retryAfter: null, + leaseOwner: null, + leaseExpiresAt: null, + lastError: null, + blockedReason: null, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + ...input, + }; + } + + it("claims the first due workflow work item without reading task columns", () => { + const item = workItem({ id: "work-1", taskId: "FN-1", nodeId: "node-a" }); + const store = { + listDueWorkflowWorkItems: vi.fn(() => [item]), + acquireWorkflowWorkItemLease: vi.fn(() => ({ ...item, state: "running", leaseOwner: "scheduler-a" })), + }; + + const dispatch = claimDueWorkflowWorkItem(store, { + now: "2026-06-09T00:00:00.000Z", + leaseOwner: "scheduler-a", + leaseDurationMs: 60_000, + kinds: ["task"], + }); + + expect(store.listDueWorkflowWorkItems).toHaveBeenCalledWith({ + now: "2026-06-09T00:00:00.000Z", + limit: 25, + kinds: ["task"], + }); + expect(store.acquireWorkflowWorkItemLease).toHaveBeenCalledWith("work-1", "scheduler-a", { + now: "2026-06-09T00:00:00.000Z", + leaseDurationMs: 60_000, + }); + expect(dispatch).toMatchObject({ + runId: "run-1", + taskId: "FN-1", + nodeId: "node-a", + workItem: { state: "running", leaseOwner: "scheduler-a" }, + }); + }); + + it("skips contenders whose lease was already acquired", () => { + const first = workItem({ id: "work-1", taskId: "FN-1", nodeId: "node-a" }); + const second = workItem({ id: "work-2", taskId: "FN-2", nodeId: "node-b" }); + const store = { + listDueWorkflowWorkItems: vi.fn(() => [first, second]), + acquireWorkflowWorkItemLease: vi.fn((id: string) => (id === "work-2" ? { ...second, state: "running" } : null)), + }; + + const dispatch = claimDueWorkflowWorkItem(store, { + now: "2026-06-09T00:00:00.000Z", + leaseOwner: "scheduler-a", + leaseDurationMs: 60_000, + }); + + expect(dispatch?.workItem.id).toBe("work-2"); + expect(store.acquireWorkflowWorkItemLease).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/engine/src/__tests__/worktree-backend.test.ts b/packages/engine/src/__tests__/worktree-backend.test.ts index 3ff2579ac3..b1cc8406a3 100644 --- a/packages/engine/src/__tests__/worktree-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-backend.test.ts @@ -10,7 +10,19 @@ import { } from "../worktree-backend.js"; import { activeSessionRegistry } from "../active-session-registry.js"; -const { execMock, accessMock, rmMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock, parseStaleRegistrationPathMock, recoverStaleRegistrationMock, installGuardMock } = vi.hoisted(() => { +const { + execMock, + accessMock, + rmMock, + existsSyncMock, + parseIndexLockPathMock, + classifyStaleLockMock, + tryRemoveStaleLockMock, + parseStaleRegistrationPathMock, + recoverStaleRegistrationMock, + installGuardMock, + pruneWorktreeAdminEntriesMock, +} = vi.hoisted(() => { const mock = vi.fn(); (mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock; return { @@ -24,6 +36,7 @@ const { execMock, accessMock, rmMock, existsSyncMock, parseIndexLockPathMock, cl parseStaleRegistrationPathMock: vi.fn(), recoverStaleRegistrationMock: vi.fn(), installGuardMock: vi.fn(), + pruneWorktreeAdminEntriesMock: vi.fn(), }; }); @@ -58,6 +71,9 @@ vi.mock("../worktree-stale-registration.js", () => ({ parseStaleRegistrationPath: parseStaleRegistrationPathMock, recoverStaleRegistration: recoverStaleRegistrationMock, })); +vi.mock("../worktree-prune.js", () => ({ + pruneWorktreeAdminEntries: pruneWorktreeAdminEntriesMock, +})); beforeEach(() => { execMock.mockReset(); @@ -72,6 +88,8 @@ beforeEach(() => { tryRemoveStaleLockMock.mockReset(); installGuardMock.mockReset(); installGuardMock.mockResolvedValue(undefined); + pruneWorktreeAdminEntriesMock.mockReset(); + pruneWorktreeAdminEntriesMock.mockResolvedValue(undefined); parseIndexLockPathMock.mockReturnValue(null); parseStaleRegistrationPathMock.mockReset(); parseStaleRegistrationPathMock.mockReturnValue(null); @@ -150,6 +168,99 @@ describe("NativeWorktreeBackend", () => { 'git worktree remove --force "/repo/.worktrees/fn-1"', expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }), ); + expect(rmMock).not.toHaveBeenCalled(); + expect(pruneWorktreeAdminEntriesMock).not.toHaveBeenCalled(); + }); + + it("falls back to filesystem removal and prunes admin entries when native remove leaves a non-empty directory", async () => { + const audit = { git: vi.fn().mockResolvedValue(undefined) }; + execMock.mockRejectedValueOnce({ + message: "Command failed: git worktree remove --force /repo/.worktrees/fn-1", + stderr: "error: failed to delete '/repo/.worktrees/fn-1': Directory not empty", + }); + + await new NativeWorktreeBackend({ audit }).remove({ + rootDir: "/repo", + worktreePath: "/repo/.worktrees/fn-1", + }); + + expect(rmMock).toHaveBeenCalledWith("/repo/.worktrees/fn-1", { recursive: true, force: true }); + expect(pruneWorktreeAdminEntriesMock).toHaveBeenCalledWith({ + rootDir: "/repo", + auditor: audit, + reason: "remove-non-empty-fallback", + target: "/repo/.worktrees/fn-1", + logger: undefined, + }); + expect(audit.git).toHaveBeenCalledWith({ + type: "worktree:remove-fallback", + target: "/repo/.worktrees/fn-1", + metadata: expect.objectContaining({ fallback: "filesystem-non-empty", error: expect.stringContaining("Directory not empty") }), + }); + }); + + it("falls back for modified or untracked file native remove failures", async () => { + execMock.mockRejectedValueOnce({ + message: "fatal: '/repo/.worktrees/fn-1' contains modified or untracked files, use --force to delete it", + stderr: "", + }); + + await new NativeWorktreeBackend().remove({ + rootDir: "/repo", + worktreePath: "/repo/.worktrees/fn-1", + }); + + expect(rmMock).toHaveBeenCalledWith("/repo/.worktrees/fn-1", { recursive: true, force: true }); + expect(pruneWorktreeAdminEntriesMock).toHaveBeenCalledWith( + expect.objectContaining({ rootDir: "/repo", reason: "remove-non-empty-fallback", target: "/repo/.worktrees/fn-1" }), + ); + }); + + it("falls back for failed-to-delete native remove failures without a directory-not-empty suffix", async () => { + execMock.mockRejectedValueOnce({ + message: "Command failed: git worktree remove --force /repo/.worktrees/fn-1", + stderr: "error: failed to delete '/repo/.worktrees/fn-1'", + }); + + await new NativeWorktreeBackend().remove({ + rootDir: "/repo", + worktreePath: "/repo/.worktrees/fn-1", + }); + + expect(rmMock).toHaveBeenCalledWith("/repo/.worktrees/fn-1", { recursive: true, force: true }); + expect(pruneWorktreeAdminEntriesMock).toHaveBeenCalledWith( + expect.objectContaining({ rootDir: "/repo", reason: "remove-non-empty-fallback", target: "/repo/.worktrees/fn-1" }), + ); + }); + + it("rethrows non-recoverable native remove failures without filesystem fallback", async () => { + const error = { message: "fatal: not a git repository", stderr: "fatal: not a git repository" }; + execMock.mockRejectedValueOnce(error); + + await expect( + new NativeWorktreeBackend().remove({ + rootDir: "/repo", + worktreePath: "/repo/.worktrees/fn-1", + }), + ).rejects.toBe(error); + + expect(rmMock).not.toHaveBeenCalled(); + expect(pruneWorktreeAdminEntriesMock).not.toHaveBeenCalled(); + }); + + it("rethrows filesystem removal failure after recoverable native remove failure", async () => { + const rmError = new Error("EACCES: permission denied"); + execMock.mockRejectedValueOnce({ stderr: "error: failed to delete '/repo/.worktrees/fn-1': Directory not empty" }); + rmMock.mockRejectedValueOnce(rmError as never); + + await expect( + new NativeWorktreeBackend().remove({ + rootDir: "/repo", + worktreePath: "/repo/.worktrees/fn-1", + }), + ).rejects.toBe(rmError); + + expect(pruneWorktreeAdminEntriesMock).not.toHaveBeenCalled(); }); it("syncs by fetching then rebasing", async () => { @@ -800,6 +911,166 @@ describe("removeWorktree", () => { expect(audit.git).toHaveBeenCalledWith({ type: "worktree:remove", target: "/repo/.worktrees/fn-1" }); }); + it("classifies FN-343 nonstandard temp merge worktree remove failures as harmless when porcelain is absent after prune", async () => { + const tempPath = "/var/folders/demo/T/fusion-ai-merge-fn-327-A5uY3j"; + const validationError = { + message: `Command failed: git worktree remove --force ${tempPath}`, + stderr: `fatal: validation failed, cannot remove working tree: '${tempPath}/.git' is not a .git file, error code 2`, + status: 2, + }; + execMock + .mockRejectedValueOnce(validationError) + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ stdout: "worktree /repo\nbranch refs/heads/main\n", stderr: "" }); + const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; + + // A real-git fixture for this exact macOS temp shape is git-version sensitive: + // some versions prune the malformed admin entry before emitting the validation + // string. Keep the classifier deterministic by simulating the exact FN-327 + // command stderr, then assert the porcelain proof that no registered worktree + // remains for the temp path. + await expect( + removeWorktree({ + rootDir: "/repo", + worktreePath: tempPath, + settings: {}, + audit, + taskId: "FN-327", + reason: RemovalReason.MergerCleanup, + }), + ).resolves.toMatchObject({ + removed: false, + harmless: true, + classification: "not-registered-after-prune", + message: expect.stringContaining("no registered worktree remains after prune"), + }); + + expect(execMock).toHaveBeenNthCalledWith( + 2, + "git worktree prune", + expect.objectContaining({ cwd: "/repo" }), + ); + expect(execMock).toHaveBeenNthCalledWith( + 3, + "git worktree list --porcelain", + expect.objectContaining({ cwd: "/repo" }), + ); + expect(audit.git).toHaveBeenCalledWith( + expect.objectContaining({ + type: "worktree:remove-classified-harmless", + target: tempPath, + metadata: expect.objectContaining({ + reason: RemovalReason.MergerCleanup, + classification: "not-registered-after-prune", + registeredAfterPrune: false, + stderrPreview: expect.stringContaining("is not a .git file"), + }), + }), + ); + }); + + it("does not downgrade non-temp merger cleanup failures even when porcelain would be absent", async () => { + const worktreePath = "/repo/.worktrees/fn-327"; + const validationError = { + message: `Command failed: git worktree remove --force ${worktreePath}`, + stderr: `fatal: validation failed, cannot remove working tree: '${worktreePath}/.git' is not a .git file, error code 2`, + status: 2, + }; + execMock.mockRejectedValueOnce(validationError); + + await expect( + removeWorktree({ + rootDir: "/repo", + worktreePath, + settings: {}, + taskId: "FN-327", + reason: RemovalReason.MergerCleanup, + }), + ).rejects.toBe(validationError); + + expect(execMock).toHaveBeenCalledTimes(1); + }); + + it("keeps FN-343 remove failures visible when the temp path remains registered after prune", async () => { + const tempPath = "/var/folders/demo/T/fusion-ai-merge-fn-327-A5uY3j"; + const validationError = { + message: `Command failed: git worktree remove --force ${tempPath}`, + stderr: `fatal: validation failed, cannot remove working tree: '${tempPath}/.git' is not a .git file, error code 2`, + status: 2, + }; + execMock + .mockRejectedValueOnce(validationError) + .mockResolvedValueOnce({ stdout: "", stderr: "" }) + .mockResolvedValueOnce({ + stdout: `worktree /repo\nbranch refs/heads/main\n\nworktree ${tempPath}\nbranch refs/heads/fusion/fn-327\n`, + stderr: "", + }); + const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; + + await expect( + removeWorktree({ + rootDir: "/repo", + worktreePath: tempPath, + settings: {}, + audit, + taskId: "FN-327", + reason: RemovalReason.MergerCleanup, + }), + ).rejects.toMatchObject({ stderr: expect.stringContaining("is not a .git file") }); + + expect(execMock).toHaveBeenNthCalledWith(2, "git worktree prune", expect.objectContaining({ cwd: "/repo" })); + expect(execMock).toHaveBeenNthCalledWith(3, "git worktree list --porcelain", expect.objectContaining({ cwd: "/repo" })); + expect(audit.git).toHaveBeenCalledWith( + expect.objectContaining({ + type: "worktree:remove-leaked-registered-worktree", + target: tempPath, + metadata: expect.objectContaining({ + reason: RemovalReason.MergerCleanup, + registeredAfterPrune: true, + }), + }), + ); + }); + + + it("preserves the original remove failure when classification probes fail", async () => { + const tempPath = "/var/folders/demo/T/fusion-ai-merge-fn-327-A5uY3j"; + const validationError = { + message: `Command failed: git worktree remove --force ${tempPath}`, + stderr: `fatal: validation failed, cannot remove working tree: '${tempPath}/.git' is not a .git file, error code 2`, + status: 2, + }; + const probeError = new Error("git worktree prune failed"); + execMock + .mockRejectedValueOnce(validationError) + .mockRejectedValueOnce(probeError); + const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; + + await expect( + removeWorktree({ + rootDir: "/repo", + worktreePath: tempPath, + settings: {}, + audit, + taskId: "FN-327", + reason: RemovalReason.MergerCleanup, + }), + ).rejects.toBe(validationError); + + expect(execMock).toHaveBeenNthCalledWith(2, "git worktree prune", expect.objectContaining({ cwd: "/repo" })); + expect(audit.git).toHaveBeenCalledWith( + expect.objectContaining({ + type: "worktree:remove-classification-probe-failed", + target: tempPath, + metadata: expect.objectContaining({ + reason: RemovalReason.MergerCleanup, + stderrPreview: expect.stringContaining("is not a .git file"), + probeError: expect.stringContaining("git worktree prune failed"), + }), + }), + ); + }); + it("uses worktrunk remove and emits worktree:worktrunk-remove", async () => { execMock.mockResolvedValue({ stdout: "", stderr: "" }); const audit = { git: vi.fn().mockResolvedValue(undefined) } as any; diff --git a/packages/engine/src/__tests__/worktree-paths.test.ts b/packages/engine/src/__tests__/worktree-paths.test.ts index 369cef64a8..d927e3d324 100644 --- a/packages/engine/src/__tests__/worktree-paths.test.ts +++ b/packages/engine/src/__tests__/worktree-paths.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { + AI_MERGE_DIRNAME, + isAiMergeContainerDir, isInsideConfiguredWorktreesDir, + resolveAiMergeRootPath, resolveTaskWorktreePath, resolveTaskWorktreePathForBackend, resolveWorktreesDir, @@ -41,6 +44,27 @@ describe("worktree-paths", () => { ); }); + it("builds the AI-merge root under the default worktrees dir", () => { + expect(resolveAiMergeRootPath(rootDir, undefined)).toBe(join(rootDir, ".worktrees", AI_MERGE_DIRNAME)); + }); + + it("builds the AI-merge root under an absolute custom worktrees dir", () => { + expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "/tmp/ext-worktrees" } as any)).toBe(join("/tmp/ext-worktrees", AI_MERGE_DIRNAME)); + }); + + it("builds the AI-merge root under expanded {repo} and ~ worktrees dirs", () => { + expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "../{repo}.worktrees" } as any)).toBe( + resolve(rootDir, "../repo-name.worktrees", AI_MERGE_DIRNAME), + ); + expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "~/.fn/{repo}/trees" } as any)).toBe(join(homedir(), ".fn/repo-name/trees", AI_MERGE_DIRNAME)); + }); + + it("identifies only the dedicated AI-merge container name", () => { + expect(isAiMergeContainerDir(AI_MERGE_DIRNAME)).toBe(true); + expect(isAiMergeContainerDir("fusion-ai-merge-fn-1-abc")).toBe(false); + expect(isAiMergeContainerDir(".ai-merge-child")).toBe(false); + }); + it("detects paths inside and outside configured dir", () => { const dir = resolveWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any); expect(isInsideConfiguredWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any, join(dir, "fn-1"))).toBe(true); diff --git a/packages/engine/src/__tests__/worktree-pool.test.ts b/packages/engine/src/__tests__/worktree-pool.test.ts index 72c0dfad32..aae3bc59ba 100644 --- a/packages/engine/src/__tests__/worktree-pool.test.ts +++ b/packages/engine/src/__tests__/worktree-pool.test.ts @@ -875,6 +875,20 @@ describe("scanIdleWorktrees", () => { ); }); + it("excludes the .ai-merge container even when git lists clean-room children", async () => { + mockedReaddirSync.mockReturnValue([ + makeDirEntry(".ai-merge"), + makeDirEntry("registered-wt"), + ] as any); + mockRegisteredWorktrees("/root", [".ai-merge/fusion-ai-merge-fn-1-active", "registered-wt"]); + + const store = createMockStore([]); + + const idle = await scanIdleWorktrees("/root", store); + expect(idle).toEqual(["/root/.worktrees/registered-wt"]); + expect(idle).not.toContain("/root/.worktrees/.ai-merge"); + }); + it("does not return unregistered directories for pool rehydration", async () => { mockedReaddirSync.mockReturnValue([ makeDirEntry("registered-wt"), @@ -1033,6 +1047,25 @@ describe("cleanupOrphanedWorktrees", () => { expect(removeCalls).toHaveLength(0); }); + it("excludes the .ai-merge container while still removing genuine unregistered orphans", async () => { + mockedReaddirSync.mockReturnValue([ + makeDirEntry(".ai-merge"), + makeDirEntry("broken-wt"), + ] as any); + mockRegisteredWorktrees("/root", []); + + const store = createMockStore([]); + + const cleaned = await cleanupOrphanedWorktrees("/root", store); + + expect(cleaned).toBe(1); + expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", { + recursive: true, + force: true, + }); + expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything()); + }); + it("removes unregistered directories even when stale active task metadata references them", async () => { mockedReaddirSync.mockReturnValue([ makeDirEntry("broken-wt"), @@ -1056,3 +1089,25 @@ describe("cleanupOrphanedWorktrees", () => { }); }); +describe("reapOrphanWorktrees", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRegisteredWorktrees("/root", []); + mockedExistsSync.mockImplementation((path) => String(path) === "/root/.worktrees"); + mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any); + }); + + it("excludes the .ai-merge container while removing half-initialized task worktrees", async () => { + mockedReaddirSync.mockReturnValue([ + makeDirEntry(".ai-merge"), + makeDirEntry("half-built"), + ] as any); + + const removed = await reapOrphanWorktrees("/root"); + + expect(removed).toBe(1); + expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/half-built", { recursive: true, force: true }); + expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything()); + }); +}); + diff --git a/packages/engine/src/active-session-registry.ts b/packages/engine/src/active-session-registry.ts index 045afe3e8c..ec28db0158 100644 --- a/packages/engine/src/active-session-registry.ts +++ b/packages/engine/src/active-session-registry.ts @@ -1,4 +1,4 @@ -export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel"; +export type ActiveSessionKind = "executor" | "step-session" | "workflow-step" | "step-session-parallel" | "ai-merge"; export interface ActiveSessionRegistration { taskId: string; diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 69657b35c2..be743c3da2 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -158,10 +158,9 @@ export interface PauseAgentOptions { pauseReason?: string; stopActiveRun?: boolean; /** - * When true (default), assigned tasks are also paused with `pausedByAgentId` - * set to this agent. Set to false for internal/recovery flows that should - * not visibly pause user-facing tasks (e.g. heartbeat-unresponsive recovery, - * which immediately calls resumeAgent afterward). + * Deprecated/ignored for pause: pausing or sleeping an agent never pauses + * assigned tasks. Tasks remain in their current column so the scheduler can + * re-dispatch them. */ cascadeToTasks?: boolean; } @@ -170,7 +169,11 @@ export interface ResumeAgentOptions { triggerDetail?: string; triggerSource?: string; clearPauseReason?: boolean; - /** When true (default), unpauses tasks paused by this agent. */ + /** + * When true, unpauses tasks paused by this agent. Defaults to false; this is + * legacy cleanup only and correctness must not depend on cascade-unpause. + * User-paused tasks are never cascade-unpaused. + */ cascadeToTasks?: boolean; } @@ -269,6 +272,39 @@ function resolveAutoClaimCandidatesInPromptLimit(agent: Agent, settings?: Settin return Math.max(0, Math.min(10, integer)); } +function resolveEngineerBacklogAutoClaim(agent: Agent, settings?: Settings): boolean { + const runtimeConfig = (agent.runtimeConfig ?? {}) as AgentHeartbeatConfig; + const perAgent = runtimeConfig.engineerBacklogAutoClaim; + const projectValue = settings?.engineerBacklogAutoClaim; + return typeof perAgent === "boolean" ? perAgent : (typeof projectValue === "boolean" ? projectValue : false); +} + +function formatBacklogAutoClaimRoleStatus(agent: Agent, allowEngineer: boolean): string { + if (agent.role === "engineer") { + return allowEngineer + ? "enabled" + : "enabled (no role-compatible candidates; engineerBacklogAutoClaim disabled)"; + } + return allowEngineer + ? "enabled (no role-compatible candidates; executor or opted-in engineer role required)" + : "enabled (no role-compatible candidates; executor role required)"; +} + +function formatBacklogAutoClaimRoleGuidance(agent: Agent, allowEngineer: boolean, candidateCount: number): string[] { + if (agent.role === "engineer" && !allowEngineer) { + return [ + `- Snapshot found ${candidateCount} eligible Todo task(s), but this engineer-role agent is not opted into backlog auto-claim.`, + "- Backlog auto-claim is executor-only by default; set project settings.engineerBacklogAutoClaim or per-agent runtimeConfig.engineerBacklogAutoClaim to true to opt engineer agents in.", + ]; + } + return [ + `- Snapshot found ${candidateCount} eligible Todo task(s), but this agent role cannot auto-claim implementation work.`, + allowEngineer + ? "- Backlog auto-claim allows executor-role agents and engineer-role agents with engineerBacklogAutoClaim enabled; use delegation or create coordination follow-up instead of assuming the board is empty." + : "- Backlog auto-claim is restricted to executor-role agents by default; use delegation or create coordination follow-up instead of assuming the board is empty.", + ]; +} + type RelevanceScorableTask = { title?: string | null; description: string }; const agentSoulWordsCache = new Map<string, { soulSnapshot: string; words: readonly string[] }>(); @@ -1611,7 +1647,7 @@ export class HeartbeatMonitor { } async pauseAgent(agentId: string, options: PauseAgentOptions = {}): Promise<Agent> { - const { pauseReason, stopActiveRun = false, cascadeToTasks = true } = options; + const { pauseReason, stopActiveRun = false } = options; if (stopActiveRun) { try { @@ -1635,19 +1671,6 @@ export class HeartbeatMonitor { updated = await this.store.updateAgent(agentId, { pauseReason }); } - if (this.taskStore && cascadeToTasks) { - const assignedTasks = await this.taskStore.getTasksByAssignedAgent(agentId, { excludeArchived: true }); - const toPause = assignedTasks.filter((task) => task.paused !== true); - const results = await Promise.allSettled( - toPause.map((task) => this.taskStore!.pauseTask(task.id, true, undefined, { pausedByAgentId: agentId })), - ); - results.forEach((result, index) => { - if (result.status === "rejected") { - heartbeatLog.warn(`pauseAgent(${agentId}) failed to pause assigned task ${toPause[index]?.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`); - } - }); - } - return updated; } @@ -1656,7 +1679,7 @@ export class HeartbeatMonitor { triggerDetail = "Triggered from state resume", triggerSource = "state-resume", clearPauseReason = true, - cascadeToTasks = true, + cascadeToTasks = false, } = options; const current = await this.store.getAgent(agentId); @@ -1678,7 +1701,7 @@ export class HeartbeatMonitor { pausedOnly: true, excludeArchived: true, }); - const toUnpause = pausedTasks.filter((task) => task.pausedByAgentId === agentId); + const toUnpause = pausedTasks.filter((task) => task.pausedByAgentId === agentId && !task.userPaused); const results = await Promise.allSettled(toUnpause.map((task) => this.taskStore!.pauseTask(task.id, false))); results.forEach((result, index) => { if (result.status === "rejected") { @@ -1958,8 +1981,10 @@ export class HeartbeatMonitor { // Pause governance: globalPause blocks all heartbeat sources; // enginePaused is a soft pause that only blocks timer ticks. + let heartbeatModelSettings: Settings | undefined; try { - const settings = await taskStore.getSettings(); + heartbeatModelSettings = await taskStore.getSettings(); + const settings = heartbeatModelSettings; if (settings.globalPause) { heartbeatLog.log(`Agent ${agentId} heartbeat skipped — global pause active (source=${source})`); await this.completeRun(agentId, run.id, { @@ -2061,16 +2086,17 @@ export class HeartbeatMonitor { let autoClaimSnapshotCandidateCount = 0; let autoClaimRoleFilteredCount = 0; const autoClaimEnabled = isAutoClaimRelevantTasksEnabled(agent); + const engineerBacklogAutoClaim = resolveEngineerBacklogAutoClaim(agent, heartbeatModelSettings); if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled && this.snapshotManager) { try { const snapshot = await this.snapshotManager.getSnapshot(); autoClaimSnapshotCandidateCount = snapshot.tasks.length; - const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate)); + const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate, { allowEngineer: engineerBacklogAutoClaim })); const skippedIncompatibleCount = snapshot.tasks.length - roleCompatibleCandidates.length; autoClaimRoleFilteredCount = skippedIncompatibleCount; if (skippedIncompatibleCount > 0) { heartbeatLog.log( - `Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`, + `Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — ${engineerBacklogAutoClaim ? "only executor agents or engineer agents opted into engineerBacklogAutoClaim may claim implementation work" : "only executor agents may claim implementation work by default"}`, ); } @@ -2504,11 +2530,12 @@ export class HeartbeatMonitor { }); }; - let heartbeatModelSettings: Settings | undefined; - try { - heartbeatModelSettings = await taskStore.getSettings(); - } catch (settingsErr) { - heartbeatLog.warn(`Failed to read heartbeat model settings for ${agentId}: ${settingsErr instanceof Error ? settingsErr.message : String(settingsErr)}`); + if (!heartbeatModelSettings) { + try { + heartbeatModelSettings = await taskStore.getSettings(); + } catch (settingsErr) { + heartbeatLog.warn(`Failed to read heartbeat model settings for ${agentId}: ${settingsErr instanceof Error ? settingsErr.message : String(settingsErr)}`); + } } let sessionCwd = rootDir; @@ -2733,18 +2760,16 @@ export class HeartbeatMonitor { } const promptCandidateLimit = resolveAutoClaimCandidatesInPromptLimit(agent, heartbeatModelSettings); + const hasOnlyRoleIncompatibleAutoClaimCandidates = autoClaimCandidates.length === 0 && autoClaimSnapshotCandidateCount > 0 && autoClaimRoleFilteredCount > 0; const autoClaimStatus = autoClaimEnabled ? (promptCandidateLimit === 0 ? "disabled (prompt-suppressed)" - : (autoClaimCandidates.length === 0 && autoClaimSnapshotCandidateCount > 0 && autoClaimRoleFilteredCount > 0 - ? "enabled (no role-compatible candidates; executor role required)" + : (hasOnlyRoleIncompatibleAutoClaimCandidates + ? formatBacklogAutoClaimRoleStatus(agent, engineerBacklogAutoClaim) : "enabled")) : "disabled"; - const noRoleCompatibleCandidateLines = autoClaimCandidates.length === 0 && autoClaimSnapshotCandidateCount > 0 && autoClaimRoleFilteredCount > 0 - ? [ - `- Snapshot found ${autoClaimSnapshotCandidateCount} eligible Todo task(s), but this agent role cannot auto-claim implementation work.`, - "- Backlog auto-claim is restricted to executor-role agents; use delegation or create coordination follow-up instead of assuming the board is empty.", - ] + const noRoleCompatibleCandidateLines = hasOnlyRoleIncompatibleAutoClaimCandidates + ? formatBacklogAutoClaimRoleGuidance(agent, engineerBacklogAutoClaim, autoClaimSnapshotCandidateCount) : []; const candidateLines = promptCandidateLimit > 0 ? [ @@ -3638,11 +3663,19 @@ function readHeartbeatTimerRepairMetadata(agent: Agent): HeartbeatTimerRepairMet }; } +type PendingAssignment = { + taskId: string; + triggeringCommentIds?: string[]; + triggeringCommentType?: "steering" | "task" | "pr"; + budgetStatus?: AgentBudgetStatus; +}; + export class HeartbeatTriggerScheduler { private store: AgentStore; private callback: TriggerCallback; private taskStore?: TaskStore; private timers: Map<string, AgentTimer> = new Map(); + private pendingAssignments: Map<string, PendingAssignment> = new Map(); private registrationEpochs: Map<string, number> = new Map(); private running = false; private assignedListener: ((agent: import("@fusion/core").Agent, taskId: string) => void) | null = null; @@ -3922,6 +3955,7 @@ export class HeartbeatTriggerScheduler { */ unregisterAgent(agentId: string): void { this.registrationEpochs.set(agentId, (this.registrationEpochs.get(agentId) ?? 0) + 1); + this.pendingAssignments.delete(agentId); if (this.timers.has(agentId)) { this.clearAgentTimer(agentId); heartbeatLog.log(`Unregistered timer for ${agentId}`); @@ -3959,9 +3993,12 @@ export class HeartbeatTriggerScheduler { return; } - // Guard: skip if agent already has an active run + // Guard: skip if agent already has an active run. Preserve this + // assignment for completion-driven re-fire so it is not stranded by + // long/idle-skipped timer intervals. const activeRun = await this.store.getActiveHeartbeatRun(agent.id); if (activeRun) { + this.pendingAssignments.set(agent.id, { taskId }); heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (active run)`); return; } @@ -4035,6 +4072,101 @@ export class HeartbeatTriggerScheduler { heartbeatLog.log("Watching agent:assigned events"); } + /** + * Re-evaluate and re-fire an assignment trigger that was deferred because + * the agent already had an active heartbeat run. Transient ineligibility + * keeps the pending entry so a later completion can retry; terminal + * ineligibility clears it. + */ + async drainPendingAssignment(agentId: string): Promise<void> { + if (!this.running) return; + + const pending = this.pendingAssignments.get(agentId); + if (!pending) { + return; + } + + try { + const agent = await this.store.getAgent(agentId); + if (!agent) { + this.pendingAssignments.delete(agentId); + heartbeatLog.log(`Deferred assignment cleared for ${agentId} (agent missing)`); + return; + } + + if (!isHeartbeatManaged(agent)) { + this.pendingAssignments.delete(agentId); + heartbeatLog.log(`Deferred assignment cleared for ${agentId} (ephemeral/internal)`); + return; + } + + const runtimeConfig = (agent.runtimeConfig ?? {}) as { enabled?: boolean; allowParallelExecution?: boolean }; + if (runtimeConfig.enabled === false) { + this.pendingAssignments.delete(agentId); + heartbeatLog.log(`Deferred assignment cleared for ${agentId} (disabled)`); + return; + } + + if (!isTickableState(agent.state)) { + heartbeatLog.log(`Deferred assignment preserved for ${agentId} (state=${agent.state})`); + return; + } + + const settings = this.taskStore ? await this.taskStore.getSettings() : null; + if (settings?.globalPause) { + heartbeatLog.log(`Deferred assignment preserved for ${agentId} (global pause active)`); + return; + } + if (settings?.enginePaused) { + heartbeatLog.log(`Deferred assignment preserved for ${agentId} (engine paused)`); + return; + } + + const activeRun = await this.store.getActiveHeartbeatRun(agentId); + if (activeRun) { + heartbeatLog.log(`Deferred assignment preserved for ${agentId} (active run)`); + return; + } + + if ( + runtimeConfig.allowParallelExecution === false + && (this.isTaskExecuting?.(pending.taskId) || this.isAgentEffectivelyExecuting?.(agentId)) + ) { + heartbeatLog.log(`Deferred assignment preserved for ${agentId} (parallel execution disabled, task ${pending.taskId} or column-bound session executing)`); + return; + } + + let budgetStatus: AgentBudgetStatus | undefined = pending.budgetStatus; + try { + budgetStatus = await this.store.getBudgetStatus(agentId); + if (budgetStatus.isOverBudget) { + this.pendingAssignments.delete(agentId); + heartbeatLog.log(`Deferred assignment cleared for ${agentId} (budget exhausted)`); + return; + } + } catch (budgetErr) { + heartbeatLog.warn(`Deferred assignment budget check failed for ${agentId}: ${budgetErr instanceof Error ? budgetErr.message : String(budgetErr)} — proceeding without budget check`); + } + + this.pendingAssignments.delete(agentId); + heartbeatLog.log(`Deferred assignment re-fired for ${agentId} (task: ${pending.taskId})`); + await this.callback(agentId, "assignment", { + taskId: pending.taskId, + wakeReason: "assignment", + triggerDetail: "task-assigned", + ...(pending.triggeringCommentIds?.length + ? { + triggeringCommentIds: pending.triggeringCommentIds, + triggeringCommentType: pending.triggeringCommentType ?? "steering", + } + : {}), + ...(budgetStatus && { budgetStatus }), + }); + } catch (err) { + heartbeatLog.error(`Deferred assignment drain error for ${agentId}: ${err instanceof Error ? err.message : err}`); + } + } + /** * Unsubscribe from agent:assigned events. */ diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 98939f5162..7d252371d5 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -14,9 +14,12 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent"; import { isTestModeActive, resolveExecutionSettingsModel, + resolveProjectDefaultModel, resolveTaskExecutionModel, resolveTaskPlanningModel, + resolveTaskValidatorModel, TEST_MODE_RESOLVED, + type ResolvedModelSelection, type Settings, } from "@fusion/core"; import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js"; @@ -131,6 +134,36 @@ export function extractRuntimeModel( }; } +function hasCompleteRuntimeModel( + model: ResolvedModelSelection, +): model is { provider: string; modelId: string } { + return Boolean(model.provider && model.modelId); +} + +function pickSettingsThenRuntimeModel( + settingsModel: ResolvedModelSelection, + assignedAgentRuntimeConfig?: Record<string, unknown>, +): { provider: string | undefined; modelId: string | undefined } { + // Project/task/global settings are the authoritative model hierarchy. The + // assigned durable agent runtime model is only a final compatibility fallback + // when the hierarchy produced no complete pair; partial runtime pairs must + // never be mixed with settings fields or mask saved project overrides. + if (settingsModel.provider && settingsModel.modelId) { + return { + provider: settingsModel.provider, + modelId: settingsModel.modelId, + }; + } + + const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); + return hasCompleteRuntimeModel(assignedRuntimeModel) + ? assignedRuntimeModel + : { + provider: settingsModel.provider, + modelId: settingsModel.modelId, + }; +} + export function resolveExecutorSessionModel( taskModelProvider: string | undefined, taskModelId: string | undefined, @@ -144,11 +177,6 @@ export function resolveExecutorSessionModel( }; } - const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); - if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { - return assignedRuntimeModel; - } - const resolvedTaskModel = resolveTaskExecutionModel( { modelProvider: taskModelProvider, @@ -157,10 +185,7 @@ export function resolveExecutorSessionModel( settings, ); - return { - provider: resolvedTaskModel.provider, - modelId: resolvedTaskModel.modelId, - }; + return pickSettingsThenRuntimeModel(resolvedTaskModel, assignedAgentRuntimeConfig); } export function resolvePlanningSessionModel( @@ -176,11 +201,6 @@ export function resolvePlanningSessionModel( }; } - const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); - if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { - return assignedRuntimeModel; - } - const resolvedTaskPlanningModel = resolveTaskPlanningModel( { planningModelProvider: taskPlanningModelProvider, @@ -189,10 +209,31 @@ export function resolvePlanningSessionModel( settings, ); - return { - provider: resolvedTaskPlanningModel.provider, - modelId: resolvedTaskPlanningModel.modelId, - }; + return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig); +} + +export function resolveValidatorSessionModel( + taskValidatorModelProvider: string | undefined, + taskValidatorModelId: string | undefined, + settings: Partial<Settings> | undefined, + assignedAgentRuntimeConfig?: Record<string, unknown>, +): { provider: string | undefined; modelId: string | undefined } { + if (isTestModeActive(settings)) { + return { + provider: TEST_MODE_RESOLVED.provider, + modelId: TEST_MODE_RESOLVED.modelId, + }; + } + + const resolvedTaskValidatorModel = resolveTaskValidatorModel( + { + validatorModelProvider: taskValidatorModelProvider, + validatorModelId: taskValidatorModelId, + }, + settings, + ); + + return pickSettingsThenRuntimeModel(resolvedTaskValidatorModel, assignedAgentRuntimeConfig); } export function resolveHeartbeatSessionModels( @@ -213,21 +254,14 @@ export function resolveHeartbeatSessionModels( }; } - const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const executionSettingsModel = resolveExecutionSettingsModel(settings); - - const defaultProvider = assignedRuntimeModel.provider ?? executionSettingsModel.provider; - const defaultModelId = assignedRuntimeModel.modelId ?? executionSettingsModel.modelId; - - const executionPairAvailable = Boolean(executionSettingsModel.provider && executionSettingsModel.modelId); - const defaultMatchesExecution = - defaultProvider === executionSettingsModel.provider && defaultModelId === executionSettingsModel.modelId; + const resolvedModel = pickSettingsThenRuntimeModel(executionSettingsModel, assignedAgentRuntimeConfig); return { - defaultProvider, - defaultModelId, - fallbackProvider: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.provider : undefined, - fallbackModelId: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.modelId : undefined, + defaultProvider: resolvedModel.provider, + defaultModelId: resolvedModel.modelId, + fallbackProvider: undefined, + fallbackModelId: undefined, }; } @@ -242,22 +276,11 @@ export function resolveMergerSessionModel( }; } - const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); - if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { - return assignedRuntimeModel; - } - - if (settings?.defaultProviderOverride && settings.defaultModelIdOverride) { - return { - provider: settings.defaultProviderOverride, - modelId: settings.defaultModelIdOverride, - }; - } - - return { - provider: settings?.defaultProvider, - modelId: settings?.defaultModelId, - }; + // Merger intentionally uses the default lane rather than execution/validator + // lanes. Validator-specific callers resolve `resolveValidatorSettingsModel` + // before falling back here; generic merger work uses project/global defaults. + const defaultModel = resolveProjectDefaultModel(settings); + return pickSettingsThenRuntimeModel(defaultModel, assignedAgentRuntimeConfig); } /** diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 637b78cabf..a69ee13acb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n import { existsSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker } from "@fusion/core"; import { mergeEffectiveSettings } from "./effective-settings.js"; import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core"; import { @@ -231,6 +231,67 @@ export { const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve)); +function getPromptSection(prompt: string, heading: string): string { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = prompt.match(new RegExp(`^##\\s+${escapedHeading}\\s*$([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "im")); + return match?.[1]?.trim() ?? ""; +} + +function promptDeclaresReviewLevelOnePlanOnly(prompt: string): boolean { + return /^##\s+Review Level:\s*1\b[^\n]*\bPlan Only\b/im.test(prompt); +} + +function promptDeclaresNoSourceChangeIntent(prompt: string): boolean { + const normalized = prompt.toLowerCase(); + return [ + /should\s+not\s+change\s+(?:product\s+)?source/, + /do\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?source/, + /no\s+(?:source|code)\s+changes?\s+(?:are\s+)?(?:expected|required|needed|allowed)/, + /must\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?(?:source|code)/, + ].some((pattern) => pattern.test(normalized)); +} + +function promptLooksCoordinationOnly(prompt: string): boolean { + const titleMatch = prompt.match(/^#\s+Task:\s+[^\n]+/im)?.[0] ?? ""; + const mission = getPromptSection(prompt, "Mission"); + const assessment = prompt.match(/^\*\*Assessment:\*\*\s*([^\n]+)/im)?.[1] ?? ""; + const coordinationText = `${titleMatch}\n${mission}\n${assessment}`.toLowerCase(); + const hasCoordinationIntent = /\b(coordination|routing|route|handoff|assign(?:ment)?|owner|triage|select exactly one|record (?:the )?intentional block)\b/.test(coordinationText); + const missionLower = mission.toLowerCase() + .replace(/do\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?source/g, "") + .replace(/should\s+not\s+change\s+(?:product\s+)?source/g, "") + .replace(/must\s+not\s+(?:edit|modify|change)\s+(?:product\s+)?(?:source|code)/g, ""); + const hasImplementationDirective = /\b(implement|fix|add|change|modify|refactor|build|create|delete|remove)\b/.test(missionLower); + return hasCoordinationIntent && !hasImplementationDirective; +} + +function promptFileScopeIsBoardOnly(prompt: string): boolean { + const fileScope = getPromptSection(prompt, "File Scope"); + if (!fileScope.trim()) return false; + const normalized = fileScope.toLowerCase(); + const sourcePathPattern = /(?:^|[\s`'"(])(?:packages|src|source|sources|app|apps|lib|libs|components|scripts|docs|\.github|config|test|tests|__tests__)\//m; + const sourceExtensionPattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|swift|kt|java|py|go|rs|rb|php|cs|cpp|c|h|hpp|json|ya?ml|toml|mdx?|css|scss|html|sql|sh)\b/m; + if (sourcePathPattern.test(normalized) || sourceExtensionPattern.test(normalized)) return false; + const allowedBoardOnlyPattern = /(?:^|[^\w/])(?:task[- ]?board|board task|task document|task documents|task metadata|task logs|fusion task tools|fn_task_[\w-]*|\.fusion\/tasks|attachments?)(?=$|[^\w/-])/; + return allowedBoardOnlyPattern.test(normalized); +} + +function getNoCommitEligibilityReason(task: Task): "explicit noCommitsExpected=true" | "prompt-derived coordination-only no-source scope" | null { + if (task.noCommitsExpected === true) return "explicit noCommitsExpected=true"; + const rawPrompt = task.prompt; + const prompt = typeof rawPrompt === "string" ? rawPrompt : ""; + if (!prompt.trim()) return null; + if ( + promptDeclaresReviewLevelOnePlanOnly(prompt) && + promptLooksCoordinationOnly(prompt) && + promptDeclaresNoSourceChangeIntent(prompt) && + promptFileScopeIsBoardOnly(prompt) + ) { + return "prompt-derived coordination-only no-source scope"; + } + return null; +} + /** * How long to wait after engine startup before spawning AI agent sessions for * orphaned in-progress tasks. The work itself (worktree setup, pi-coding-agent @@ -275,6 +336,13 @@ const MAX_WORKFLOW_STEP_RETRIES = 3; const MAX_TASK_DONE_SESSION_RETRIES = 3; /** Maximum todo requeues after exhausting in-session fn_task_done retries. */ const MAX_TASK_DONE_REQUEUE_RETRIES = 3; +/** + * Maximum bounded retries for the narrow resume-after-restart graph transient. + * Budget exhaustion falls through to terminal status:"failed" so FN-5704's + * self-healing anti-loop exemption remains intact for genuine graph failures. + */ +const MAX_TRANSIENT_GRAPH_RESUME_RETRIES = 2; +const TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS = process.env.VITEST || process.env.NODE_ENV === "test" ? 0 : 1_000; /** How long to wait before recovering a completed task still stuck in in-progress. */ const COMPLETED_TASK_WATCHDOG_MS = 60_000; /** How long to wait before retrying a workflow rerun handoff that never reached in-progress. */ @@ -581,6 +649,111 @@ export function parseReviewLevelFromPrompt(prompt: string): number { return reviewMatch ? parseInt(reviewMatch[1], 10) : 0; } +function extractPromptSection(prompt: string, heading: string): string { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headingPattern = new RegExp(`^##\\s+${escaped}\\s*:?\\s*$`, "i"); + const nextHeadingPattern = /^##\s+/; + const lines = prompt.split(/\r?\n/); + const start = lines.findIndex((line) => headingPattern.test(line.trim())); + if (start === -1) return ""; + + const sectionLines: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (nextHeadingPattern.test(line.trim())) break; + sectionLines.push(line); + } + return sectionLines.join("\n").trim(); +} + +function extractPromptListEntries(section: string): string[] { + return section + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => line.replace(/^[-*]\s+/, "").replace(/^`([^`]+)`.*$/, "$1").trim()) + .filter(Boolean); +} + +function isNoSourceScopeEntry(entry: string): boolean { + const normalized = entry.toLowerCase(); + return ( + normalized.includes("no source") || + normalized.includes("no product-source") || + normalized.includes("no code") || + normalized.includes("no file mutations") || + normalized.includes("task document") || + normalized.includes("task log") || + normalized.includes("agent log") || + normalized.includes("read-only evidence") || + normalized.startsWith(".fusion/tasks/") || + normalized.startsWith("<rootdir>/.fusion/tasks/") + ); +} + +function hasSourceChangingScopeEntry(entry: string): boolean { + const normalized = entry.toLowerCase(); + if (!normalized) return false; + if (normalized.startsWith(".fusion/tasks/") || normalized.startsWith("<rootdir>/.fusion/tasks/")) return false; + if (/\b(source|sources|packages|tests|src|app|scripts|\.changeset)\b/.test(normalized)) return true; + if (/\.(ts|tsx|js|jsx|mjs|cjs|swift|kt|java|py|rs|go|rb|md|json|ya?ml|toml|css|scss|html)\b/.test(normalized)) return true; + if (normalized.includes("read-only") || isNoSourceScopeEntry(normalized)) return false; + return false; +} + +function getTaskTextForNoCommitEligibility(task: Task, promptContent: string): string { + const logText = (task.log ?? []) + .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) + .join("\n"); + const sourceMetadata = task.sourceMetadata ? JSON.stringify(task.sourceMetadata) : ""; + return [task.title, task.description, promptContent, sourceMetadata, logText] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n"); +} + +function evaluatePromptDerivedNoCommitEligibility(task: Task, promptContent: string): { eligible: boolean; reason?: string } { + const combined = getTaskTextForNoCommitEligibility(task, promptContent).toLowerCase(); + const reviewLevel = typeof task.reviewLevel === "number" ? task.reviewLevel : parseReviewLevelFromPrompt(promptContent); + const isPlanOnly = reviewLevel === 1 && (/plan\s*only/.test(combined) || combined.includes("plan-only")); + if (!isPlanOnly) return { eligible: false }; + + const explicitNoSourceIntent = [ + "no expected product-source changes", + "no product-source changes", + "no source changes expected", + "no source files expected", + "no code changes expected", + "no expected source changes", + "no file mutations", + "no source/config/file mutations", + ].some((phrase) => combined.includes(phrase)); + if (!explicitNoSourceIntent) return { eligible: false }; + + const excludedImplementationIntent = /\b(investigate and fix|fix if needed|implement|source-changing|code change|docs\/tests changes|documentation change|bug[- ]fix|feature)\b/.test(combined); + const operationalIntent = /\b(operational|routing|route|assign|assignment|owner|handoff|coordination|coordinate|no-route|triage)\b/.test(combined); + if (!operationalIntent || excludedImplementationIntent) return { eligible: false }; + + const promptScopeEntries = extractPromptListEntries(extractPromptSection(promptContent, "File Scope")); + const metadataScope = Array.isArray(task.sourceMetadata?.fileScope) + ? task.sourceMetadata.fileScope.filter((entry): entry is string => typeof entry === "string") + : []; + const declaredScope = [...promptScopeEntries, ...metadataScope]; + if (declaredScope.length === 0) return { eligible: false }; + if (declaredScope.some(hasSourceChangingScopeEntry)) return { eligible: false }; + if (!declaredScope.every(isNoSourceScopeEntry)) return { eligible: false }; + + const stepsComplete = Array.isArray(task.steps) && task.steps.length > 0 + ? task.steps.every((step) => step.status === "done" || step.status === "skipped") + : false; + const logText = (task.log ?? []) + .map((entry) => `${entry.action ?? ""}\n${entry.outcome ?? ""}`) + .join("\n") + .toLowerCase(); + const hasOperationalEvidence = /\b(evidence|recorded|documented|no-route|routed|assigned|handoff|decision)\b/.test(logText); + if (!stepsComplete && !hasOperationalEvidence) return { eligible: false }; + + return { eligible: true, reason: "prompt/source metadata derived operational no-commit contract" }; +} + export function partitionWorkflowRevisionFeedback( feedback: string, declaredFileScope: readonly string[], @@ -936,7 +1109,7 @@ PROMPT.md is captured at task-creation time; HEAD may have moved on since then. 3. Mark every remaining step skipped with a one-line reason: \`fn_task_update(step=N, status="skipped")\`. 4. Call \`fn_task_done\` with a summary that begins \`PREMISE STALE:\` followed by the concrete reason (e.g. \`PREMISE STALE: targeted reproduction passes unchanged on HEAD; PROMPT claimed MOBILE_MEDIA_QUERY had been expanded but useViewportMode.ts:9 still exports the legacy value\`). -This path exists specifically to prevent the executor from looping when PROMPT.md is out of sync with HEAD. Use it only after running the actual reproduction — do not invoke it to dodge real work. +This path exists specifically to prevent the executor from looping when PROMPT.md is out of sync with HEAD. Use it only after running the actual reproduction — do not invoke it to dodge real work. If a task is verified as a no-op, duplicate, or redundant for the same reason (the requested behavior is already present on HEAD), \`fn_task_done\` may also use a leading sentinel summary of \`NO-OP:\`, \`NOOP:\`, \`DUPLICATE: FN-NNNN ...\`, or \`REDUNDANT:\`. These sentinels are audit-logged and allow a verified zero-commit completion; ordinary zero-commit implementation completions without a recognized leading sentinel are still refused. **Logging important actions:** \`fn_task_log(message="what happened")\` @@ -1184,6 +1357,17 @@ export interface CliAgentRuntime { hookDirRoot?: string; } +interface ActiveExecutorSessionState { + session: AgentSession; + seenSteeringIds: Set<string>; + lastResolvedModelProvider?: string; + lastResolvedModelId?: string; + lastTaskModelProvider?: string | null; + lastTaskModelId?: string | null; + lastAssignedAgentId?: string | null; + lastEffectiveColumnAgentId?: string | null; +} + export class TaskExecutor { private activeWorktrees = new Map<string, string>(); private executing = new Set<string>(); @@ -1201,24 +1385,11 @@ export class TaskExecutor { * session being fully reaped before creating/acquiring a new worktree. */ private pendingTaskDisposals = new Map<string, Promise<void>>(); /** Active agent sessions per task, used to terminate on pause and inject steering. */ - private activeSessions = new Map<string, { - session: AgentSession; - seenSteeringIds: Set<string>; - lastResolvedModelProvider?: string; - lastResolvedModelId?: string; - lastTaskModelProvider?: string | null; - lastTaskModelId?: string | null; - lastAssignedAgentId?: string | null; - // Column-agent restart-invalidation (plan U5, R7/KTD-4). The effective - // column-agent id governing this session's seam (undefined when no binding - // governs — the legacy path). Tracked so the watcher can detect a workflow- - // definition edit or agent runtimeConfig change that re-keys the column- - // effective agent/model mid-flight and trigger the same restart path a - // task.modelProvider change does today. - lastEffectiveColumnAgentId?: string | null; - }>(); + private activeSessions = new Map<string, ActiveExecutorSessionState>(); /** Active step-session executors per task (mutually exclusive with activeSessions). */ private activeStepExecutors = new Map<string, StepSessionExecutor>(); + /** Steering comments already observed for active step-session executor runs. */ + private activeStepExecutorSeenSteeringIds = new Map<string, Set<string>>(); /** Column-agent principal alignment (plan U5, R6): the EFFECTIVE column-agent id * currently running each executing task's coding/step session, when an * override/defer binding governs the in-flight seam. Keyed by task id, populated @@ -1231,6 +1402,8 @@ export class TaskExecutor { private effectiveColumnAgentByTask = new Map<string, string>(); /** Active pre-merge workflow step sessions per task. */ private activeWorkflowStepSessions = new Map<string, AgentSession>(); + /** Steering comments already observed for active workflow step sessions. */ + private activeWorkflowStepSessionSeenSteeringIds = new Map<string, Set<string>>(); /** Active configured-command abort controllers keyed by task. */ private activeConfiguredCommandControllers = new Map<string, Set<AbortController>>(); /** @@ -1275,16 +1448,7 @@ export class TaskExecutor { /** Set of ephemeral spawned agent IDs with in-flight cleanup (prevents duplicate deletion attempts). */ private pendingEphemeralDeletions = new Set<string>(); - private setActiveSession(taskId: string, sessionState: { - session: AgentSession; - seenSteeringIds: Set<string>; - lastResolvedModelProvider?: string; - lastResolvedModelId?: string; - lastTaskModelProvider?: string | null; - lastTaskModelId?: string | null; - lastAssignedAgentId?: string | null; - lastEffectiveColumnAgentId?: string | null; - }, worktreePath: string): void { + private setActiveSession(taskId: string, sessionState: ActiveExecutorSessionState, worktreePath: string): void { this.activeSessions.set(taskId, sessionState); activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "executor", ownerKey: taskId }); } @@ -1299,13 +1463,15 @@ export class TaskExecutor { } } - private setActiveStepExecutor(taskId: string, stepExecutor: StepSessionExecutor, worktreePath: string): void { + private setActiveStepExecutor(taskId: string, stepExecutor: StepSessionExecutor, worktreePath: string, seenSteeringIds = new Set<string>()): void { this.activeStepExecutors.set(taskId, stepExecutor); + this.activeStepExecutorSeenSteeringIds.set(taskId, seenSteeringIds); activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "step-session", ownerKey: `${taskId}#step-session` }); } private deleteActiveStepExecutor(taskId: string, worktreePath?: string): void { this.activeStepExecutors.delete(taskId); + this.activeStepExecutorSeenSteeringIds.delete(taskId); // U5: drop the effective column-agent principal for this task's step session. this.effectiveColumnAgentByTask.delete(taskId); const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); @@ -1314,19 +1480,29 @@ export class TaskExecutor { } } - private setActiveWorkflowStepSession(taskId: string, session: AgentSession, worktreePath: string): void { + private setActiveWorkflowStepSession(taskId: string, session: AgentSession, worktreePath: string, seenSteeringIds = new Set<string>()): void { this.activeWorkflowStepSessions.set(taskId, session); + this.activeWorkflowStepSessionSeenSteeringIds.set(taskId, seenSteeringIds); activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "workflow-step", ownerKey: `${taskId}#workflow-step` }); } private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void { this.activeWorkflowStepSessions.delete(taskId); + this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId); const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId); if (resolvedWorktreePath) { activeSessionRegistry.unregisterPath(resolvedWorktreePath); } } + private createSeenSteeringIds(task: { comments?: Array<{ id: string }>; steeringComments?: Array<{ id: string }> }): Set<string> { + const seenSteeringIds = new Set<string>(); + for (const comment of task.steeringComments ?? task.comments ?? []) { + seenSteeringIds.add(comment.id); + } + return seenSteeringIds; + } + private registerConfiguredCommandController(taskId: string, controller: AbortController): void { const controllers = this.activeConfiguredCommandControllers.get(taskId) ?? new Set<AbortController>(); controllers.add(controller); @@ -1946,7 +2122,6 @@ export class TaskExecutor { } this.loopRecoveryState.delete(taskId); - this.spawnedAgents.delete(taskId); this.stuckAborted.delete(taskId); if (hadActiveSurface) { @@ -2372,56 +2547,117 @@ export class TaskExecutor { } } - // Handle steering comments - inject new ones into the running session - // Only process if session is active (activeSessions check is sufficient - // since entries are only added when a task is in-progress) - if (this.activeSessions.has(task.id) && task.steeringComments) { - const activeSession = this.activeSessions.get(task.id)!; - const { session, seenSteeringIds } = activeSession; + // Handle steering comments - inject new ones into whichever execution + // surface currently owns the task: legacy single-session, step-session + // executor (including graph-pinned/workflow stepwise runs), or an + // individual workflow step AgentSession. + if (task.steeringComments) { + const injectionTargets: Array<{ + kind: "legacy" | "step-session" | "workflow-step"; + seenSteeringIds: Set<string>; + inject: (message: string) => Promise<void>; + legacySession?: AgentSession; + legacyState?: ActiveExecutorSessionState; + }> = []; - // Find new steering comments that haven't been seen yet - const newComments = task.steeringComments.filter(c => !seenSteeringIds.has(c.id)); + const activeSession = this.activeSessions.get(task.id); + if (activeSession) { + injectionTargets.push({ + kind: "legacy", + seenSteeringIds: activeSession.seenSteeringIds, + inject: (message) => activeSession.session.steer(message), + legacySession: activeSession.session, + legacyState: activeSession, + }); + } + + const stepExecutor = this.activeStepExecutors.get(task.id); + if (stepExecutor) { + const seenSteeringIds = this.activeStepExecutorSeenSteeringIds.get(task.id) ?? this.createSeenSteeringIds(task); + this.activeStepExecutorSeenSteeringIds.set(task.id, seenSteeringIds); + injectionTargets.push({ + kind: "step-session", + seenSteeringIds, + inject: (message) => stepExecutor.steerActiveSessions(message), + }); + } + + const workflowSession = this.activeWorkflowStepSessions.get(task.id); + if (workflowSession) { + const seenSteeringIds = this.activeWorkflowStepSessionSeenSteeringIds.get(task.id) ?? this.createSeenSteeringIds(task); + this.activeWorkflowStepSessionSeenSteeringIds.set(task.id, seenSteeringIds); + injectionTargets.push({ + kind: "workflow-step", + seenSteeringIds, + inject: (message) => workflowSession.steer(message), + }); + } + + const loggedCommentIds = new Set<string>(); + let legacyReviewHandoff: { + comments: import("@fusion/core").SteeringComment[]; + session: AgentSession; + state: ActiveExecutorSessionState; + } | undefined; + + for (const target of injectionTargets) { + // Find new steering comments that haven't been seen by this running surface yet. + const newComments = task.steeringComments.filter(c => !target.seenSteeringIds.has(c.id)); + if (newComments.length === 0) continue; - if (newComments.length > 0) { for (const comment of newComments) { const summary = comment.text.length > 80 ? comment.text.slice(0, 80) + "..." : comment.text; - // Mark as seen BEFORE attempting injection to prevent retry loops on failure - seenSteeringIds.add(comment.id); + // Mark as seen BEFORE attempting injection to prevent retry loops on failure. + target.seenSteeringIds.add(comment.id); - // Format and inject the comment const commentMessage = formatCommentForInjection(comment); try { - executorLog.log(`Injecting comment into ${task.id}: ${summary}`); - await session.steer(commentMessage); - executorLog.log(`Successfully injected comment into ${task.id}`); + executorLog.log(`Injecting comment into ${task.id} (${target.kind}): ${summary}`); + await target.inject(commentMessage); + executorLog.log(`Successfully injected comment into ${task.id} (${target.kind})`); - // Log to the task that comment was received - await this.store.logEntry( - task.id, - `Comment received mid-execution: ${summary}`, - `by ${comment.author}` - ); + // Log to the task once per comment/tick even if multiple active surfaces exist. + if (!loggedCommentIds.has(comment.id)) { + await this.store.logEntry( + task.id, + `Comment received mid-execution: ${summary}`, + `by ${comment.author}` + ); + loggedCommentIds.add(comment.id); + } } catch (err) { - executorLog.error(`Failed to inject comment for ${task.id}:`, err); + executorLog.error(`Failed to inject comment for ${task.id} (${target.kind}):`, err); // Comment is already marked as seen - we won't retry to avoid spamming // the agent with failed injections. The error is logged for debugging. } } - // After injecting comments, check for review handoff intent + if (target.kind === "legacy" && target.legacySession && target.legacyState) { + legacyReviewHandoff = { + comments: newComments, + session: target.legacySession, + state: target.legacyState, + }; + } + } + + // After injecting comments, check for review handoff intent on the legacy + // session path. Step-session/workflow-step runs do not have the legacy + // review handoff state required by executeReviewHandoff. + if (legacyReviewHandoff) { // Only detect handoff in agent-authored comments when policy is enabled. // Merge per-task effective workflow settings (U3, KTD-3) so // reviewHandoffPolicy resolves from the workflow. Behavior-inert by default. const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings()); if (settings.reviewHandoffPolicy === "comment-triggered") { - const agentComments = newComments.filter(c => c.author !== "user"); + const agentComments = legacyReviewHandoff.comments.filter(c => c.author !== "user"); for (const comment of agentComments) { if (detectReviewHandoffIntent(comment.text)) { executorLog.log(`Review handoff detected in ${task.id}: ${comment.text.slice(0, 50)}...`); - await this.executeReviewHandoff(task, session, activeSession); + await this.executeReviewHandoff(task, legacyReviewHandoff.session, legacyReviewHandoff.state); return; // Exit early - handoff handles session disposal } } @@ -3833,6 +4069,11 @@ export class TaskExecutor { } if (result.disposition === "failed") { await this.handleGraphFailure(task, result); + } else if (result.disposition === "completed") { + const live = await this.store.getTask(task.id).catch(() => task); + if ((live.graphResumeRetryCount ?? 0) !== 0) { + await this.store.updateTask(task.id, { graphResumeRetryCount: 0 }, this.getRunContextFor(task.id)); + } } return true; } finally { @@ -4446,11 +4687,15 @@ export class TaskExecutor { stageByNodeId.set(node.id, seam); } } - // Stop the shadow walk at the live terminal seam. The graph walker visits a - // node *before* invoking its seam, so even a failing merge seam (the case - // when the live task is parked in-review with autoMerge off) still records a - // "merge" stage. The legacy side never reports merge for an in-review task, - // so that phantom stage manufactures stageTransitions drift on healthy runs. + // The built-in coding IR now enters an interpreter-owned merge-policy + // primitive region after review; graph execution collapses that region to a + // synthetic legacy merge seam recorded as `merge` until merge-policy cutover. + stageByNodeId.set("merge", "merge"); + // Stop the shadow walk at the live terminal seam. The graph walker records a + // merge stage before/while invoking its seam, so even a failing merge seam + // (the case when the live task is parked in-review with autoMerge off) still + // records a "merge" stage. The legacy side never reports merge for an + // in-review task, so that phantom stage manufactures stageTransitions drift. // Truncate the visited-stage sequence at the stage the live task actually // reached: merged → merge, reachedReview → review, else → execute. const terminalStage: WorkflowStage = merged ? "merge" : reachedReview ? "review" : "execute"; @@ -5606,6 +5851,22 @@ export class TaskExecutor { return this.runCliAgentNode(node, live, cfg); } + // Fast mode bypasses pre-merge automated review/validation gates. Custom + // graph prompt/script/gate nodes are implemented by synthesizing pre-merge + // WorkflowStep executions below, so skip them here before worktree or CLI + // approval gates can fire. Human waits (`awaitInput`) and implementation + // CLI-agent nodes are handled above and remain enforced. + if (live.executionMode === "fast" && !cfg.seam && (node.kind === "prompt" || node.kind === "script" || node.kind === "gate")) { + executorLog.log(`${live.id}: fast mode — skipping custom graph node '${node.id}'`); + await this.store.logEntry( + live.id, + `Fast mode — custom graph node '${node.id}' skipped`, + undefined, + this.getRunContextFor(live.id), + ); + return { outcome: "success", value: "workflow-step-skipped" }; + } + const scriptName = typeof cfg.scriptName === "string" && cfg.scriptName.trim() ? cfg.scriptName : undefined; const rawCliCommand = executorKind === "cli" && typeof cfg.cliCommand === "string" && cfg.cliCommand.trim() ? cfg.cliCommand.trim() @@ -5954,6 +6215,22 @@ export class TaskExecutor { } } + private isTransientResumeAfterRestartGraphFailure(live: Task, result: WorkflowGraphTaskRunResult): boolean { + if ((result.reason ?? "").trim().length > 0) return false; + + const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1]; + if (failedNode !== undefined && failedNode !== "execute") return false; + + if (live.steps.some((step) => step.status === "done")) return false; + + const failureState = live as Task & { lastError?: unknown; failureReason?: unknown }; + if (failureState.lastError != null || failureState.failureReason != null) return false; + + const latestAction = live.log.at(-1)?.action; + return latestAction === "Resumed after engine restart" + || latestAction === "Resuming execution after unpause"; + } + /** Terminal failure of a graph run: record the error and park the task in * review so a human can act — never leave it invisible in in-progress. */ private async handleGraphFailure(task: Task, result: WorkflowGraphTaskRunResult): Promise<void> { @@ -5976,6 +6253,32 @@ export class TaskExecutor { return; } const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1]; + if (this.isTransientResumeAfterRestartGraphFailure(live, result)) { + const priorRetries = live.graphResumeRetryCount ?? 0; + if (priorRetries < MAX_TRANSIENT_GRAPH_RESUME_RETRIES) { + const nextRetries = priorRetries + 1; + const benignMessage = `Transient resume-after-restart graph failure — auto-retrying (${nextRetries}/${MAX_TRANSIENT_GRAPH_RESUME_RETRIES}) instead of parking`; + executorLog.warn(`${task.id}: ${benignMessage}`); + await this.store.logEntry(task.id, benignMessage, undefined, this.getRunContextFor(task.id)); + await this.store.updateTask(task.id, { + graphResumeRetryCount: nextRetries, + status: null, + error: null, + }, this.getRunContextFor(task.id)); + const scheduleRetry = () => { + this.execute(live).catch((err) => + executorLog.error(`Failed transient graph resume retry for ${task.id}:`, err), + ); + }; + if (TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS > 0) { + const handle = setTimeout(scheduleRetry, TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS); + handle.unref?.(); + } else { + setTimeout(scheduleRetry, 0).unref?.(); + } + return; + } + } const message = `Workflow graph terminated with failure at node '${failedNode ?? "unknown"}'`; executorLog.warn(`${task.id}: ${message}`); await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id)); @@ -6699,7 +7002,7 @@ export class TaskExecutor { }); }, }); - this.setActiveStepExecutor(task.id, stepExecutor, worktreePath); + this.setActiveStepExecutor(task.id, stepExecutor, worktreePath, this.createSeenSteeringIds(detail)); const stepWork = async () => { const results = await stepExecutor.executeAll(); @@ -7423,14 +7726,10 @@ export class TaskExecutor { // Make session available to custom tools (fn_task_update checkpoint capture, fn_review_step rewind) sessionRef.current = session; - // Register session so the pause listener can terminate it - // Initialize with empty set of seen comments - const seenSteeringIds = new Set<string>(); - if (detail.comments) { - for (const comment of detail.comments) { - seenSteeringIds.add(comment.id); - } - } + // Register session so the pause listener can terminate it. + // Initialize with all existing steering comments so only mid-flight + // comments are injected into the running session. + const seenSteeringIds = this.createSeenSteeringIds(detail); this.setActiveSession(task.id, { session, seenSteeringIds, @@ -9270,6 +9569,7 @@ export class TaskExecutor { task: Task, worktreePathOverride?: string, allowReanchor = true, + options?: { noOpCompletion?: boolean; noOpCompletionReason?: string }, ): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> { const settings = await this.store.getSettings(); const branchName = resolveTaskWorkingBranch(task); @@ -9333,7 +9633,7 @@ export class TaskExecutor { executorLog.log(`${task.id}: re-anchored nested task.worktree ${worktreePath} -> ${reanchor.root}`); await this.store.logEntry(task.id, `Re-anchored nested task.worktree from ${worktreePath} to ${reanchor.root}`, undefined, this.getRunContextFor(task.id)); await this.emitWorktreeReanchoredAudit(task.id, worktreePath, reanchor.root, "verify-worktree-invariants"); - return this.verifyWorktreeInvariants(task, reanchor.root, false); + return this.verifyWorktreeInvariants(task, reanchor.root, false, options); } } return { @@ -9403,8 +9703,33 @@ export class TaskExecutor { }; } - if (task.noCommitsExpected === true) { - executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (noCommitsExpected=true)`); + const promptContent = (task as Task & { prompt?: unknown }).prompt; + const promptDerivedEligibility = evaluatePromptDerivedNoCommitEligibility( + task, + typeof promptContent === "string" ? promptContent : "", + ); + const noCommitEligibilityReason = + getNoCommitEligibilityReason(task) ?? + (options?.noOpCompletion + ? options.noOpCompletionReason ?? "verified no-op/duplicate completion sentinel" + : null) ?? + (promptDerivedEligibility.eligible + ? promptDerivedEligibility.reason ?? "prompt-derived no-commit eligibility" + : null); + if (noCommitEligibilityReason) { + executorLog.log(`${task.id}: fn_task_done no_commits guard skipped (${noCommitEligibilityReason})`); + try { + await this.store.logEntry( + task.id, + `fn_task_done no_commits guard skipped (${noCommitEligibilityReason})`, + undefined, + this.getRunContextFor(task.id), + ); + } catch (error) { + executorLog.warn( + `${task.id}: failed to write no_commits guard skip audit log: ${error instanceof Error ? error.message : String(error)}`, + ); + } return { ok: true }; } @@ -9617,7 +9942,13 @@ export class TaskExecutor { }; } - const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath); + const noOpMarker = parseNoOpCompletionMarker(params.summary); + const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath, true, { + noOpCompletion: Boolean(noOpMarker), + noOpCompletionReason: noOpMarker + ? `verified ${noOpMarker.kind} completion sentinel${noOpMarker.canonicalId ? ` (${noOpMarker.canonicalId})` : ""}` + : undefined, + }); if (!invariantCheck.ok) { const refusalMessage = `fn_task_done refused: ${invariantCheck.reason} — observed=${invariantCheck.observed}, expected=${invariantCheck.expected}`; await store.logEntry(taskId, refusalMessage, undefined, this.getRunContextFor(task.id)); @@ -9754,6 +10085,52 @@ export class TaskExecutor { }; } + if (noOpMarker) { + const runContext = this.getRunContextFor(taskId); + await store.updateTask(taskId, { noCommitsExpected: true }); + await store.logEntry( + taskId, + `Verified ${noOpMarker.kind} completion sentinel accepted; no commits expected for terminal handoff`, + JSON.stringify({ + kind: noOpMarker.kind, + reason: noOpMarker.reason, + canonicalId: noOpMarker.canonicalId, + summary: params.summary, + runId: runContext?.runId, + agentId: runContext?.agentId, + }), + runContext, + ); + const recordActivity = (store as typeof store & { + recordActivity?: (entry: { + type: "task:updated"; + taskId: string; + taskTitle?: string; + details: string; + metadata?: Record<string, unknown>; + }) => Promise<unknown>; + }).recordActivity; + if (recordActivity) { + await recordActivity.call(store, { + type: "task:updated", + taskId, + taskTitle: task.title, + details: `Task marked as verified ${noOpMarker.kind}; no commits expected`, + metadata: { + taskId, + kind: noOpMarker.kind, + reason: noOpMarker.reason, + canonicalId: noOpMarker.canonicalId, + summary: params.summary, + runId: runContext?.runId, + agentId: runContext?.agentId, + }, + }).catch((error: unknown) => { + executorLog.warn(`${taskId}: failed to record no-op completion activity: ${error instanceof Error ? error.message : String(error)}`); + }); + } + } + onDone(); // Mark all pending/in-progress steps as done @@ -11604,7 +11981,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit task.id, `Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`, ); - this.setActiveWorkflowStepSession(task.id, session, worktreePath); + this.setActiveWorkflowStepSession(task.id, session, worktreePath, this.createSeenSteeringIds(task)); let output = ""; const deltaNormalizer = createStreamingDeltaNormalizer(); @@ -13797,6 +14174,51 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit try { const settings = await this.store.getSettings(); const preserveProgress = settings.preserveProgressOnStuckRequeue !== false; + const latestTask = await this.store.getTask(taskId); + const worktreePath = this.getWorktreePath(taskId) ?? latestTask.worktree; + await this.store.logEntry( + taskId, + `Force-kill cleanup starting after stuck-kill unwind timeout — reaping in-flight surfaces and worktree`, + ); + + // Spawned children must be terminated before the canonical reaper clears + // spawnedAgents bookkeeping; otherwise child agent sessions would be orphaned. + await this.terminateAllChildren(taskId).catch((err: unknown) => { + executorLog.warn(`${taskId}: spawned child cleanup failed during force-requeue: ${err instanceof Error ? err.message : String(err)}`); + }); + await this.awaitAbortInFlightTaskWork(taskId, "force-requeue after stuck-kill unwind timeout"); + // awaitAbortInFlightTaskWork marks pausedAborted as a generic hard-cancel + // signal. The force-requeue path has already handled the task move, so + // clear it to prevent a later subprocess unwind from logging/moving as a pause. + this.pausedAborted.delete(taskId); + + if (!preserveProgress) { + await this.resetStepsIfWorkLost(latestTask); + } + + let cleanupFailed = false; + if (worktreePath && existsSync(worktreePath)) { + try { + await removeWorktree({ + worktreePath, + rootDir: this.rootDir, + settings, + taskId, + reason: RemovalReason.ExecutorStuckKilled, + expectedOwnerTaskId: taskId, + liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path), + }); + executorLog.log(`${taskId}: removed worktree during force-requeue cleanup: ${worktreePath}`); + } catch (cleanupErr: unknown) { + cleanupFailed = true; + const cleanupErrMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr); + executorLog.warn(`${taskId}: worktree removal failed during force-requeue cleanup (${worktreePath}): ${cleanupErrMessage}`); + await this.store.logEntry(taskId, `Force-kill cleanup failed to remove worktree ${worktreePath}: ${cleanupErrMessage}`); + } + } + + this.activeWorktrees.delete(taskId); + await this.store.logEntry( taskId, `Force-requeued after stuck-kill: executor did not unwind within ${FORCE_REQUEUE_GRACE_MS / 1000}s (hung subprocess)${preserveProgress ? " — progress preserved" : ""}`, @@ -13808,16 +14230,23 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit branch: null, }); await this.store.moveTask(taskId, "todo", preserveProgress ? { preserveProgress: true } : undefined); - // Remove from executing so the scheduler can re-dispatch normally. - // The old Promise is still running but the executing guard is cleared so - // a fresh execute() call won't be blocked. + // Remove from executing only after the hung surfaces and worktree have + // been reaped, preventing a scheduler re-dispatch onto stale resources. this.executing.delete(taskId); executingTaskLock.release(taskId); this.stuckAborted.delete(taskId); - executorLog.log(`${taskId} force-requeued to todo`); + this.loopRecoveryState.delete(taskId); + await this.store.logEntry( + taskId, + cleanupFailed + ? "Force-kill cleanup completed with non-fatal worktree removal failure — task requeued" + : "Force-kill cleanup completed — in-flight surfaces reaped and task requeued", + ); + executorLog.log(`${taskId} force-requeued to todo after stuck-kill cleanup`); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); executorLog.error(`Failed to force-requeue stuck task ${taskId}: ${errorMessage}`); + await this.store.logEntry(taskId, `Force-kill cleanup failed during stuck-kill force-requeue: ${errorMessage}`).catch(() => undefined); } }, FORCE_REQUEUE_GRACE_MS); } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 76826ac8ae..70bd3a348d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -141,6 +141,12 @@ export { } from "./workflow-task-runtime.js"; export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js"; export { Scheduler, type SchedulerOptions } from "./scheduler.js"; +export { + claimDueWorkflowWorkItem, + type ClaimWorkflowWorkOptions, + type WorkflowWorkDispatch, + type WorkflowWorkSchedulerStore, +} from "./workflow-work-scheduler.js"; export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js"; diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index e06102db25..caf959f102 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -32,10 +32,10 @@ */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { readdirSync, realpathSync, rmSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, relative } from "node:path"; import { buildTaskLineageTrailer, getPrimaryPrInfo, @@ -64,6 +64,8 @@ import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run import { createLogger } from "./logger.js"; import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js"; import { activeSessionRegistry } from "./active-session-registry.js"; +import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js"; +import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js"; const execFileAsync = promisify(execFile); const aiMergeLog = createLogger("merger-ai"); @@ -105,55 +107,148 @@ function describeCleanupError(err: unknown): string { return stderr ? `${message}: ${stderr.trim()}` : message; } +export function isBenignAbsentWorktreeError(err: unknown): boolean { + const code = getErrorStringProperty(err, "code"); + if (code === "ENOENT") return true; + const description = describeCleanupError(err); + return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description); +} + +function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void { + const excludePath = join(projectRootDir, ".git", "info", "exclude"); + if (!existsSync(excludePath)) return; + try { + const current = readFileSync(excludePath, "utf-8"); + const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir); + const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot); + const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`]; + const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings); + const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot); + if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) { + entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`); + } + + const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry)); + if (missing.length > 0) { + appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`); + } + } catch { + // Best effort only: cleanup still removes the root contents, and existing + // projects generally ignore .fusion already. + } +} + +export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string { + const root = resolveAiMergeRootPath(projectRootDir, settings); + mkdirSync(root, { recursive: true }); + ensureAiMergeRootIgnored(projectRootDir, settings); + return root; +} + +function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] { + const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()]; + const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT; + if (testWorkerRoot) { + try { + for (const entry of readdirSync(testWorkerRoot)) { + if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry)); + } + } catch { + // Best effort for the test harness' bounded temp-dir redirection root. + } + } + return Array.from(new Set(roots)); +} + export async function pruneExistingAiMergeWorktrees( taskId: string, projectRootDir: string, audit: RunAuditor, log: (message: string) => Promise<void>, + settings?: Settings, ): Promise<number> { const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`; - const tempRoot = tmpdir(); - let entries: string[]; - try { - entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); - } catch (err: unknown) { - await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); - throw err; - } + const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings); let pruned = 0; - for (const entry of entries) { - const candidatePath = join(tempRoot, entry); - let canonicalPath = candidatePath; + let cleanupAttempted = false; + for (const tempRoot of tempRoots) { + let entries: string[]; try { - canonicalPath = realpathSync(candidatePath); - } catch { - canonicalPath = candidatePath; - } - - if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) { - await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`); + entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix)); + } catch (err: unknown) { + await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`); + if (tempRoot === tmpdir()) throw err; continue; } - try { - await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], { - cwd: projectRootDir, - timeout: 30_000, - }); - } catch (err: unknown) { - await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`); - } + for (const entry of entries) { + const candidatePath = join(tempRoot, entry); + let canonicalPath = candidatePath; + try { + canonicalPath = realpathSync(candidatePath); + } catch { + canonicalPath = candidatePath; + } + if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) { + await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`); + continue; + } + + try { + const stat = statSync(canonicalPath); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) { + await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`); + continue; + } + } catch (err: unknown) { + await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`); + continue; + } + + let alreadyAbsent = false; + try { + cleanupAttempted = true; + await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], { + cwd: projectRootDir, + timeout: 30_000, + }); + } catch (err: unknown) { + if (isBenignAbsentWorktreeError(err)) { + alreadyAbsent = true; + await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`); + } else { + await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`); + } + } + + try { + cleanupAttempted = true; + rmSync(canonicalPath, { recursive: true, force: true }); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); + pruned++; + } catch (err: unknown) { + if (isBenignAbsentWorktreeError(err)) { + await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } }); + pruned++; + continue; + } + const error = getErrorMessage(err); + const code = getErrorStringProperty(err, "code"); + await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } }); + } + } + } + + if (cleanupAttempted) { try { - rmSync(canonicalPath, { recursive: true, force: true }); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true } }); - pruned++; + await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 }); } catch (err: unknown) { - const error = getErrorMessage(err); - const code = getErrorStringProperty(err, "code"); - await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } }); + await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`); } } @@ -171,26 +266,75 @@ export async function cleanupAiMergeWorktree(input: { rmRunner?: typeof rm; }): Promise<void> { const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input; + let canonicalRoot = mergeRoot; + try { + canonicalRoot = realpathSync(mergeRoot); + } catch { + canonicalRoot = mergeRoot; + } + const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot]; + const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot }; + let alreadyAbsent = false; + if (worktreeAdded) { - try { - await gitRunner(["worktree", "remove", "--force", mergeRoot], projectRootDir); - await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "git-remove", success: true } }); - } catch (err: unknown) { - const error = describeCleanupError(err); - const code = getErrorStringProperty(err, "code"); - await log(`AI merge cleanup: git worktree remove failed for ${mergeRoot}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } }); + if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) { + alreadyAbsent = true; + await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } }); + } else { + try { + await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } }); + } catch (err: unknown) { + const error = describeCleanupError(err); + const code = getErrorStringProperty(err, "code"); + if (isBenignAbsentWorktreeError(err)) { + alreadyAbsent = true; + await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); + } else { + await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } }); + } + } } } - try { - await rmRunner(mergeRoot, { recursive: true, force: true }); - await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "fs-rm", success: true } }); - } catch (err: unknown) { - const error = getErrorMessage(err); - const code = getErrorStringProperty(err, "code"); - await log(`AI merge cleanup: filesystem rm failed for ${mergeRoot}${code ? ` (${code})` : ""}: ${error}`); - await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "fs-rm", success: false, error, ...(code ? { code } : {}) } }); + + let removedFromFilesystem = false; + for (const target of removalTargets) { + try { + await rmRunner(target, { recursive: true, force: true }); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } }); + removedFromFilesystem = true; + break; + } catch (err: unknown) { + const error = getErrorMessage(err); + const code = getErrorStringProperty(err, "code"); + if (isBenignAbsentWorktreeError(err)) { + await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } }); + removedFromFilesystem = true; + break; + } + await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } }); + } } + + if (!removedFromFilesystem) { + await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`); + } + + try { + await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 }); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } }); + } catch (err: unknown) { + const error = describeCleanupError(err); + const code = getErrorStringProperty(err, "code"); + await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`); + await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } }); + } + } const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; @@ -897,7 +1041,7 @@ export async function runAiMerge( await setStatus("merging"); try { - const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log); + const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log, settings); if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`); } catch (err: unknown) { await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`); @@ -908,11 +1052,31 @@ export async function runAiMerge( const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir); // 1. Clean-room worktree at the integration tip. - const mergeRoot = await mkdtemp(join(tmpdir(), `fusion-ai-merge-${taskId.toLowerCase()}-`)); + const mergeRoot = await mkdtemp(join(resolveAiMergeRoot(projectRootDir, settings), `fusion-ai-merge-${taskId.toLowerCase()}-`)); let worktreeAdded = false; + const registeredMergePaths = new Set<string>(); + const registerMergeRoot = (pathToRegister: string): void => { + if (registeredMergePaths.has(pathToRegister)) return; + activeSessionRegistry.registerPath(pathToRegister, { taskId, kind: "ai-merge", ownerKey: `ai-merge:${taskId}` }); + registeredMergePaths.add(pathToRegister); + }; + // Register the repo-local clean-room path as soon as it exists, before + // `git worktree add`, so self-healing/pre-merge sweeps cannot reap a + // just-created clean room in the small window before canonical registration + // is available. + registerMergeRoot(mergeRoot); try { await git(["worktree", "add", "--detach", mergeRoot, tipSha], projectRootDir); worktreeAdded = true; + let canonicalMergeRoot = mergeRoot; + try { + canonicalMergeRoot = realpathSync(mergeRoot); + } catch { + canonicalMergeRoot = mergeRoot; + } + for (const pathToRegister of new Set([canonicalMergeRoot, mergeRoot])) { + registerMergeRoot(pathToRegister); + } await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } }); await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`); @@ -948,6 +1112,9 @@ export async function runAiMerge( await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`); return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false }); } finally { + for (const registeredPath of registeredMergePaths) { + activeSessionRegistry.unregisterPath(registeredPath); + } await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log }); } } diff --git a/packages/engine/src/merger-integration-worktree.ts b/packages/engine/src/merger-integration-worktree.ts index a852ab363e..e830c593fc 100644 --- a/packages/engine/src/merger-integration-worktree.ts +++ b/packages/engine/src/merger-integration-worktree.ts @@ -71,6 +71,62 @@ export function resolveMergeIntegrationRoot( }; } +export type MergeIntegrationRootPreflightResult = + | { ok: true; resolution: MergeIntegrationRootResolution; checked: false | "reuse-task-worktree" } + | { + ok: false; + resolution: MergeIntegrationRootResolution; + checked: "reuse-task-worktree"; + reason: "missing-task-worktree" | "unusable-task-worktree"; + classification?: Awaited<ReturnType<typeof classifyTaskWorktree>>; + }; + +export interface EnsureUsableMergeIntegrationRootInput { + resolution: MergeIntegrationRootResolution; + projectRoot: string; +} + +/** + * Preflight the merge integration cwd before any merge-runner git spawn. + * + * In cwd/project-root mode this is intentionally a no-op: the project root is + * the stable repository checkout and the common path should not pay an extra + * stat or git-worktree-list probe. In reuse-task-worktree mode the resolved + * `task.worktree` is user/session mutable, so classify it before it can be + * used as `cwd`; callers can then reacquire/recreate the worktree instead of + * letting Node surface a misleading `spawn git ENOENT` for a vanished cwd. + */ +export async function ensureUsableMergeIntegrationRoot( + input: EnsureUsableMergeIntegrationRootInput, +): Promise<MergeIntegrationRootPreflightResult> { + if (input.resolution.mode !== "reuse-task-worktree") { + return { ok: true, resolution: input.resolution, checked: false }; + } + + const reusableRoot = input.resolution.rootDir.trim(); + if (!reusableRoot) { + return { + ok: false, + resolution: input.resolution, + checked: "reuse-task-worktree", + reason: "missing-task-worktree", + }; + } + + const classification = await classifyTaskWorktree(input.projectRoot, reusableRoot); + if (!classification.ok) { + return { + ok: false, + resolution: input.resolution, + checked: "reuse-task-worktree", + reason: "unusable-task-worktree", + classification, + }; + } + + return { ok: true, resolution: input.resolution, checked: "reuse-task-worktree" }; +} + export interface ResolveIntegrationRemoteInput { settings: Pick<ProjectSettings, "worktreeRebaseRemote">; rootDir: string; @@ -306,6 +362,21 @@ function asCentralClaimAccessor(store: TaskStore): { export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<HandoffResult> { const expectedBranch = canonicalFusionBranchName(input.task.id); const worktreePath = input.worktreePath; + const preflight = await ensureUsableMergeIntegrationRoot({ + resolution: { + mode: "reuse-task-worktree", + rootDir: worktreePath, + branchName: expectedBranch, + }, + projectRoot: input.projectRoot, + }); + if (!preflight.ok) { + throw new MergeHandoffRefusedError("integration-root-preflight", preflight.reason, { + taskId: input.task.id, + worktreePath, + classification: preflight.classification ?? null, + }); + } if (canonicalizePath(worktreePath) === canonicalizePath(input.projectRoot)) { throw new MergeHandoffRefusedError("reuse-misconfigured", "worktree-equals-project-root", { taskId: input.task.id, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 81fe69852a..1cb1390870 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -129,6 +129,7 @@ import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from " import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js"; import { acquireReuseHandoff, + ensureUsableMergeIntegrationRoot, MergeHandoffRefusedError, probeIntegrationWorktreeState, releaseReuseHandoff, @@ -4055,6 +4056,7 @@ async function buildDeterministicMergeMessage(params: { } export { buildDeterministicMergeMessage as __testOnlyBuildDeterministicMergeMessage }; +export { resolveSafeCommitBody as __testOnlyResolveSafeCommitBody }; export { resolveComplexRebaseConflictsWithAi as __testOnlyResolveComplexRebaseConflictsWithAi }; /** @@ -4967,18 +4969,31 @@ export class FileScopeViolationError extends Error { } } +export type StagedFilesReader = (cwd: string) => Promise<string[]>; + +async function readStagedFileNames(cwd: string): Promise<string[]> { + const { stdout } = await execAsync("git diff --cached --name-only", { + cwd, + encoding: "utf-8", + }); + return stdout.split("\n").map((line) => line.trim()).filter(Boolean); +} + export async function assertSquashOverlapsFileScope(params: { store: TaskStore; taskId: string; rootDir: string; task: Task; + /** Test seam for deterministic file-scope invariant coverage. Production + * callers use the default real-git staged-file reader. */ + stagedFilesReader?: StagedFilesReader; /** U7 (R10): when the merge trait's `fileScope: "custom"` mode is active, * these glob/path rules replace the task's File Scope section as the * declared scope. `scopeOverride` is a documented no-op only under * `fileScope: "off"` (handled by the caller, which skips this assert). */ customScopeRules?: string[]; }): Promise<void> { - const { store, taskId, rootDir, task, customScopeRules } = params; + const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params; const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0; if (!hasCustomRules && task.scopeOverride === true) { @@ -5009,11 +5024,7 @@ export async function assertSquashOverlapsFileScope(params: { return; } - const { stdout } = await execAsync("git diff --cached --name-only", { - cwd: rootDir, - encoding: "utf-8", - }); - const stagedFiles = stdout.split("\n").map((line) => line.trim()).filter(Boolean); + const stagedFiles = await stagedFilesReader(rootDir); const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope)); if (!hasOverlap) { throw new FileScopeViolationError(taskId, stagedFiles, declaredScope); @@ -5037,6 +5048,7 @@ export async function enforceSquashFileScopeInvariant(params: { rootDir: string; task: Task; resetLabel: string; + stagedFilesReader?: StagedFilesReader; auditor?: RunAuditor; }): Promise<void> { // U7 (R10): resolve the file-scope enforcement mode from the merge trait @@ -6671,25 +6683,12 @@ async function resolveSafeCommitBody(opts: { const cleanStat = opts.diffStat.trim(); if (cleanStat.length > 0) { if (opts.settings.useAiMergeCommitSummary) { - // Prefer the dedicated title-summarization model — a small, fast tier - // intended for short summarization. Falls back to the project / global - // default model when the summarizer lane isn't configured. The core - // `summarizeCommitBody` helper handles missing-runtime / timeout / empty - // response gracefully and returns null. - const useTitleSummarizer = - !!opts.settings.titleSummarizerProvider && !!opts.settings.titleSummarizerModelId; - const provider = useTitleSummarizer - ? opts.settings.titleSummarizerProvider! - : (opts.settings.defaultProviderOverride && opts.settings.defaultModelIdOverride - ? opts.settings.defaultProviderOverride - : opts.settings.defaultProvider); - const modelId = useTitleSummarizer - ? opts.settings.titleSummarizerModelId! - : (opts.settings.defaultProviderOverride && opts.settings.defaultModelIdOverride - ? opts.settings.defaultModelIdOverride - : opts.settings.defaultModelId); + // Prefer the dedicated title-summarization lane and its documented + // fallbacks. The core `summarizeCommitBody` helper handles missing-runtime + // / timeout / empty response gracefully and returns null. + const resolved = resolveTitleSummarizerSettingsModel(opts.settings); - const ai = await summarizeCommitBody(cleanStat, opts.rootDir, provider, modelId, { + const ai = await summarizeCommitBody(cleanStat, opts.rootDir, resolved.provider, resolved.modelId, { branch: opts.branch, taskId: opts.taskId, signal: opts.signal, @@ -7313,15 +7312,20 @@ async function removePostMergeWorktree( postMergeWorktree: string, taskId: string, settings: Partial<Settings>, + audit?: RunAuditor, ): Promise<void> { try { - await removeWorktree({ + const outcome = await removeWorktree({ rootDir, worktreePath: postMergeWorktree, settings, taskId, reason: RemovalReason.MergerPostMerge, + audit, }); + if ("harmless" in outcome && outcome.harmless) { + mergerLog.warn(`${taskId}: post-merge worktree cleanup classified harmless for ${postMergeWorktree}: ${outcome.message}`); + } } catch (err: unknown) { mergerLog.warn(`${taskId}: failed to remove post-merge worktree ${postMergeWorktree}: ${getCommandErrorMessage(err)}`); } @@ -8170,19 +8174,15 @@ export async function aiMergeTask( let reuseHandoff: HandoffResult | undefined; if (integrationRoot.mode === "reuse-task-worktree") { - const reusableWorktreePath = task.worktree?.trim(); - if (!reusableWorktreePath) { - await reacquireReuseIntegrationWorktree("missing-task-worktree", { + const preflight = await ensureUsableMergeIntegrationRoot({ + resolution: integrationRoot, + projectRoot: projectRootDir, + }); + if (!preflight.ok) { + await reacquireReuseIntegrationWorktree(preflight.reason, { requestedMode: requestedIntegrationMode, + classification: preflight.classification ?? null, }); - } else { - const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath); - if (!classification.ok) { - await reacquireReuseIntegrationWorktree("unusable-task-worktree", { - requestedMode: requestedIntegrationMode, - classification, - }); - } } } @@ -10522,7 +10522,7 @@ export async function aiMergeTask( // Non-fatal — task still moves to done } finally { if (postMergeWorktree) { - await removePostMergeWorktree(rootDir, postMergeWorktree, taskId, settings); + await removePostMergeWorktree(rootDir, postMergeWorktree, taskId, settings, audit); } } } @@ -10574,7 +10574,7 @@ export async function aiMergeTask( metadata: { taskId, reason: RemovalReason.MergerCleanup, kind: "merger" }, }); } else { - await removeWorktree({ + const outcome = await removeWorktree({ rootDir, worktreePath, settings, @@ -10582,7 +10582,10 @@ export async function aiMergeTask( audit, reason: RemovalReason.MergerCleanup, }); - result.worktreeRemoved = true; + if ("harmless" in outcome && outcome.harmless) { + mergerLog.warn(`${taskId}: merge worktree cleanup classified harmless for ${worktreePath}: ${outcome.message}`); + } + result.worktreeRemoved = outcome.removed || ("harmless" in outcome && outcome.harmless); } if (result.worktreeRemoved) { try { diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index 4fcd10bc85..5baf42a89b 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -34,7 +34,7 @@ import { mergeEffectiveSettings } from "./effective-settings.js"; import { createResolvedAgentSession, extractRuntimeHint, - extractRuntimeModel, + resolveValidatorSessionModel, } from "./agent-session-helpers.js"; import { createLogger } from "./logger.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; @@ -799,30 +799,12 @@ export class MissionExecutionLoop extends EventEmitter { settings: Partial<Settings> | undefined, assignedAgentRuntimeConfig?: Record<string, unknown>, ): { provider: string | undefined; modelId: string | undefined } { - if (isTestModeActive(settings)) { - return { - provider: TEST_MODE_RESOLVED.provider, - modelId: TEST_MODE_RESOLVED.modelId, - }; - } - - const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); - if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { - return assignedRuntimeModel; - } - - const resolvedTaskModel = resolveTaskValidatorModel( - { - validatorModelProvider: task?.validatorModelProvider, - validatorModelId: task?.validatorModelId, - }, + return resolveValidatorSessionModel( + task?.validatorModelProvider, + task?.validatorModelId, settings, + assignedAgentRuntimeConfig, ); - - return { - provider: resolvedTaskModel.provider, - modelId: resolvedTaskModel.modelId, - }; } /** diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index c5e7646afd..790c863501 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1080,6 +1080,7 @@ interface PackageManagerSettingsView { getGlobalSettings(): Record<string, any>; getProjectSettings(): Record<string, any>; getNpmCommand(): string[] | undefined; + isProjectTrusted(): boolean; } function readJsonObject(path: string): Record<string, any> { @@ -1258,7 +1259,7 @@ function siblingAgentDir(agentDir: string, siblingRoot: ".fusion" | ".pi"): stri return join(dirname(dirname(agentDir)), siblingRoot, "agent"); } -function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView { +export function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView { const projectRoot = resolvePiExtensionProjectRoot(cwd); const fusionAgentDir = agentDir.includes(`${join(".fusion", "agent")}`) ? agentDir @@ -1279,6 +1280,10 @@ function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageMan getNpmCommand: () => Array.isArray(mergedSettings.npmCommand) ? [...mergedSettings.npmCommand] : undefined, + // Pi's SettingsManager defaults projects to trusted. Fusion workspaces are + // user-owned, so preserve pre-upgrade behavior and keep project-scoped + // .fusion resources loadable through the read-only settings view. + isProjectTrusted: () => true, }; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 578d9d7d00..e50a60c234 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -9,6 +9,9 @@ import type { AutomationStore as AutomationStoreType, ScheduledTask, AutomationRunResult, + ResearchModelSettings, + ResearchSynthesisRequest, + ResearchSynthesisResult, } from "@fusion/core"; import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; import { execFile } from "node:child_process"; @@ -36,6 +39,7 @@ import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchRunDispatcher } from "./research-dispatcher.js"; import { ResearchStepRunner } from "./research-step-runner.js"; +import { ResearchProviderRegistry } from "./research/provider-registry.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { computeVerificationFailureSignature, @@ -387,6 +391,7 @@ export class ProjectEngine { private taskUpdatedHandler?: (...args: any[]) => void; private taskDeletedHandler?: (...args: any[]) => void; private autostashOrphansHandler?: (...args: any[]) => void; + private legacyAutoMergeStampAdvisoryEmitted = false; constructor( private config: ProjectRuntimeConfig, @@ -457,9 +462,26 @@ export class ProjectEngine { const settings = await store.getSettings(); if (typeof (store as { getResearchStore?: () => unknown }).getResearchStore === "function") { + const registry = new ResearchProviderRegistry(settings, cwd); + const providers = registry.getAvailableProviders() + .map((type) => registry.getProvider(type)) + .filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)); + const synthesisProvider = registry.getProvider("llm-synthesis") as ({ + synthesize?: ( + request: ResearchSynthesisRequest, + modelSelection: { provider?: string; modelId?: string }, + signal?: AbortSignal, + ) => Promise<ResearchSynthesisResult>; + } | undefined); + const synthesisRunner = typeof synthesisProvider?.synthesize === "function" + ? (request: ResearchSynthesisRequest, _modelSettings: ResearchModelSettings, signal?: AbortSignal) => synthesisProvider.synthesize!(request, { + provider: settings.researchGlobalDefaults?.synthesisProvider ?? settings.defaultProvider, + modelId: settings.researchGlobalDefaults?.synthesisModelId ?? settings.defaultModelId, + }, signal) + : undefined; this.researchOrchestrator = new ResearchOrchestrator({ store: store.getResearchStore(), - stepRunner: new ResearchStepRunner(), + stepRunner: new ResearchStepRunner({ providers, synthesisRunner }), maxConcurrentRuns: settings.researchMaxConcurrentRuns ?? 3, }); this.researchDispatcher = new ResearchRunDispatcher({ @@ -1618,6 +1640,43 @@ export class ProjectEngine { return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); } + private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise<void> { + if (this.legacyAutoMergeStampAdvisoryEmitted) { + return; + } + this.legacyAutoMergeStampAdvisoryEmitted = true; + + try { + const candidates = (await store.listTasks({ column: "in-review" })) + .filter((task) => task.autoMerge === true && task.autoMergeProvenance !== "user"); + if (candidates.length === 0) { + return; + } + + const taskIds = candidates.map((task) => task.id); + runtimeLog.warn( + `Global auto-merge was turned off, but ${taskIds.length} legacy in-review task(s) still have task.autoMerge=true without user provenance and may continue to auto-merge: ${taskIds.join(", ")}. Run reconcileLegacyAutoMergeStamps({ apply: true }) to clear these legacy stamps after review.`, + ); + store.recordRunAuditEvent({ + agentId: "system", + runId: `legacy-auto-merge-stamp-advisory-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-advisory", + target: "settings.autoMerge", + metadata: { + taskIds, + candidateCount: taskIds.length, + recommendation: "Run reconcileLegacyAutoMergeStamps({ apply: true }) to clear legacy stamps after operator review.", + changedTaskState: false, + }, + }); + } catch (err: unknown) { + runtimeLog.warn( + `Legacy auto-merge stamp advisory failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number { const eligible = sortTasksByPriorityThenAgeAndId( tasks.filter((t) => !t.paused && this.canMergeTask(t as any) && this.allowInReviewMergeProcessing(t, settings)) as Task[], @@ -3295,7 +3354,23 @@ export class ProjectEngine { store.on("settings:updated", onGlobalPause); this.settingsHandlers.push(onGlobalPause); - // 3. Global unpause — resume orphaned tasks + sweep in-review + // 3. Auto-merge OFF — legacy pre-provenance stamps are ambiguous, so only + // advise operators about clearable candidates; do not mutate task state. + const onAutoMergeDisabled = async ({ + settings: s, + previous: prev, + }: { + settings: Settings; + previous: Settings; + }) => { + if (prev.autoMerge !== false && s.autoMerge === false) { + await this.emitLegacyAutoMergeStampAdvisory(store); + } + }; + store.on("settings:updated", onAutoMergeDisabled); + this.settingsHandlers.push(onAutoMergeDisabled); + + // 4. Global unpause — resume orphaned tasks + sweep in-review const onGlobalUnpause = async ({ settings: s, previous: prev, @@ -3311,7 +3386,7 @@ export class ProjectEngine { store.on("settings:updated", onGlobalUnpause); this.settingsHandlers.push(onGlobalUnpause); - // 4. Engine unpause — same as global unpause + // 5. Engine unpause — same as global unpause const onEngineUnpause = async ({ settings: s, previous: prev, @@ -3327,7 +3402,7 @@ export class ProjectEngine { store.on("settings:updated", onEngineUnpause); this.settingsHandlers.push(onEngineUnpause); - // 5. Maintenance interval change — reschedule mergeActive reconciliation + // 6. Maintenance interval change — reschedule mergeActive reconciliation const onMaintenanceIntervalChange = ({ settings: s, previous: prev, @@ -3347,7 +3422,7 @@ export class ProjectEngine { store.on("settings:updated", onMaintenanceIntervalChange); this.settingsHandlers.push(onMaintenanceIntervalChange); - // 6. Stuck task timeout change — trigger immediate check + // 7. Stuck task timeout change — trigger immediate check const onStuckTimeoutChange = async ({ settings: s, previous: prev, @@ -3373,7 +3448,7 @@ export class ProjectEngine { store.on("settings:updated", onStuckTimeoutChange); this.settingsHandlers.push(onStuckTimeoutChange); - // 7. Memory maintenance settings change — sync automations + // 8. Memory maintenance settings change — sync automations const onInsightSettingsChange = async ({ settings: s, previous: prev, @@ -3419,7 +3494,7 @@ export class ProjectEngine { store.on("settings:updated", onInsightSettingsChange); this.settingsHandlers.push(onInsightSettingsChange); - // 8. Auto-summarize settings change — sync automation + // 9. Auto-summarize settings change — sync automation const onAutoSummarizeSettingsChange = async ({ settings: s, previous: prev, @@ -3455,7 +3530,7 @@ export class ProjectEngine { store.on("settings:updated", onAutoSummarizeSettingsChange); this.settingsHandlers.push(onAutoSummarizeSettingsChange); - // 9. Scheduled eval settings change — sync automation + // 10. Scheduled eval settings change — sync automation const onScheduledEvalSettingsChange = async ({ settings: s, previous: prev, diff --git a/packages/engine/src/prompt-layers.ts b/packages/engine/src/prompt-layers.ts index d307afd735..569627e5ef 100644 --- a/packages/engine/src/prompt-layers.ts +++ b/packages/engine/src/prompt-layers.ts @@ -17,7 +17,7 @@ export interface SystemPromptLayers { } export interface PromptLayerInput { - /** The base role system prompt (e.g. REVIEWER_SYSTEM_PROMPT). */ + /** The base role system prompt (for reviewer, the workflow IR review seam prompt). */ basePrompt: string; /** Resolved agent instructions (instructionsText + instructionsPath + soul). */ agentInstructions?: string; diff --git a/packages/engine/src/research-orchestrator.ts b/packages/engine/src/research-orchestrator.ts index 72be515a89..0fd4e76573 100644 --- a/packages/engine/src/research-orchestrator.ts +++ b/packages/engine/src/research-orchestrator.ts @@ -77,7 +77,7 @@ export class ResearchOrchestrator { const run = this.store.getRun(runId); if (!run) throw new Error(`Research run not found: ${runId}`); - const config = (run.providerConfig ?? {}) as unknown as ResearchOrchestrationConfig; + const config = this.normalizeConfig(run.providerConfig); const controller = new AbortController(); if (options.abortSignal) { options.abortSignal.addEventListener("abort", () => controller.abort(options.abortSignal?.reason), { once: true }); @@ -542,6 +542,53 @@ export class ResearchOrchestrator { return 1 + providers + Math.max(1, config.maxSources) + Math.max(1, config.maxSynthesisRounds) + 1; } + private normalizeConfig(rawConfig: ResearchRun["providerConfig"]): ResearchOrchestrationConfig { + const raw = (rawConfig ?? {}) as Record<string, unknown>; + const rawProviders = Array.isArray(raw.providers) ? raw.providers : []; + const providers = rawProviders + .map((provider): ResearchOrchestrationConfig["providers"][number] | null => { + if (typeof provider === "string") { + const type = provider.trim(); + return type && type !== "llm-synthesis" ? { type } : null; + } + if (provider && typeof provider === "object") { + const candidate = provider as { type?: unknown; config?: unknown }; + if (typeof candidate.type === "string" && candidate.type.trim() && candidate.type !== "llm-synthesis") { + return { + type: candidate.type.trim(), + config: candidate.config && typeof candidate.config === "object" + ? candidate.config as ResearchOrchestrationConfig["providers"][number]["config"] + : undefined, + }; + } + } + return null; + }) + .filter((provider): provider is ResearchOrchestrationConfig["providers"][number] => Boolean(provider)); + + const maxSources = this.positiveNumber(raw.maxSources) ?? this.positiveNumber(raw.maxResults) ?? 20; + const maxSynthesisRounds = this.positiveNumber(raw.maxSynthesisRounds) ?? 2; + + return { + providers: providers.length ? providers : [{ type: "web-search" }], + maxSources, + maxSynthesisRounds, + phaseTimeoutMs: this.positiveNumber(raw.phaseTimeoutMs), + stepTimeoutMs: this.positiveNumber(raw.stepTimeoutMs), + rateLimitPerMinute: this.positiveNumber(raw.rateLimitPerMinute), + synthesisModel: raw.synthesisModel && typeof raw.synthesisModel === "object" + ? raw.synthesisModel as ResearchOrchestrationConfig["synthesisModel"] + : undefined, + metadata: raw.metadata && typeof raw.metadata === "object" + ? raw.metadata as Record<string, unknown> + : undefined, + }; + } + + private positiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; + } + private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase { if (status === "completed") return "completed"; if (status === "failed" || status === "timed_out" || status === "retry_exhausted") return "failed"; diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 1aa9662fe2..1730c5d1d3 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -1,4 +1,4 @@ -// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the reviewer prompt. +// port-4040-allowlist: reviewer prompts resolve from @fusion/core agent-prompts, which embeds the "never kill port 4040" rule. /** * Reviewer — spawns a separate pi agent to review a worker's plan or code. * @@ -10,7 +10,13 @@ */ import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core"; -import { buildReviewerMemoryInstructions, resolveAgentPrompt, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core"; +import { + buildReviewerMemoryInstructions, + resolveAgentMemoryInclusionMode, + resolveAgentPrompt, + resolvePersistAgentThinkingLog, + resolveTaskSeamPrompt, +} from "@fusion/core"; import { recordRetry } from "./retry-burned-logger.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { describeModel, promptWithFallback } from "./pi.js"; @@ -29,201 +35,6 @@ import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { createMemoryGetTool, createMemorySearchTool, createWebFetchTool } from "./agent-tools.js"; -export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer. - -## Your Role -You are an objective quality gate for plans, code, and specs. -You are neither the implementor's advocate nor adversary: your job is evidence-based assessment that protects delivery quality. - -You provide quality assessment for task implementations. You have full read -access to the codebase and can run commands to inspect code. - -## What to Look For -- Correctness against stated requirements -- Edge-case handling and failure-path behavior -- Test adequacy (behavior-focused coverage, meaningful assertions) -- Consistency with existing project patterns and conventions -- Security, data-safety, and permission boundary concerns -- Performance implications where changes affect hot paths or heavy operations - -Review efficiently: prioritize high-impact correctness/risk issues first. Do not spend blocking attention on style nits when substantive defects exist. - -## Verdict Criteria - -- **APPROVE** — Step will achieve its stated outcomes. Minor suggestions go in - the Suggestions section but do NOT block progress. If your only findings are - minor or suggestion-level, verdict is APPROVE. -- **REVISE** — Step will fail, produce incorrect results, or miss a stated - requirement without fixes. Use ONLY for issues that would cause the worker to - redo work later. -- **RETHINK** — Approach is fundamentally wrong. Explain why and suggest an - alternative. - -### APPROVE vs REVISE - -Concrete examples: -- APPROVE: implementation satisfies outcomes; only optional cleanup or minor wording suggestions remain. -- REVISE: a required behavior is missing, tests are insufficient for changed behavior, or a likely regression exists. -- RETHINK: the approach conflicts with architecture/task goals such that incremental edits are unlikely to rescue it. - -**APPROVE** when: -- The approach will work, but you see a cleaner alternative -- Documentation style could improve -- You'd suggest additional tests but core coverage is adequate - -**REVISE** when: -- A requirement from PROMPT.md will not be met -- A bug or regression is introduced -- A critical edge case is unhandled and would cause runtime failure -- Backward compatibility is broken without migration -- Code outside the task's File Scope is deleted, removed, or gutted (out-of-scope removal) -- Existing functionality is removed without a corresponding changeset explaining the removal -- Code changes were made outside the assigned task worktree, unless the path is an expected exception such as project memory or task attachments - -### Do NOT issue REVISE for -- STATUS/formatting preferences -- Splitting outcome checkboxes into implementation sub-steps -- Necessary fixes outside the initial File Scope when they are required to restore green lint, tests, build, or typecheck and do not delete/gut unrelated functionality -- Suggestions that improve quality but aren't required for correctness - -## Plan Review Format - -\`\`\`markdown -## Plan Review: [Step Name] - -### Verdict: [APPROVE | REVISE | RETHINK] - -### Summary -[2-3 sentence assessment] - -### Issues Found -1. **[Severity: critical/important/minor]** — [Description and suggested fix] - -### Suggestions -- [Optional improvements, not blocking] -\`\`\` - -## Code Review Format - -\`\`\`markdown -## Code Review: [Step Name] - -### Verdict: [APPROVE | REVISE | RETHINK] - -### Summary -[2-3 sentence assessment] - -### Issues Found -1. **[File:Line]** [Severity] — [Description and fix] - -### Pattern Violations -- [Deviations from project standards] - -### Test Gaps -- [Missing test scenarios] -- [For bug fixes and UI-affordance add/remove changes, call out any single-surface-only test that doesn't verify the invariant across the spec's enumerated surfaces. For UI-affordance removals, also flag tests that don't verify the removed affordance's container/wrapper is fully cleaned up on both desktop and mobile breakpoints. Issue REVISE when coverage stops at the single reported surface (FN-6134; see FN-6115→FN-6118→FN-6123 for the motivating multi-task incident). Keep enforcing FN-5893 for bug fixes; see FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751.] - -### Suggestions -- [Optional improvements, not blocking] -\`\`\` - -## Spec Review Format - -\`\`\`markdown -## Spec Review: [Task ID] - -### Verdict: [APPROVE | REVISE | RETHINK] - -### Summary -[2-3 sentence assessment of the specification quality] - -### Issues Found -1. **[Severity: critical/important/minor]** — [Description and suggested fix] - -### Criteria Assessment -- **Mission clarity:** [Clear, unambiguous mission statement?] -- **Step specificity:** [Steps have verifiable, concrete outcomes?] -- **File scope accuracy:** [All affected files listed? No extras?] -- **Dependency correctness:** [Dependencies exist and are appropriate?] -- **Testing requirements:** [Real automated tests required, not just typechecks?] -- **Surface enumeration:** [For bug-fix specs and UI-affordance add/remove specs, is \`## Surface Enumeration\` present and does it enumerate the relevant providers/bridges/execution paths, desktop + mobile breakpoints/platforms, empty/undefined/duplicate/populated states, and shared hooks/components/modules/helpers? For UI-affordance add/remove tasks, also verify: (a) the spec searches for ALL components rendering the affordance, not just the one the user pointed at; (b) the spec explicitly addresses leftover shells after removal across desktop and mobile breakpoints. Missing or incomplete coverage is a blocking REVISE.] -- **Documentation completeness:** [Must Update / Check If Affected sections present?] -- **Dangling task-document references:** [No \`.fusion/tasks/<id>/<file>\` path is cited in Context, Steps, or File Scope unless the file exists or is explicitly created as a \`(new)\` artifact in this spec. References to nonexistent task-local artifacts are a blocking REVISE.] -- **Sizing & review level:** [Size and review level appropriate for the work?] -- **Subtask breakdown:** [Only flag genuinely oversized specs (12+ implementation steps, OR 5+ truly independent deliverables that could ship separately). Do NOT flag a coherent vertical change just because it touches multiple packages. When borderline, prefer leaving the task whole.] -- **User comment coverage:** [Were all user comments addressed? Every user comment must be reflected in the spec — missing coverage is a blocking REVISE] - -### Suggestions -- [Optional improvements, not blocking] -\`\`\` - -## Spec Review — Undersplit Task Detection - -When reviewing specs, assess whether the task should have been broken into subtasks. The bar for splitting is high — most tasks should remain whole. Coordination overhead (worktrees, dependency wiring, merge sequencing) is real, so splitting must clearly pay for itself. - -**Default position:** do NOT flag undersplit. Reach for it only when the spec is genuinely oversized. - -**Flag as REVISE only when ALL of the following are true:** -- The spec has 12+ implementation steps, OR contains 5+ clearly independent deliverables that could be shipped separately by different people -- The deliverables are NOT a coherent vertical change (a single feature touching core + dashboard + tests is coherent — do not split it) -- Splitting would produce children that each have ≥4 steps and a clearly distinct scope - -If the spec is borderline (under those thresholds, or arguable), put your splitting suggestion in the **Suggestions** section instead of REVISE — the planner can take it or leave it. - -**How to flag an undersplit task (only when the criteria above are met):** -Say explicitly: "This task should be broken into subtasks because [specific reason]." -Recommend the number of child tasks (2-5) and what each should cover. -Instruct the planner to: -1. Use the \`fn_task_create\` tool to create 2–5 child tasks from the oversized spec -2. Do NOT write a parent PROMPT.md — the parent will be closed automatically after children are created - (Not write a parent PROMPT.md is also unacceptable.) -3. Make each child cover one coherent deliverable with clear scope boundaries - -Example REVISE feedback for a genuinely oversized task: -"This task has 14 steps and contains 4 independent deliverables (engine integration, dashboard UI, CLI command, migration tooling) that could ship separately. Use fn_task_create to split into: (1) engine logic, (2) dashboard UI, (3) CLI integration, (4) migration tooling. Do not write a parent PROMPT." - -**Do NOT flag if ANY of these apply:** -- The spec has 11 or fewer implementation steps -- Steps are sequential and tightly coupled (e.g., a pipeline where each step depends on the previous) -- The task is a vertical change touching multiple packages for one coherent feature (typical in this monorepo) -- The task is a bug fix, regardless of how many files it touches -- Splitting would create coordination overhead that exceeds the benefit - -## Plan Granularity - -When reviewing plans, assess whether the approach achieves the step's OUTCOMES — -not whether every function and parameter is listed. - -Good plan: identifies key behavioral changes, calls out risks, has a testing strategy. -Do NOT demand function-level implementation checklists. - -## Test Quality Review - -When reviewing tests, check that they verify observable behavior and regression risk (not only implementation trivia). -Flag REVISE when key edge cases or failure modes for changed behavior are untested. -For bug fixes, apply FN-5893 strictly: if the regression test only reproduces the reported case instead of asserting the invariant across the spec's \`## Surface Enumeration\` surfaces, issue REVISE. Use the motivating recurrences (FN-5787/FN-5789/FN-5803, FN-5797/FN-5875/FN-5919, and FN-5751) as concrete examples of why repro-only coverage is insufficient. -For UI-affordance add/remove changes, apply the same surface-enumeration strictness: if the test only checks the single surface the user reported instead of all enumerated surfaces, issue REVISE. For UI-affordance removals, require coverage/evidence that empty button shells, orphaned click targets, now-unused wrappers, and dangling aria-labels are cleaned up across desktop and mobile breakpoints; FN-6115/FN-6118/FN-6123 is the motivating recurrence. - -## Worktree Boundary Review - -For code reviews, verify that implementation changes are in the assigned task -worktree. The review request includes the current worktree path. Inspect git -state and recent commits from that worktree, and treat changes outside it as a -blocking REVISE unless they are expected project-root state such as -\`.fusion/memory/\` files, task attachments, or other explicitly documented -Fusion metadata. If you see edits or commits in the primary project checkout -instead of the task worktree, call that out directly and ask the worker to move -the changes into the assigned worktree. - -## Rules - -- Be specific — reference actual files and line numbers -- Be constructive — suggest fixes, not just problems -- Be proportional — don't block on style nits -- Output your review as plain text (not to a file) -- **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. If you need to test server endpoints, start a server on a different port (\`--port 0\` for random). If port 4040 is occupied, use a different port — do NOT kill the occupant. Issue REVISE if the executor kills or attempts to kill processes on port 4040. -`; - export type ReviewType = "plan" | "code" | "spec"; export type ReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE"; @@ -407,7 +218,15 @@ export async function reviewStep( // Graceful fallback } } - const reviewerBasePrompt = resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT; + const userReviewerPrompt = options.agentPrompts?.roleAssignments?.reviewer + ? resolveAgentPrompt("reviewer", options.agentPrompts) + : ""; + const workflowReviewerPrompt = options.store + ? await resolveTaskSeamPrompt(options.store, taskId, "review").catch(() => undefined) + : undefined; + // FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node; + // explicit reviewer role overrides still win, and the built-in default keeps this fail-soft. + const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer"); const memorySection = options.rootDir && options.settings?.memoryEnabled !== false ? buildReviewerMemoryInstructions(options.rootDir, options.settings) : ""; diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 21465a436f..611d4dea4e 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -91,6 +91,10 @@ export interface EngineRunContext { export type GitMutationType = | "worktree:create" | "worktree:remove" + | "worktree:remove-fallback" + | "worktree:remove-classified-harmless" + | "worktree:remove-classification-probe-failed" + | "worktree:remove-leaked-registered-worktree" | "worktree:reuse" | "worktree:incomplete-detected" | "worktree:reanchored" @@ -503,6 +507,7 @@ export type DatabaseMutationType = /** Metadata: { taskId, branch, worktree, checkedOutBy, executionStartedAt, executionAgeMs, graceMs, liveWorktreeBoundBranch, reason } */ | "task:reclaim-self-owned-branch-conflict-no-action" | "task:orphan-detected-no-action" + | "task:reattach-orphaned-execution" /** Metadata: { taskId, lastReason, stuckKillCount, attemptedStuckKillCount, maxStuckKills, checkedOutBy, executionStartedAt, executionAgeMs, graceMs, liveWorktreeBoundBranch } */ | "task:stuck-loop-exhausted-no-action" /** Metadata: { taskId: string; ignoredStepUpdateCount: number; stuckKillStreak: number; lastReason: "no-progress-churn" } */ diff --git a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts index 0dcbefca34..f4c3aaf39a 100644 --- a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts +++ b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts @@ -18,6 +18,7 @@ const { mockRecoverInterruptedRuns, mockExecutorCtor, mockResumeOrphaned, + mockResumeTaskForAgent, mockTaskStoreSettings, mockTaskStoreGetTask, mockTaskStoreUpdateSettings, @@ -36,6 +37,7 @@ const { mockRecoverInterruptedRuns: vi.fn().mockResolvedValue(undefined), mockExecutorCtor: vi.fn(), mockResumeOrphaned: vi.fn().mockResolvedValue(undefined), + mockResumeTaskForAgent: vi.fn().mockResolvedValue(undefined), mockTaskStoreSettings: {} as Record<string, unknown>, mockTaskStoreGetTask: vi.fn().mockResolvedValue(null), mockTaskStoreUpdateSettings: vi.fn().mockResolvedValue(undefined), @@ -193,6 +195,7 @@ vi.mock("../../executor.js", async () => { mockExecutorCtor(options); const self = {} as Record<string, unknown>; self.resumeOrphaned = mockResumeOrphaned; + self.resumeTaskForAgent = mockResumeTaskForAgent; self.recoverCompletedTask = vi.fn().mockResolvedValue(true); self.getExecutingTaskIds = vi.fn().mockReturnValue(new Set()); self.handleLoopDetected = vi.fn().mockResolvedValue(false); @@ -244,6 +247,8 @@ describe("InProcessRuntime", () => { } mockTaskStoreGetTask.mockReset(); mockTaskStoreGetTask.mockResolvedValue(null); + mockResumeTaskForAgent.mockReset(); + mockResumeTaskForAgent.mockResolvedValue(undefined); mockIsGitRepository.mockReset(); mockIsGitRepository.mockResolvedValue(true); mockReapOrphanWorktrees.mockReset(); @@ -699,6 +704,25 @@ describe("InProcessRuntime", () => { }); describe("trigger scheduler wiring", () => { + it("composes run-completion resume with deferred assignment drain", async () => { + await runtime.start(); + const store = getAgentStore(runtime); + const agent = await store.createAgent({ name: "completion-wiring", role: "executor" }); + const monitor = runtime.getHeartbeatMonitor(); + const triggerScheduler = runtime.getTriggerScheduler(); + expect(monitor).toBeDefined(); + expect(triggerScheduler).toBeDefined(); + const drainSpy = vi.spyOn(triggerScheduler!, "drainPendingAssignment").mockResolvedValue(undefined); + + const run = await monitor!.startRun(agent.id, { source: "timer" }); + await monitor!.completeRun(agent.id, run.id, { status: "completed" }); + + await vi.waitFor(() => { + expect(mockResumeTaskForAgent).toHaveBeenCalledWith(agent.id); + expect(drainSpy).toHaveBeenCalledWith(agent.id); + }); + }, 30000); + it("creates trigger scheduler on start", async () => { await runtime.start(); expect(runtime.getTriggerScheduler()).toBeDefined(); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 40ed13d67f..f778f0fce5 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -610,6 +610,9 @@ export class InProcessRuntime runtimeLog.warn(`resumeTaskForAgent failed for ${agentId}: ${err instanceof Error ? err.message : String(err)}`); }); } + void this.triggerScheduler?.drainPendingAssignment(agentId).catch((err) => { + runtimeLog.warn(`drainPendingAssignment failed for ${agentId}: ${err instanceof Error ? err.message : String(err)}`); + }); }, }); this.heartbeatMonitor.start(); @@ -794,6 +797,7 @@ export class InProcessRuntime getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null, leaseManager: this.leaseManager, hasActiveAgentExecution: (agentId: string) => this.heartbeatMonitor?.getTrackedAgents().includes(agentId) ?? false, + resumeAssignedTaskForAgent: (agentId: string) => this.executor.resumeTaskForAgent(agentId), recoverActiveMissionValidations: async () => { if (!this.missionExecutionLoop) { return { recoveredCount: 0 }; diff --git a/packages/engine/src/sandbox/bubblewrap-backend.ts b/packages/engine/src/sandbox/bubblewrap-backend.ts index 7873d2fbbb..2f86511417 100644 --- a/packages/engine/src/sandbox/bubblewrap-backend.ts +++ b/packages/engine/src/sandbox/bubblewrap-backend.ts @@ -17,6 +17,7 @@ import type { const execAsync = promisify(exec); type FailureMode = "fail-hard" | "fallback-native"; +type BubblewrapRunner = (command: string, args: string[], options: SandboxRunOptions) => Promise<SandboxRunResult>; export class SandboxUnavailableError extends Error { constructor(message: string) { @@ -30,7 +31,10 @@ export class BubblewrapBackend implements SandboxBackend { private useNativeFallback = false; private pnpmStorePathByCwd = new Map<string, string>(); - constructor(private readonly nativeBackend: SandboxBackend = new NativeSandboxBackend()) {} + constructor( + private readonly nativeBackend: SandboxBackend = new NativeSandboxBackend(), + private readonly bwrapRunner?: BubblewrapRunner, + ) {} capabilities(): SandboxCapabilities { return { @@ -87,7 +91,8 @@ export class BubblewrapBackend implements SandboxBackend { }); const bwrapPath = detect.path ?? "bwrap"; - return this.runBwrapSpawn(bwrapPath, [...policyArgs, "--", "/bin/sh", "-lc", command], options); + const bwrapArgs = [...policyArgs, "--", "/bin/sh", "-lc", command]; + return (this.bwrapRunner ?? this.runBwrapSpawn.bind(this))(bwrapPath, bwrapArgs, options); } async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> { diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index c945550542..a21a2cd5aa 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -1334,6 +1334,23 @@ export class Scheduler { ); } + const mergeShadowEnabled = settings.mergeRequestContractShadowEnabled === true; + const markerAcceptedByTaskId = new Map<string, boolean>(); + if (mergeShadowEnabled) { + const dependencyIds = new Set(tasks.flatMap((candidate) => candidate.dependencies)); + for (const depId of dependencyIds) { + markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null); + } + } + const schedulingDependencyOptions = mergeShadowEnabled + ? { + markerAcceptedByTaskId, + onParityDiff: (diff: SchedulingDependencyParityDiff) => { + this.emitDependencyParityDiff(diff); + }, + } + : undefined; + /** * Pre-compute file scopes for all currently active tasks (in-progress * AND in-review with unmerged worktrees) so that todo tasks are never @@ -1373,7 +1390,11 @@ export class Scheduler { for (const t of inProgress) { const filteredScope = await getFilteredFileScope(t.id); if (isCoordinationOnlyTask(t, filteredScope)) continue; - if (filteredScope.length > 0) setActiveScopeLease(t.id, filteredScope, "in-progress"); + if (filteredScope.length === 0) continue; + // FN-6292: a holder waiting on scheduling deps must not lease files + // that can block its own dependency and create a circular wait. + if (getUnmetSchedulingDependencies(t, tasks, schedulingDependencyOptions).length > 0) continue; + setActiveScopeLease(t.id, filteredScope, "in-progress"); } // Only live in-review tasks with a worktree belong in activeScopes. // Paused in-review tasks (e.g., failed-merge tasks awaiting human triage) cannot @@ -1442,14 +1463,6 @@ export class Scheduler { // Resolve dependency order among todo tasks const ordered = resolveDependencyOrder(todo); - const mergeShadowEnabled = settings.mergeRequestContractShadowEnabled === true; - const markerAcceptedByTaskId = new Map<string, boolean>(); - if (mergeShadowEnabled) { - const dependencyIds = new Set(todo.flatMap((candidate) => candidate.dependencies)); - for (const depId of dependencyIds) { - markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null); - } - } let started = 0; let loggedMissingAgentStoreThisPass = false; @@ -1471,14 +1484,7 @@ export class Scheduler { } // Check all deps are satisfied (done, in-review, or archived) - const unmetDeps = getUnmetSchedulingDependencies(task, tasks, mergeShadowEnabled - ? { - markerAcceptedByTaskId, - onParityDiff: (diff) => { - this.emitDependencyParityDiff(diff); - }, - } - : undefined); + const unmetDeps = getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions); if (unmetDeps.length > 0) { await this.store.updateTask(task.id, { @@ -2037,15 +2043,34 @@ export class Scheduler { return filteredScope; }; + const mergeShadowEnabled = settings.mergeRequestContractShadowEnabled === true; + const markerAcceptedByTaskId = new Map<string, boolean>(); + if (mergeShadowEnabled) { + const dependencyIds = new Set(tasks.flatMap((candidate) => candidate.dependencies)); + for (const depId of dependencyIds) { + markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null); + } + } + const schedulingDependencyOptions = mergeShadowEnabled + ? { + markerAcceptedByTaskId, + onParityDiff: (diff: SchedulingDependencyParityDiff) => { + this.emitDependencyParityDiff(diff); + }, + } + : undefined; + if (settings.groupOverlappingFiles) { for (const task of tasks) { if (task.column !== "in-progress") continue; const filteredScope = await getFilteredFileScope(task.id); if (isCoordinationOnlyTask(task, filteredScope)) continue; - if (filteredScope.length > 0) { - activeScopes.set(task.id, filteredScope); - activeScopeColumns.set(task.id, task.column); - } + if (filteredScope.length === 0) continue; + // FN-6292: do not let a task with unmet deps lease files that can + // keep those deps queued behind their own dependent. + if (getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions).length > 0) continue; + activeScopes.set(task.id, filteredScope); + activeScopeColumns.set(task.id, task.column); } const inReviewWithWorktree = tasks.filter( diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index ec64edb904..3c359e0322 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -18,7 +18,7 @@ * - `pruneWorktrees`: defer to backend prune * - `cleanupOrphans`: defer to backend prune/remove semantics * - `reapUnregisteredOrphans`: defer to backend prune/remove semantics - * - `cleanupStaleTempMergeWorktrees`: remains native (temp-dir scope, outside worktrunk layout) + * - `cleanupStaleTempMergeWorktrees`: remains native (dedicated AI-merge root + legacy roots) * - `enforceWorktreeCap`: defer to backend prune/remove semantics * - `reclaimSelfOwnedBranchConflicts`: remains native (branch-level) * - `reclaimStaleActiveBranches`: remains native (branch-level) @@ -48,7 +48,7 @@ import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, ty import { AutoRecoveryDispatcher } from "./auto-recovery.js"; import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js"; import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js"; -import { resolveWorktreesDir } from "./worktree-paths.js"; +import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js"; @@ -65,6 +65,7 @@ import { } from "./notifier.js"; import type { GhostBugDecision } from "./triage-preflight.js"; import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporter.js"; +import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap } from "./scheduler.js"; const log = createLogger("self-healing"); const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile"); @@ -75,8 +76,9 @@ const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000; const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000; const FTS_MAINTENANCE_MERGE_CADENCE_TICKS = 1; const FTS_MAINTENANCE_OPTIMIZE_CADENCE_TICKS = 4; -const STALE_TEMP_MERGE_WORKTREE_MS = 2 * 60 * 60 * 1000; -const DONE_TASK_TEMP_WORKTREE_GRACE_MS = 10 * 60 * 1000; +export const STALE_TEMP_MERGE_WORKTREE_MS = 2 * 60 * 60 * 1000; +export const DONE_TASK_TEMP_WORKTREE_GRACE_MS = 10 * 60 * 1000; +export const MIN_TEMP_WORKTREE_REAP_AGE_MS = DONE_TASK_TEMP_WORKTREE_GRACE_MS; // Live pathology peaked around 775 KB/task (~96 MB for ~120 tasks), while a // rebuilt healthy index was ~0.1 MB. Keep the steady-state budget generous but // bounded so sustained text churn heals before segment growth becomes material. @@ -100,6 +102,18 @@ function extractTaskIdFromTempMergeDir(dirname: string): string | null { return match?.[1]?.toUpperCase() ?? null; } +function resolveRepoLocalAiMergeRoot(rootDir: string, settings?: Pick<Settings, "worktreesDir">): string { + return resolveAiMergeRootPath(rootDir, settings); +} + +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isTaskNotFoundError(err: unknown): boolean { + return /\btask\s+fn-\d+\s+not found\b/i.test(getErrorMessage(err)); +} + type BranchGroupLandingRecorder = { recordBranchGroupMemberLanded?: (groupId: string, payload: { taskId: string; @@ -302,6 +316,12 @@ export interface SelfHealingOptions { */ unbackedMergingFanoutGraceMs?: number; hasActiveAgentExecution?: (agentId: string) => boolean; + /** + * Re-dispatches an agent's orphaned assigned in-progress execution forward, + * via Executor.resumeTaskForAgent. This must never move the task backward in + * lifecycle; the executor seam owns all in-memory double-execution guards. + */ + resumeAssignedTaskForAgent?: (agentId: string) => Promise<void>; restartDurableAgentHeartbeat?: (agentId: string, context: { reason: string; attempt: number }) => Promise<boolean>; autoRecoveryDispatcher?: AutoRecoveryDispatcher; /** Optional ChatStore for maintenance chat-retention cleanup. */ @@ -644,6 +664,7 @@ export class SelfHealingManager { private finalizeUnprovenWarned = new Set<string>(); private metaResolvedSkipAuditMemo = new Map<string, string>(); private metaStalledSkipAuditMemo = new Map<string, string>(); + private preservedQueuedOverlapLogged = new Map<string, string>(); private maintenanceTickCounter = 0; private readonly processBootStartedAt = Date.now(); private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null; @@ -1014,6 +1035,7 @@ export class SelfHealingManager { { name: "orphaned-planning", fn: () => this.recoverOrphanedPlanningTasks().then(() => undefined) }, { name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents().then(() => undefined) }, { name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) }, + { name: "reattach-orphaned-assigned-executions", fn: () => this.reattachOrphanedAssignedExecutions().then(() => undefined) }, { name: "reap-stale-mission-validator-runs", fn: async () => { @@ -1029,6 +1051,7 @@ export class SelfHealingManager { { name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift().then(() => undefined) }, { name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) }, { name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) }, + { name: "reconcile-dependency-blocking-leases", fn: () => this.reconcileDependencyBlockingLeases().then(() => undefined) }, { name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) }, { name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts().then(() => undefined) }, { name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) }, @@ -1089,6 +1112,7 @@ export class SelfHealingManager { this.finalizeUnprovenWarned.clear(); this.metaResolvedSkipAuditMemo.clear(); this.metaStalledSkipAuditMemo.clear(); + this.preservedQueuedOverlapLogged.clear(); log.log("Stopped"); } @@ -1206,6 +1230,15 @@ export class SelfHealingManager { const task = await this.store.getTask(taskId); + if (task.userPaused) { + log.warn(`${taskId} STUCK_KILL: skipped — task is user-paused; leaving paused`); + await this.store.logEntry( + taskId, + `STUCK_KILL: skipped stuck-budget recovery for ${reason} because the task is user-paused; leaving paused.`, + ); + return false; + } + if (reason === "no-progress-churn") { const ignoredStepUpdateCount = event?.ignoredStepUpdateCount ?? 0; const stuckKillStreak = task.stuckKillCount ?? 0; @@ -1305,10 +1338,9 @@ export class SelfHealingManager { const requeueUpdate = { stuckKillCount: newCount, paused: false, - userPaused: false, pausedReason: null, status: "queued", - } satisfies Parameters<typeof this.store.updateTask>[1] & { userPaused: boolean }; + } satisfies Parameters<typeof this.store.updateTask>[1]; try { await this.store.updateTask(taskId, requeueUpdate); } catch (patchErr: unknown) { @@ -1971,6 +2003,7 @@ export class SelfHealingManager { { name: "recover-ghost-review", fn: () => this.recoverGhostReviewTasks() }, { name: "recover-orphaned-agents", fn: () => this.recoverOrphanedAgents() }, { name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() }, + { name: "reattach-orphaned-assigned-executions", fn: () => this.reattachOrphanedAssignedExecutions() }, { name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() }, { name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() }, { name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift() }, @@ -1984,6 +2017,7 @@ export class SelfHealingManager { // only; a no-op when there are no markers). { name: "recover-stale-transition-pending", fn: () => this.runStaleTransitionPendingSweep() }, { name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() }, + { name: "reconcile-dependency-blocking-leases", fn: () => this.reconcileDependencyBlockingLeases() }, { name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) }, { name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts() }, { name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() }, @@ -4009,6 +4043,18 @@ export class SelfHealingManager { memo.delete(taskId); } + private shouldLogPreservedQueuedOverlap(taskId: string, overlapBlockedBy: string | null | undefined): overlapBlockedBy is string { + if (!overlapBlockedBy) return false; + const previous = this.preservedQueuedOverlapLogged.get(taskId); + if (previous === overlapBlockedBy) return false; + this.preservedQueuedOverlapLogged.set(taskId, overlapBlockedBy); + return true; + } + + private clearPreservedQueuedOverlapMemo(taskId: string): void { + this.preservedQueuedOverlapLogged.delete(taskId); + } + async autoArchiveResolvedMetaTasks(reboundedTargets?: Set<string>): Promise<number> { const tasks = await this.store.listTasks({ slim: false, includeArchived: true }); const byId = new Map(tasks.map((task) => [task.id.toUpperCase(), task])); @@ -4345,7 +4391,10 @@ export class SelfHealingManager { (task) => task.status === "queued" && (task.dependencies.length > 0 || Boolean(task.overlapBlockedBy)), ); - if (blockedTasks.length === 0 && queuedDependencyTasks.length === 0) return 0; + if (blockedTasks.length === 0 && queuedDependencyTasks.length === 0) { + this.preservedQueuedOverlapLogged.clear(); + return 0; + } const allTasks = await this.store.listTasks({ includeArchived: true }); const taskById = new Map(allTasks.map((task) => [task.id, task])); @@ -4358,6 +4407,24 @@ export class SelfHealingManager { for (const task of blockedTasks) candidates.set(task.id, task); for (const task of queuedDependencyTasks) candidates.set(task.id, task); + for (const [taskId, lastLoggedBlockerId] of this.preservedQueuedOverlapLogged) { + const memoTask = taskById.get(taskId); + const memoOverlapBlocker = memoTask?.overlapBlockedBy ? taskById.get(memoTask.overlapBlockedBy) : undefined; + const memoHasActiveOverlapBlocker = Boolean( + memoOverlapBlocker + && (memoOverlapBlocker.column === "in-progress" || (memoOverlapBlocker.column === "in-review" && !memoOverlapBlocker.paused)), + ); + if ( + !candidates.has(taskId) + || memoTask?.column !== "todo" + || memoTask.status !== "queued" + || memoTask.overlapBlockedBy !== lastLoggedBlockerId + || !memoHasActiveOverlapBlocker + ) { + this.clearPreservedQueuedOverlapMemo(taskId); + } + } + for (const task of candidates.values()) { const blockerId = task.blockedBy; @@ -4453,26 +4520,36 @@ export class SelfHealingManager { if (reason) { try { + let didRecover = false; if (todoTaskIds.has(task.id)) { if (unresolvedDeps.length > 0) { + this.clearPreservedQueuedOverlapMemo(task.id); const nextBlocker = unresolvedDeps[0]!; if (nextBlocker === blockerId) { continue; } await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" }); await this.store.logEntry(task.id, `Auto-recovered (FN-5488): refreshed stale blockedBy — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; ${reason}; now blocked by ${nextBlocker}`); + didRecover = true; } else if (hasActiveOverlapBlocker) { await this.store.updateTask(task.id, { blockedBy: null, status: "queued" }); - await this.store.logEntry(task.id, `Auto-recovered (FN-5488): preserved queued status — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; still blocked by file scope overlap with ${task.overlapBlockedBy}`); + if (this.shouldLogPreservedQueuedOverlap(task.id, task.overlapBlockedBy)) { + await this.store.logEntry(task.id, `Auto-recovered (FN-5488): preserved queued status — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; still blocked by file scope overlap with ${task.overlapBlockedBy}`); + didRecover = true; + } } else { + this.clearPreservedQueuedOverlapMemo(task.id); await this.store.updateTask(task.id, { blockedBy: null, overlapBlockedBy: null, status: null }); await this.store.logEntry(task.id, `Auto-recovered (FN-5488): cleared stale blockedBy — blocker=${blockerId} blockerStatus=${blocker?.status ?? "none"} reason=${reasonCode ?? "unspecified"}; ${reason}`); + didRecover = true; } } else { + this.clearPreservedQueuedOverlapMemo(task.id); await this.store.updateTask(task.id, { blockedBy: null }); await this.store.logEntry(task.id, `Auto-recovered (FN-4091): cleared stale blockedBy — ${reason}`); + didRecover = true; } - recovered++; + if (didRecover) recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); log.error(`Failed to clear stale blockedBy for ${task.id}: ${errorMessage}`); @@ -4490,14 +4567,15 @@ export class SelfHealingManager { try { if (hasActiveOverlapBlocker) { await this.store.updateTask(task.id, { blockedBy: null, status: "queued" }); - await this.store.logEntry(task.id, `Auto-recovered: preserved queued status — still blocked by file scope overlap with ${task.overlapBlockedBy}`); + if (this.shouldLogPreservedQueuedOverlap(task.id, task.overlapBlockedBy)) { + await this.store.logEntry(task.id, `Auto-recovered: preserved queued status — still blocked by file scope overlap with ${task.overlapBlockedBy}`); + recovered++; + } } else { + this.clearPreservedQueuedOverlapMemo(task.id); // FN-5434: routine scheduler↔self-healing queued-status churn should stay silent; keep state cleanup only. await this.store.updateTask(task.id, { blockedBy: null, overlapBlockedBy: null, status: null }); } - if (hasActiveOverlapBlocker) { - recovered++; - } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); log.error(`Failed to clear stale queued status for ${task.id}: ${errorMessage}`); @@ -4506,6 +4584,7 @@ export class SelfHealingManager { continue; } + this.clearPreservedQueuedOverlapMemo(task.id); const nextBlocker = unresolvedDeps[0] ?? null; if (nextBlocker && task.blockedBy !== nextBlocker) { try { @@ -4525,6 +4604,135 @@ export class SelfHealingManager { } } + async reconcileDependencyBlockingLeases(): Promise<number> { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + let tasks: Task[] = []; + try { + tasks = await this.store.listTasks({ includeArchived: false, slim: true }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`reconcileDependencyBlockingLeases: failed to list tasks: ${errorMessage}`); + return 0; + } + + const byId = new Map(tasks.map((task) => [task.id, task])); + const markerAcceptedByTaskId = new Map<string, boolean>(); + if (settings.mergeRequestContractShadowEnabled === true) { + const dependencyIds = new Set(tasks.flatMap((task) => task.dependencies)); + for (const depId of dependencyIds) { + markerAcceptedByTaskId.set(depId, this.store.getCompletionHandoffAcceptedMarker(depId) !== null); + } + } + const dependencyOptions = settings.mergeRequestContractShadowEnabled === true + ? { markerAcceptedByTaskId } + : undefined; + const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; + const filteredScopeByTaskId = new Map<string, string[]>(); + const getFilteredFileScope = async (taskId: string): Promise<string[]> => { + const cached = filteredScopeByTaskId.get(taskId); + if (cached) return cached; + const scope = await this.store.parseFileScopeFromPrompt(taskId); + const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths); + filteredScopeByTaskId.set(taskId, filteredScope); + return filteredScope; + }; + + let recovered = 0; + for (const holder of tasks) { + if (holder.column !== "in-progress") continue; + if (holder.paused === true || holder.userPaused === true) continue; + + const unmetDeps = getUnmetSchedulingDependencies(holder, tasks, dependencyOptions); + if (unmetDeps.length === 0) continue; + + const holderScope = await getFilteredFileScope(holder.id); + if (holderScope.length === 0 || isCoordinationOnlyTask(holder, holderScope)) continue; + + let deadlockingDependency: Task | undefined; + let deadlockEvidence: "stale-overlap-blocker" | "overlapping-todo-dependency" | undefined; + for (const depId of unmetDeps) { + const dependency = byId.get(depId); + if (!dependency) continue; + if (dependency.overlapBlockedBy === holder.id) { + deadlockingDependency = dependency; + deadlockEvidence = "stale-overlap-blocker"; + break; + } + if (dependency.column !== "todo") continue; + const dependencyScope = await getFilteredFileScope(dependency.id); + if (dependencyScope.length === 0 || isCoordinationOnlyTask(dependency, dependencyScope)) continue; + if (pathsOverlap(holderScope, dependencyScope)) { + deadlockingDependency = dependency; + deadlockEvidence = "overlapping-todo-dependency"; + break; + } + } + if (!deadlockingDependency || !deadlockEvidence) continue; + + const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS; + const proof = await this.evaluateBackwardMoveTripleProof(holder, { + stage: "reconcile-dependency-blocking-lease", + graceMs, + stalenessAnchor: holder.executionStartedAt ?? holder.updatedAt, + reason: "dependency-blocking-file-scope-lease", + extra: { + holderId: holder.id, + dependencyId: deadlockingDependency.id, + unmetDeps, + deadlockEvidence, + holderScope, + dependencyOverlapBlockedBy: deadlockingDependency.overlapBlockedBy ?? null, + }, + }); + if (!proof.ok) { + await this.emitBackwardMoveNoAction( + holder, + "reconcile-dependency-blocking-lease", + "task:reconcile-dependency-blocking-lease-no-action", + proof, + ); + continue; + } + + await this.store.moveTask(holder.id, "todo", { + preserveProgress: true, + preserveWorktree: true, + preserveResumeState: true, + moveSource: "engine", + recoveryRehome: true, + }); + if (deadlockingDependency.overlapBlockedBy === holder.id) { + await this.store.updateTask(deadlockingDependency.id, { overlapBlockedBy: null, status: null }); + } + await this.store.logEntry( + holder.id, + `Auto-rebounded (FN-6292): released dependency-blocking file-scope lease; dependency ${deadlockingDependency.id} can run before this task resumes`, + ); + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("fn6292-dependency-blocking-lease", holder.id), + agentId: "self-healing", + taskId: holder.id, + taskLineageId: holder.lineageId, + phase: "reconcile-dependency-blocking-lease", + }).database({ + type: "task:reconcile-dependency-blocking-lease" as DatabaseMutationType, + target: holder.id, + metadata: { + holderId: holder.id, + dependencyId: deadlockingDependency.id, + unmetDeps, + deadlockEvidence, + clearedOverlapBlockedBy: deadlockingDependency.overlapBlockedBy === holder.id, + }, + }); + recovered++; + } + + return recovered; + } + async reconcileSelfDefeatingDependencies(): Promise<number> { const targetColumns: Array<Task["column"]> = ["triage", "todo"]; let recovered = 0; @@ -7704,6 +7912,120 @@ export class SelfHealingManager { } } + /** + * Re-dispatch assigned in-progress tasks whose durable agent has no active + * heartbeat run and no active executor session. This is a forward resume via + * Executor.resumeTaskForAgent; it never moves lifecycle backward and + * complements the observation-only recoverOrphanedExecutions pass. + */ + async reattachOrphanedAssignedExecutions(): Promise<number> { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) { + return 0; + } + + const agentStore = this.options.agentStore; + const resumeAssignedTaskForAgent = this.options.resumeAssignedTaskForAgent; + if (!agentStore || !resumeAssignedTaskForAgent) { + return 0; + } + + const tasks = await this.store.listTasks({ column: "in-progress", slim: true }); + const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>(); + const now = Date.now(); + const candidates: Task[] = []; + + for (const task of tasks) { + if (task.column !== "in-progress") continue; + if (task.paused || task.deletedAt) continue; + if (!task.assignedAgentId) continue; + if (executingIds.has(task.id)) continue; + if (isTaskWorkComplete(task)) continue; + + const updatedAtMs = new Date(task.updatedAt).getTime(); + if (!Number.isFinite(updatedAtMs)) continue; + const hadWorktree = Boolean(task.worktree && existsSync(task.worktree)); + const graceMs = hadWorktree ? ORPHANED_WITH_WORKTREE_GRACE_MS : ORPHANED_EXECUTION_RECOVERY_GRACE_MS; + if (now - updatedAtMs < graceMs) continue; + + candidates.push(task); + } + + if (candidates.length === 0) { + return 0; + } + + const tasksByAgent = new Map<string, Task[]>(); + for (const task of candidates) { + const agentId = task.assignedAgentId; + if (!agentId) continue; + + const agent = await agentStore.getAgent(agentId); + if (!agent) continue; + + const activeRun = await agentStore.getActiveHeartbeatRun(agentId); + if (activeRun) continue; + if (this.options.hasActiveAgentExecution?.(agentId) === true) continue; + + const agentTasks = tasksByAgent.get(agentId) ?? []; + agentTasks.push(task); + tasksByAgent.set(agentId, agentTasks); + } + + let reattachedAgents = 0; + for (const [agentId, agentTasks] of tasksByAgent) { + try { + await resumeAssignedTaskForAgent(agentId); + reattachedAgents += 1; + + for (const task of agentTasks) { + try { + const hadWorktree = Boolean(task.worktree && existsSync(task.worktree)); + const stalenessMs = now - new Date(task.updatedAt).getTime(); + const reason = hadWorktree + ? "assigned-agent-no-active-run-or-execution-worktree-exists" + : "assigned-agent-no-active-run-or-execution"; + + await createRunAuditor(this.store, { + runId: generateSyntheticRunId("self-healing-reattach-orphaned-execution", task.id), + agentId: "self-healing", + taskId: task.id, + taskLineageId: task.lineageId, + phase: "reattach-orphaned-assigned-executions", + }).database({ + type: "task:reattach-orphaned-execution", + target: task.id, + metadata: { + assignedAgentId: agentId, + priorWorktree: task.worktree ?? null, + priorBranch: task.branch ?? null, + hadWorktree, + stalenessMs, + reason, + }, + }); + + log.log(`[reattach-orphaned-execution] ${task.id}: re-dispatched agent ${agentId} (${reason})`); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(`Failed to annotate reattached orphaned execution ${task.id}: ${errorMessage}`); + } + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(`Failed to reattach orphaned assigned executions for ${agentId}: ${errorMessage}`); + } + } + + return reattachedAgents; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(`Orphaned assigned execution reattach failed: ${errorMessage}`); + return 0; + } + } + private getDurableAgentRecoveryState(agent: { metadata?: Record<string, unknown> | null }): { attempts: number; nextRetryAt?: string; @@ -8760,6 +9082,14 @@ export class SelfHealingManager { let cleaned = 0; for (const worktreePath of orphaned) { + // FN-4811/FN-5065: never reap a worktree bound to a live executor/merger/ + // step/workflow session. Such a task can sit transiently in "done" (or have + // null worktree metadata mid-transition) while the owning process is still + // working in the checkout — scanIdleWorktrees would otherwise flag it idle. + if (activeSessionRegistry.isPathActive(worktreePath) || activeSessionRegistry.isPathActive(resolve(worktreePath))) { + log.log(`[self-healing] deferring idle-sweep for ${worktreePath}: active session present`); + continue; + } // U8: never reclaim a worktree backing a resume-eligible CLI session. if (this.isWorktreeResumeReserved(worktreePath)) { log.log(`[self-healing] deferring idle-sweep for ${worktreePath}: resume-eligible CLI session present`); @@ -8812,7 +9142,7 @@ export class SelfHealingManager { let dirs: string[]; try { dirs = readdirSync(worktreesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) + .filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)) .map((e) => join(worktreesDir, e.name)); } catch (err: unknown) { log.warn(`Failed to read .worktrees/ for unregistered orphan reap: ${err instanceof Error ? err.message : String(err)}`); @@ -8858,30 +9188,20 @@ export class SelfHealingManager { } /** - * Sweep stale AI merge clean-room worktrees from `tmpdir()`. + * Sweep stale AI merge clean-room worktrees from the configured worktrees-dir + * clean-room root plus legacy `.fusion/ai-merge/` and `tmpdir()` locations + * used by older engine versions. * - * These worktrees are intentionally outside the project/worktrunk-managed - * `.worktrees/` layout, so this native sweep proceeds even when worktrunk is - * enabled. Safety is bounded by a two-hour age gate plus active-session checks. + * Safety is bounded by age gates plus active-session checks. */ private async cleanupStaleTempMergeWorktrees(): Promise<number> { try { const settings = await this.store.getSettings(); if (settings.worktrunk?.enabled === true) { - log.log("[self-healing] temp-dir sweep: worktrunk enabled — AI merge temp worktrees are outside worktrunk's managed layout, proceeding with native sweep"); + log.log("[self-healing] temp-dir sweep: worktrunk enabled — AI merge clean-room worktrees use Fusion's dedicated clean-room root, proceeding with native sweep"); } - const tempRoot = tmpdir(); - let entries: string[]; - try { - entries = readdirSync(tempRoot).filter((entry) => entry.startsWith("fusion-ai-merge-")); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - log.warn(`[self-healing] temp-dir sweep: failed to read ${tempRoot}: ${errorMessage}`); - return 0; - } - if (entries.length === 0) return 0; - + const roots = Array.from(new Set([resolveRepoLocalAiMergeRoot(this.options.rootDir, settings), resolveLegacyAiMergeRootPath(this.options.rootDir), tmpdir()])); const auditor = createRunAuditor(this.store, { runId: generateSyntheticRunId("self-heal", "tempdir-sweep"), agentId: "self-healing", @@ -8890,71 +9210,105 @@ export class SelfHealingManager { const now = Date.now(); let cleaned = 0; - for (const entry of entries) { - const path = join(tempRoot, entry); - let canonicalPath = path; - let cleanupReason = "stale"; + for (const tempRoot of roots) { + let entries: string[]; try { - const stat = statSync(path); - if (!stat.isDirectory()) { - await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "not-directory" } }); + entries = readdirSync(tempRoot).filter((entry) => entry.startsWith("fusion-ai-merge-")); + } catch (err: unknown) { + if (!existsSync(tempRoot)) continue; + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`[self-healing] temp-dir sweep: failed to read ${tempRoot}: ${errorMessage}`); + if (tempRoot === tmpdir()) return cleaned; + continue; + } + if (entries.length === 0) continue; + + for (const entry of entries) { + const path = join(tempRoot, entry); + let canonicalPath = path; + let cleanupReason = "stale"; + try { + const stat = statSync(path); + if (!stat.isDirectory()) { + await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "not-directory" } }); + continue; + } + const ageMs = now - stat.mtimeMs; + let ageGateMs = STALE_TEMP_MERGE_WORKTREE_MS; + cleanupReason = "stale"; + const taskId = extractTaskIdFromTempMergeDir(entry); + if (taskId) { + try { + const task = await this.store.getTask(taskId); + if (task.column === "done" || task.column === "archived") { + ageGateMs = DONE_TASK_TEMP_WORKTREE_GRACE_MS; + cleanupReason = "done-task-stale"; + } + } catch (err: unknown) { + if (isTaskNotFoundError(err)) { + ageGateMs = MIN_TEMP_WORKTREE_REAP_AGE_MS; + cleanupReason = "deleted-task"; + } else { + const errorMessage = getErrorMessage(err); + cleanupReason = "lookup-error"; + log.warn(`[self-healing] temp-dir sweep: task lookup failed for ${taskId}: ${errorMessage}; using conservative age gate`); + } + } + } + ageGateMs = Math.max(ageGateMs, MIN_TEMP_WORKTREE_REAP_AGE_MS); + if (ageMs < ageGateMs) continue; + try { + canonicalPath = realpathSync(path); + } catch { + canonicalPath = path; + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`[self-healing] temp-dir sweep: failed to stat ${path}: ${errorMessage}`); + await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "stat-failed", error: errorMessage } }); continue; } - const ageMs = now - stat.mtimeMs; - let ageGateMs = STALE_TEMP_MERGE_WORKTREE_MS; - cleanupReason = "stale"; - const taskId = extractTaskIdFromTempMergeDir(entry); - if (taskId) { - try { - const task = await this.store.getTask(taskId); - if (task.column === "done" || task.column === "archived") { - ageGateMs = DONE_TASK_TEMP_WORKTREE_GRACE_MS; - cleanupReason = "done-task-stale"; + + if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(path)) { + log.log(`[self-healing] temp-dir sweep: deferring ${canonicalPath}: active session present`); + await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "active-session" } }); + continue; + } + + let cleanupAttempted = false; + try { + cleanupAttempted = true; + await execAsync(`git worktree remove --force ${shellQuote(canonicalPath)}`, { + cwd: this.options.rootDir, + timeout: 120_000, + }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`[self-healing] temp-dir sweep: git worktree remove failed for ${canonicalPath}: ${errorMessage} — falling back to filesystem removal`); + await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "git-remove-failed", error: errorMessage } }); + } + + try { + cleanupAttempted = true; + rmSync(canonicalPath, { recursive: true, force: true }); + log.log(`[self-healing] temp-dir sweep: cleaned stale AI merge worktree ${canonicalPath}`); + await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: true, reason: cleanupReason } }); + cleaned++; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`[self-healing] temp-dir sweep: failed to remove ${canonicalPath}: ${errorMessage}`); + await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "fs-rm-failed", error: errorMessage } }); + } finally { + if (cleanupAttempted) { + try { + await execAsync("git worktree prune", { cwd: this.options.rootDir, timeout: 30_000 }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.warn(`[self-healing] temp-dir sweep: git worktree prune failed after cleaning ${canonicalPath}: ${errorMessage}`); + await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "git-prune-failed", error: errorMessage } }); } - } catch { - ageGateMs = 0; - cleanupReason = "deleted-task"; } } - if (ageGateMs > 0 && ageMs < ageGateMs) continue; - try { - canonicalPath = realpathSync(path); - } catch { - canonicalPath = path; - } - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - log.warn(`[self-healing] temp-dir sweep: failed to stat ${path}: ${errorMessage}`); - await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "stat-failed", error: errorMessage } }); - continue; - } - - if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(path)) { - log.log(`[self-healing] temp-dir sweep: deferring ${canonicalPath}: active session present`); - await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "active-session" } }); - continue; - } - - try { - await execAsync(`git worktree remove --force ${shellQuote(canonicalPath)}`, { - cwd: this.options.rootDir, - timeout: 120_000, - }); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - log.warn(`[self-healing] temp-dir sweep: git worktree remove failed for ${canonicalPath}: ${errorMessage} — falling back to filesystem removal`); - await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "git-remove-failed", error: errorMessage } }); - } - - try { - rmSync(canonicalPath, { recursive: true, force: true }); - log.log(`[self-healing] temp-dir sweep: cleaned stale AI merge worktree ${canonicalPath}`); - await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: true, reason: cleanupReason } }); - cleaned++; - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - log.warn(`[self-healing] temp-dir sweep: failed to remove ${canonicalPath}: ${errorMessage}`); - await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "fs-rm-failed", error: errorMessage } }); } } @@ -9193,7 +9547,7 @@ export class SelfHealingManager { const cap = (settings.maxWorktrees ?? 4) * 2; const entries = readdirSync(worktreesDir, { withFileTypes: true }); - const dirs = entries.filter((e) => e.isDirectory()); + const dirs = entries.filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)); if (dirs.length <= cap) return; @@ -9218,6 +9572,13 @@ export class SelfHealingManager { for (const { path: worktreePath } of withMtime) { if (removed >= excess) break; + // FN-4811/FN-5065: never reap a worktree bound to a live executor/merger/ + // step/workflow session — cap pressure must not yank a checkout out from + // under a process that is still working in it. + if (activeSessionRegistry.isPathActive(worktreePath) || activeSessionRegistry.isPathActive(resolve(worktreePath))) { + log.log(`[self-healing] cap-enforcement skipping ${worktreePath}: active session present`); + continue; + } // U8: never reclaim a worktree backing a resume-eligible CLI session. if (this.isWorktreeResumeReserved(worktreePath)) { log.log(`[self-healing] cap-enforcement skipping ${worktreePath}: resume-eligible CLI session present`); diff --git a/packages/engine/src/spec-validation/external-integration-evidence.ts b/packages/engine/src/spec-validation/external-integration-evidence.ts index 1b6e3d9184..31f6dd4e96 100644 --- a/packages/engine/src/spec-validation/external-integration-evidence.ts +++ b/packages/engine/src/spec-validation/external-integration-evidence.ts @@ -22,7 +22,14 @@ export interface DetectExternalIntegrationEvidenceOptions { detectorOverrides?: ExternalIntegrationDetectorOverrides; } -const SECTION_NAMES = ["Mission", "Steps", "File Scope", "Context to Read First"]; +const SECTION_NAMES = [ + "Mission", + "Steps", + "File Scope", + "Context to Read First", + "External Integration Evidence", + "External-Integration Evidence", +]; const DEFAULT_TRIGGER_TOKENS = [ "third-party", "third party", @@ -57,12 +64,42 @@ function hasLikelyCliName(text: string): boolean { for (const match of codeMatches) { const idx = match.index ?? -1; if (idx < 0) continue; - const window = text.slice(Math.max(0, idx - 80), Math.min(text.length, idx + (match[0]?.length ?? 0) + 80)); + const window = text.slice( + Math.max(0, idx - 80), + Math.min(text.length, idx + (match[0]?.length ?? 0) + 80), + ); if (/\b(?:probe|invoke|run|spawn|which|where)\b/i.test(window)) return true; + const leadingText = text.slice(Math.max(0, idx - 80), idx); + if (/\b(?:(?:binary|cli)(?:\s*\/\s*|\s+or\s+)?(?:cli\s+)?name|cli\s+name)\s*:?\s*$/i.test(leadingText)) return true; } return false; } +function collectHttpUrls(text: string): string[] { + return Array.from(text.matchAll(/https:\/\/[^\s)\]`"']+/gi)).map((m) => + m[0].replace(/[),.;:!?]+$/, ""), + ); +} + +function hasLabeledUrl(text: string, labelPattern: RegExp, urlPattern: RegExp = /https:\/\//i): boolean { + return text + .split(/\r?\n/) + .some((line) => labelPattern.test(line) && collectHttpUrls(line).some((url) => urlPattern.test(url))); +} + +function isReleaseOrDownloadUrl(url: string): boolean { + return ( + /https:\/\/github\.com\/[^\s)\]`"']*releases\/[^\s)\]`"']+/i.test(url) || + /https:\/\/[^\s)\]`"']*download[^\s)\]`"']*/i.test(url) || + /https:\/\/registry\.npmjs\.org\/[^\s)\]`"']+\/-\/[^\s)\]`"']+\.tgz(?:$|[?#])/i.test(url) || + /\.(?:tgz|tar\.gz)(?:$|[?#])/i.test(url) + ); +} + +function isLikelyDocsUrl(url: string): boolean { + return !/^https:\/\/github\.com\//i.test(url) && !isReleaseOrDownloadUrl(url); +} + function hasCanonicalGithubRepoUrl(text: string): boolean { const urls = Array.from(text.matchAll(/https:\/\/github\.com\/[^\s)\]`"']+/gi)).map((m) => m[0]); for (const url of urls) { @@ -93,9 +130,17 @@ export function detectExternalIntegrationEvidenceGaps( const hints = collectHints(text, integrationPattern); const findingHints = hints.length > 0 ? hints : ["external-integration"]; - const hasDocsUrl = /https:\/\/(?!github\.com\/)[^\s)\]`"']+/i.test(text); - const hasReleaseUrl = /https:\/\/github\.com\/[^\s)\]`"']*releases\/[^\s)\]`"']+/i.test(text) || /https:\/\/[^\s)\]`"']*download[^\s)\]`"']*/i.test(text); - const hasChecksumMarker = /\bsha256\b|pinned manifest|validateExternalIntegrationManifest|WORKTRUNK_PINNED_RELEASE|upstream-pending-verification/i.test(text); + const urls = collectHttpUrls(text); + const hasDocsUrl = + hasLabeledUrl(text, /\b(?:docs?|homepage)\b(?:\s*(?:\/|or)\s*\b(?:docs?|homepage)\b)?(?:\s+url)?\s*:/i) || + urls.some(isLikelyDocsUrl); + const hasReleaseUrl = + hasLabeledUrl(text, /\b(?:release|download)\b(?:\s*(?:\/|or)\s*\b(?:release|download)\b)?(?:\s+url)?\s*:/i) || + urls.some(isReleaseOrDownloadUrl); + const hasChecksumMarker = + /\bsha\d+\b|pinned manifest|validateExternalIntegrationManifest|WORKTRUNK_PINNED_RELEASE|upstream-pending-verification/i.test( + text, + ); const hasCliName = hasLikelyCliName(text); const hasCanonicalRepo = hasCanonicalGithubRepoUrl(text); @@ -119,7 +164,9 @@ export function formatExternalIntegrationEvidenceDiagnostic( const lines = ["REVISE — External-integration evidence gaps in PROMPT.md:"]; for (const finding of findings) { lines.push(` - ${finding.integrationHint}: missing ${finding.missing.join(", ")}`); - lines.push(" Fix: add canonical upstream repo/docs/release URL evidence, CLI name in backticks, and checksum or explicit upstream-pending-verification marker."); + lines.push( + " Fix: add canonical upstream repo/docs/release URL evidence, CLI name in backticks, and checksum or explicit upstream-pending-verification marker.", + ); } return lines.join("\n"); } diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 23bc96f0ee..c6d844aa48 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -602,6 +602,8 @@ interface SessionHandle { * is killed via pi-coding-agent's killProcessTree. dispose() alone only * disconnects listeners and leaves bash subtrees orphaned. */ abortBash: () => void; + /** Inject mid-flight steering into the live step session. */ + steer: (message: string) => Promise<void>; } @@ -768,6 +770,16 @@ export class StepSessionExecutor { } } + async steerActiveSessions(message: string): Promise<void> { + for (const [stepIdx, handle] of this.activeSessions) { + try { + await handle.steer(message); + } catch (err) { + stepExecLog.warn(`Failed to steer active session for step ${stepIdx}: ${err}`); + } + } + } + async terminateAllSessions(): Promise<void> { this.aborted = true; stepExecLog.log( @@ -1098,6 +1110,10 @@ Follow instructions precisely and avoid unrelated changes.`, const handle: SessionHandle = { dispose: () => session?.dispose(), abortBash: () => session?.abortBash(), + steer: async (message) => { + if (!session) return; + await session.steer(message); + }, }; this.registerActiveStepSession(stepIndex, handle, worktreePath); stuckTaskDetector?.trackTask(trackingKey, { dispose: () => session?.dispose() }, taskDetail.id); diff --git a/packages/engine/src/transient-merge-error-classifier.ts b/packages/engine/src/transient-merge-error-classifier.ts index a0ff7635c1..3d66546cc9 100644 --- a/packages/engine/src/transient-merge-error-classifier.ts +++ b/packages/engine/src/transient-merge-error-classifier.ts @@ -33,12 +33,27 @@ * the safety-fallback auto-prerebase (`merger-auto-prerebase.ts`, * FN-5627) rebases the task branch onto current main, so the retry * succeeds. + * + * - `process-spawn-failure`: Node/OS process launch failed while the merger + * was operating from an integration cwd (`spawn ENOTDIR`, `spawn git ENOENT`, + * `spawn ENOENT`) or git reported that the AI-merge clean-room path `is not + * a working tree`. These indicate the command could not even start because + * the cwd/entrypoint/worktree was missing or file-shadowed (for example a + * stale temp merge checkout), not that the task branch's code failed. A + * fresh merge attempt gets a fresh/revalidated worktree, so the self-healing + * sweep can recover these within its bounded retry budget. */ export function classifyTransientMergeError(error: string | null | undefined): string | null { if (!error) return null; if (/lease-handoff-failed[^a-z]+target-not-queued/i.test(error)) { return "lease-handoff-target-not-queued"; } + if (/\bspawn(?:\s+\S+)?\s+ENO(?:TDIR|ENT)\b/i.test(error)) { + return "process-spawn-failure"; + } + if (/\bis not a working tree\b/i.test(error)) { + return "process-spawn-failure"; + } const sameSha = error.match(/advanced concurrently \(expected ([0-9a-f]{7,40}),\s+observed ([0-9a-f]{7,40})\)/i); if (sameSha && sameSha[1].toLowerCase() === sameSha[2].toLowerCase()) { return "spurious-concurrent-advance-same-sha"; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 5857053272..212cbaf04f 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -13,6 +13,10 @@ import { getTaskDuplicateLineage, parseExplicitDuplicateMarker, resolveAgentPrompt, + builtinSeamPrompt, + renderTriagePolicyPlaceholders, + resolveTaskPlanningPrompt, + resolveTaskSeamPrompt, resolvePersistAgentThinkingLog, compareTaskPriority, sortTasksByPriorityThenAgeAndId, @@ -20,6 +24,7 @@ import { resolveAgentMemoryInclusionMode, extractIntentSignature, findNearDuplicates, + applyFrontendUxCriteria, type NearDuplicateCandidate, } from "@fusion/core"; import type { ImageContent } from "@earendil-works/pi-ai"; @@ -62,7 +67,7 @@ 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 { exec } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { @@ -87,531 +92,6 @@ import { archiveAsGhostBug } from "./self-healing.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.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} - -## Surface Enumeration - -{Required for bug-fix tasks and UI-affordance add/remove tasks (adding, removing, or restructuring icons, buttons, chevrons/arrows, toggles, badges, menu entries, click targets): a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. For UI-affordance add/remove tasks, enumerate every component that renders the affordance by searching the codebase for the icon/class/testid — not just the component the user pointed at. Explicitly check for leftover shells after removal (empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels) across both desktop and mobile breakpoints. Use the canonical checklist in docs/testing.md as the starting point.} - -## 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 - -> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed -> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps -> that are genuinely independent of their immediate predecessor; an unannotated step is -> assumed to depend on the one before it (fully sequential). Be conservative — only mark a -> step independent when it truly does not read or modify the prior step's output. - -### 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, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states) - -For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: -- [ ] Providers / bridges / execution paths touched by the invariant -- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior -- [ ] Empty / undefined / duplicate / populated data states -- [ ] Shared hooks / components / modules / helpers reusing the logic -- [ ] Every component that renders the affordance (search the codebase for the icon/class/testid, not just the one the user pointed at) -- [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden - -**Artifacts:** -- \`path/to/file\` (new | modified) - -### Step {N-1}: Testing & Verification - -> ZERO failures allowed for checks required by this task's quality gates. Run impacted/package-scoped verification first; run workspace-wide suites only when the task or workflow explicitly requires them, or during final integration after impacted checks pass. -> 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 impacted tests -- [ ] 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 — <short summary>\` (the \`<short summary>\` is required — use a concrete 5–10 word description) -- **Bug fixes:** \`fix({ID}): description\` (short, concrete summary required) -- **Tests:** \`test({ID}): description\` (short, concrete summary required) - -Good examples: -- \`feat(FN-1234): complete Step 2 — add retry guard for workflow step timeouts\` -- \`test(FN-1234): add regression tests for paused-session cleanup\` - -Bad example: -- \`feat(FN-1234): complete Step 2\` - -## 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 -- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE. -- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal. -- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123) -- The final Testing step runs lint, impacted/package-scoped tests first, and project typecheck when the repo exposes one. Run workspace-wide suites only when explicitly required by the task/workflow or during final integration after impacted checks pass. -- 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, first call \`fn_task_list\` to see active tasks, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). -For any likely match in \`done\` or \`archived\`, call \`fn_task_get\` to inspect details before deciding. -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 - -**Broad-scope decomposition signals:** -- Size L tasks, especially when the planned step count would reach 9 or more. -- Plans whose implementation-step count would reach 12 or more (additive signal — counts even when the surrounding "more than 7/10 steps" threshold above has not yet fired). -- Tasks whose declared \`## File Scope\` would list 20 or more entries. -- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above 30 items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying. -- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph. - -## Triage tools -You have these extra tools during triage: -- \`fn_task_list\` — list existing active tasks -- \`fn_task_search\` — keyword search across tasks, including done and archived 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 - -## Decision-only task flag (noCommitsExpected) -When ALL of the following are true, include this metadata line in the header block after Size/Review Level: - -- Add this exact line: **No commits expected:** true - -Set it only when all of these conditions hold: -- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report" -- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations -- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision" - -Anti-heuristics (bias to false-negative when ambiguous): -- SET: Decide whether FN-XYZ needs a fix -- LEAVE UNSET: Investigate FN-XYZ -- LEAVE UNSET: Investigate FN-XYZ and fix if needed - -## 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. - -## Workflow Routing -- Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal. -- For investigation, audit, research, or decision-only tasks that produce no code changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow. -- For decision-only tasks (Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report), prefer \`builtin:quick-fix\` or a custom investigation workflow when one is available. -- For standard coding tasks, \`builtin:coding\` is the default and is usually appropriate. -- Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks. -- Match the task nature to the workflow description; descriptions are authoritative for routing decisions. - -## 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. - -Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk. - -## Output -Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` for review. - -## 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**: \`<rootDir>/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, agent logs). -- Authoritative task DB rows live at the **project root** SQLite file: \`<rootDir>/.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 - -<!-- UX criteria mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts — keep them aligned. --> - -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} - -## Surface Enumeration - -{Required for bug-fix tasks and UI-affordance add/remove tasks (adding, removing, or restructuring icons, buttons, chevrons/arrows, toggles, badges, menu entries, click targets): a checklist enumerating every surface the fixed invariant must hold across. Include every provider/bridge for streaming and agent paths; desktop AND mobile breakpoints; empty/undefined/duplicate/populated data states; and every hook/component/module that shares the affected logic. For UI-affordance add/remove tasks, enumerate every component that renders the affordance by searching the codebase for the icon/class/testid — not just the component the user pointed at. Explicitly check for leftover shells after removal (empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels) across both desktop and mobile breakpoints. Use the canonical checklist in docs/testing.md as the starting point.} - -## 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 - -> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed -> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps -> that are genuinely independent of their immediate predecessor; an unannotated step is -> assumed to depend on the one before it (fully sequential). Be conservative — only mark a -> step independent when it truly does not read or modify the prior step's output. - -### 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, asserting the invariant across all known surfaces (enumerate every provider/bridge, desktop + mobile breakpoints, and empty/undefined/populated data states) - -For bug-fix and UI-affordance add/remove tasks, paste and fill in this checklist in the \`## Surface Enumeration\` section: -- [ ] Providers / bridges / execution paths touched by the invariant -- [ ] Desktop + mobile breakpoints / platforms that exercise the behavior -- [ ] Empty / undefined / duplicate / populated data states -- [ ] Shared hooks / components / modules / helpers reusing the logic -- [ ] Every component that renders the affordance (search the codebase for the icon/class/testid, not just the one the user pointed at) -- [ ] Leftover shells after removal — empty buttons, orphaned click targets, now-unused wrappers, dangling aria-labels — are explicitly checked and fixed/hidden - -**Artifacts:** -- \`path/to/file\` (new | modified) - -### Step {N-1}: Testing & Verification - -> ZERO failures allowed for checks required by this task's quality gates. Run impacted/package-scoped verification first; run workspace-wide suites only when the task or workflow explicitly requires them, or during final integration after impacted checks pass. -> 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 impacted tests -- [ ] 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 — <short summary>\` (the \`<short summary>\` is required — use a concrete 5–10 word description) -- **Bug fixes:** \`fix({ID}): description\` (short, concrete summary required) -- **Tests:** \`test({ID}): description\` (short, concrete summary required) - -Good examples: -- \`feat(FN-1234): complete Step 2 — add retry guard for workflow step timeouts\` -- \`test(FN-1234): add regression tests for paused-session cleanup\` - -Bad example: -- \`feat(FN-1234): complete Step 2\` - -## 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 -- For bug fixes and UI-affordance add/remove tasks, the spec MUST include a \`## Surface Enumeration\` section. During self-review via \`fn_review_spec()\`, treat a missing section on a bug-fix or UI-affordance add/remove spec as a blocking REVISE. -- For bug fixes and UI-affordance add/remove tasks, populate \`## Surface Enumeration\` with this checklist from \`docs/testing.md\`: providers/bridges/execution paths; desktop + mobile breakpoints/platforms; empty/undefined/duplicate/populated data states; shared hooks/components/modules/helpers; every component that renders the affordance; leftover shells after removal. -- For bug fixes and UI-affordance add/remove tasks, regression tests must assert the invariant across all known surfaces — enumerate every provider/bridge, desktop + mobile breakpoints, empty/undefined/populated data states, and for UI-affordance changes every component rendering the affordance plus leftover shells after removal — not just the reported repro (see FN-5787/FN-5789/FN-5803, FN-5751, and FN-6115/FN-6118/FN-6123) -- 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, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). -For any likely match in \`done\` or \`archived\`, call \`fn_task_get\` to inspect details before deciding. -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. - -## Decision-only task flag (noCommitsExpected) -When ALL of the following are true, include this metadata line in the header block after Size: - -- Add this exact line: **No commits expected:** true - -Set it only when all of these conditions hold: -- Title/mission starts with decision verbs like "Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", or "Investigate and report" -- Acceptance criteria are strictly observational (record findings, log a decision, update task log/docs) with no required code/config/file mutations -- Task description explicitly says things like "no code changes expected" or "the deliverable is the recorded decision" - -Anti-heuristics (bias to false-negative when ambiguous): -- SET: Decide whether FN-XYZ needs a fix -- LEAVE UNSET: Investigate FN-XYZ -- LEAVE UNSET: Investigate FN-XYZ and fix if needed - -## 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. - -## Workflow Routing -Call \`fn_workflow_list\` and use workflow descriptions as the routing signal. For investigation/audit/research or decision-only tasks that meet the no-commits criteria above, include \`**No commits expected:** true\` in the PROMPT.md header and prefer \`builtin:quick-fix\` or a custom investigation workflow; standard coding tasks can stay on the default \`builtin:coding\`. Use \`fn_workflow_select\` for the current task or pass \`workflow_id\` to \`fn_task_create\` for subtasks. - -## 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 \`<rootDir>/.fusion/tasks/{TARGET_ID}/\` (\`task.json\`, \`PROMPT.md\`, \`attachments/\`, logs). -- Task DB truth lives at \`<rootDir>/.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. - -Never reference a \`.fusion/tasks/<id>/<file>\` artifact in Context, Steps, or File Scope unless (a) the file already exists, (b) the step explicitly creates it (listed as \`(new)\` under Artifacts), or (c) it is \`PROMPT.md\` / \`task.json\` / \`attachments/*\` for a sibling task. Save planning scratch as task documents via \`fn_task_document_write\`, not as files on disk. - -## Output -Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\` to confirm.`; export interface TriageProcessorOptions { pollIntervalMs?: number; @@ -666,6 +146,7 @@ export class TriageProcessor { /** Tasks killed by the stuck task detector (to avoid reporting as errors). */ private stuckAborted = new Set<string>(); private taskDeletedHandler?: (task: Task) => void; + private taskPausedHandler?: (task: Task) => void; /** * @param store — Task store instance (also used to listen for `settings:updated` events) @@ -738,6 +219,32 @@ export class TriageProcessor { this.activeSessions.delete(task.id); } }; + + this.taskPausedHandler = (task: Task) => { + if (!task?.id || (task.paused !== true && task.userPaused !== true)) { + return; + } + if (this.activeSubagentSessions.has(task.id)) { + this.disposeSubagentsForTask(task.id, "task paused"); + } + if (this.activeSessions.has(task.id)) { + const session = this.activeSessions.get(task.id)!; + planLog.log(`task paused — terminating triage session for ${task.id}`); + this.pauseAborted.add(task.id); + this.options.stuckTaskDetector?.untrackTask(task.id); + const sessionWithAbort = session as { + abort?: () => Promise<void>; + dispose: () => void; + }; + if (typeof sessionWithAbort.abort === "function") { + void sessionWithAbort.abort().catch((err) => { + planLog.warn(`Failed to abort triage session for ${task.id}: ${err}`); + }); + } + session.dispose(); + this.activeSessions.delete(task.id); + } + }; } start(): void { @@ -746,6 +253,9 @@ export class TriageProcessor { if (this.taskDeletedHandler && typeof this.store.on === "function") { this.store.on("task:deleted", this.taskDeletedHandler); } + if (this.taskPausedHandler && typeof this.store.on === "function") { + this.store.on("task:updated", this.taskPausedHandler); + } // Clear stale "planning" statuses left by a prior crash/restart. // No triage agent is actually running at startup, so any task still @@ -787,6 +297,9 @@ export class TriageProcessor { if (this.taskDeletedHandler && typeof this.store.off === "function") { this.store.off("task:deleted", this.taskDeletedHandler); } + if (this.taskPausedHandler && typeof this.store.off === "function") { + this.store.off("task:updated", this.taskPausedHandler); + } // 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"); @@ -927,6 +440,11 @@ export class TriageProcessor { return false; } + if (task.paused === true || task.userPaused === true) { + planLog.log(`${task.id} approved-spec recovery skipped — task is paused`); + return false; + } + if (!hasLatestSpecReviewApproval(task)) { return false; } @@ -1134,6 +652,10 @@ export class TriageProcessor { const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings()); const promptPath = `.fusion/tasks/${task.id}/PROMPT.md`; const isFast = task.executionMode === "fast"; + // FN-6236: this is the only legacy executionMode="fast" bridge. Downstream + // triage policy reads resolved workflow flags instead of the raw string. + const leanPlanning = settings.leanPlanning === true || isFast; + const autoApproveSpec = settings.autoApproveSpec === true || isFast; const agentWork = async () => { // Set status only after the semaphore slot has been acquired, so @@ -1235,7 +757,7 @@ export class TriageProcessor { specReviewVerdictRef, approvedCommentFingerprintRef, settings, - isFast, + autoApproveSpec, ), ]; @@ -1267,7 +789,7 @@ export class TriageProcessor { planLog.warn(`${task.id}: failed to resolve triage agent instructions, continuing with defaults: ${msg}`); } } - planLog.log(`${task.id}: planning in ${isFast ? "fast" : "standard"} mode`); + planLog.log(`${task.id}: planning in ${leanPlanning ? "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}).` : ""; @@ -1289,9 +811,27 @@ export class TriageProcessor { runContext: triageRunContext, }); + const workflowPlanningPrompt = leanPlanning + ? undefined + : await resolveTaskPlanningPrompt(this.store, task.id).catch(() => undefined); + const workflowFastPlanningPrompt = leanPlanning + ? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined) + : undefined; + // FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed. + const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage + ? resolveAgentPrompt("triage", settings.agentPrompts) + : ""; + const defaultTriagePrompt = resolveAgentPrompt("triage"); + const resolvedBasePrompt = userTriagePrompt + || (leanPlanning + ? (workflowFastPlanningPrompt || builtinSeamPrompt("planning-fast") || defaultTriagePrompt) + : (workflowPlanningPrompt || defaultTriagePrompt)); + // Apply the workflow-native triage policy renderer to both standard and + // fast prompts. Fast mode currently has no policy placeholders, making + // this a no-op there while still guaranteeing no dangling token leaks. + const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, settings); const triageLayers = buildPromptLayers({ - basePrompt: resolveAgentPrompt("triage", settings.agentPrompts) - || (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT), + basePrompt: renderedBasePrompt, goalContext: triageGoalResolution.goalContext, agentInstructions: [ triageIdentitySection, @@ -1315,9 +855,9 @@ export class TriageProcessor { }); // 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 + // 1. Task planning override pair + // 2. Planning/project/global fallbacks + // 3. Assigned durable agent runtime model pair when no fresh model pair exists const planningModel = resolvePlanningSessionModel( task.planningModelProvider, task.planningModelId, @@ -2267,8 +1807,8 @@ export class TriageProcessor { 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)"); + planLog.log(`${taskId}: spec review auto-approved (auto-approve spec)`); + await store.logEntry(taskId, "Spec review: APPROVE (auto-approve spec)"); return { content: [{ type: "text" as const, text: "APPROVE" }], details: {} }; } @@ -2448,7 +1988,7 @@ export class TriageProcessor { private async finalizeApprovedTask( task: Task, - written: string, + writtenInput: string, settings: Settings, options: { isReplan?: boolean; @@ -2456,6 +1996,7 @@ export class TriageProcessor { recoveryLogAction?: string; } = {}, ): Promise<void> { + let written = writtenInput; const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i); if (dupMatch) { @@ -2555,6 +2096,19 @@ export class TriageProcessor { } catch { // Fail open on persisted PROMPT.md parsing and keep using the in-memory parse. } + + const promptWithFrontendUxCriteria = applyFrontendUxCriteria(written, parsedFileScope); + if (promptWithFrontendUxCriteria !== written) { + const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); + try { + await writeFile(promptPath, promptWithFrontendUxCriteria, "utf-8"); + written = promptWithFrontendUxCriteria; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + planLog.warn(`${task.id}: failed to write Frontend UX Criteria to PROMPT.md (${message})`); + } + } + let taskIntentSignature: ReturnType<typeof extractIntentSignature> = { routePaths: [], filePaths: [], @@ -2728,6 +2282,25 @@ export class TriageProcessor { planLog.warn(`${task.id}: near-duplicate backstop failed open: ${message}`); } + let latestTransitionTask: Task | undefined; + try { + latestTransitionTask = await this.store.getTask(task.id); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + planLog.warn(`${task.id}: failed to re-read task before approved-spec transition (${message}); proceeding with original task snapshot`); + latestTransitionTask = task; + } + if (latestTransitionTask?.paused === true || latestTransitionTask?.userPaused === true) { + const restoreStatus = options.isReplan ? "needs-replan" : null; + await this.store.updateTask(task.id, { status: restoreStatus }); + await this.store.logEntry( + task.id, + "Specification approved but task is paused — leaving in triage, will resume on unpause", + ); + planLog.log(`${task.id} approved specification paused — leaving in triage, will resume on unpause`); + return; + } + if (settings.requirePlanApproval) { const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" }; if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) { @@ -3074,9 +2647,9 @@ The user has requested that this task be broken into smaller subtasks if it is c 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 will require MORE THAN 7 implementation steps +- The task affects MORE THAN 3 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 1-2 hours to complete - The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people **GOOD TO SPLIT:** diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 80aa18dcc8..d001ea53a4 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -1,4 +1,13 @@ -import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode, WorkflowNodeExtensionResult } from "@fusion/core"; +import type { + Settings, + TaskDetail, + TaskStep, + WorkflowIr, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrNodeKind, + WorkflowNodeExtensionResult, +} from "@fusion/core"; import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, getWorkflowExtensionRegistry, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core"; import { @@ -164,6 +173,27 @@ const TERMINAL_FAILURE: WorkflowGraphExecutorResult = { visitedNodeIds: [], }; +/** + * Engine-local mirror of core's workflow-owned merge/retry/recovery primitive + * region. Until the workflow interpreter owns merge policy end-to-end, graph + * execution treats any entry into this region as the terminal legacy `merge` + * seam so observable lifecycle behavior stays byte-identical with the legacy + * executor. Consolidate with a core export when one exists. + */ +const MERGE_REGION_KINDS = new Set<WorkflowIrNodeKind>([ + "merge-gate", + "merge-attempt", + "manual-merge-hold", + "retry-backoff", + "recovery-router", + "branch-group-member-integration", + "branch-group-promotion", +]); + +function isMergeRegionKind(kind: WorkflowIrNodeKind): boolean { + return MERGE_REGION_KINDS.has(kind); +} + function normalizeTouchedFile(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); @@ -273,6 +303,12 @@ export class WorkflowGraphExecutor { }; const visitedNodeIds: string[] = []; const inStack = new Set<string>(); + const syntheticMergeNode: WorkflowIrNode = { + id: "merge", + kind: "prompt", + column: "in-review", + config: { seam: "merge" }, + }; // Bounded-rework generalization (U6). A `kind: "rework"` edge is the only // legal cycle: it loops back to a "rework region head" (the edge's `to` node). @@ -499,6 +535,26 @@ export class WorkflowGraphExecutor { } }; + const runLegacyMergeSeam = async (): Promise<WorkflowNodeResult> => { + // The merge-policy primitive region is interpreter-owned policy. While the + // legacy lifecycle remains authoritative, reaching any of its node kinds is + // the terminal merge boundary: dispatch the same prompt/seam handler a + // legacy `config.seam: "merge"` node used, but record it under the stable + // legacy node id `merge` and never expose raw merge-region primitive ids. + visitedNodeIds.push(syntheticMergeNode.id); + const result = await this.executeNodeWithRetries( + syntheticMergeNode, + task, + settings, + context, + ir, + ); + if (result.contextPatch) Object.assign(context, result.contextPatch); + context[`node:${syntheticMergeNode.id}:outcome`] = result.outcome; + if (result.value !== undefined) context[`node:${syntheticMergeNode.id}:value`] = result.value; + return result; + }; + const traverseChildren = async ( node: WorkflowIrNode, sourceResult: WorkflowNodeResult, @@ -532,6 +588,11 @@ export class WorkflowGraphExecutor { aggregate = sourceResult; continue; } + if (target && isMergeRegionKind(target.kind)) { + aggregate = await runLegacyMergeSeam(); + if (aggregate.outcome === "failure") break; + continue; + } const child = await walk(edge.to); // A ReworkSignal propagated from deeper: bubble it further up unchanged. if (isReworkSignal(child)) return child; diff --git a/packages/engine/src/workflow-work-scheduler.ts b/packages/engine/src/workflow-work-scheduler.ts new file mode 100644 index 0000000000..8ae7206a86 --- /dev/null +++ b/packages/engine/src/workflow-work-scheduler.ts @@ -0,0 +1,52 @@ +import type { WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind } from "@fusion/core"; + +export interface WorkflowWorkSchedulerStore { + listDueWorkflowWorkItems(filter?: WorkflowWorkItemDueFilter): WorkflowWorkItem[]; + acquireWorkflowWorkItemLease( + id: string, + leaseOwner: string, + opts: { leaseDurationMs: number; now?: string }, + ): WorkflowWorkItem | null; +} + +export interface WorkflowWorkDispatch { + workItem: WorkflowWorkItem; + runId: string; + taskId: string; + nodeId: string; +} + +export interface ClaimWorkflowWorkOptions { + now?: string; + limit?: number; + leaseOwner: string; + leaseDurationMs: number; + kinds?: WorkflowWorkItemKind[]; +} + +export function claimDueWorkflowWorkItem( + store: WorkflowWorkSchedulerStore, + opts: ClaimWorkflowWorkOptions, +): WorkflowWorkDispatch | null { + const due = store.listDueWorkflowWorkItems({ + now: opts.now, + limit: opts.limit ?? 25, + kinds: opts.kinds, + }); + + for (const candidate of due) { + const workItem = store.acquireWorkflowWorkItemLease(candidate.id, opts.leaseOwner, { + now: opts.now, + leaseDurationMs: opts.leaseDurationMs, + }); + if (!workItem) continue; + return { + workItem, + runId: workItem.runId, + taskId: workItem.taskId, + nodeId: workItem.nodeId, + }; + } + + return null; +} diff --git a/packages/engine/src/worktree-backend.ts b/packages/engine/src/worktree-backend.ts index 37128f6b6a..55e85f4686 100644 --- a/packages/engine/src/worktree-backend.ts +++ b/packages/engine/src/worktree-backend.ts @@ -30,6 +30,151 @@ const NATIVE_TIMEOUT_MS = 120_000; const REMOVE_TIMEOUT_MS = 60_000; const MAX_BUFFER = 10 * 1024 * 1024; + +export type WorktreeRemoveOutcome = + | { removed: true; classification: "removed" } + | { + removed: false; + harmless: true; + classification: "not-registered-after-prune"; + message: string; + stderrPreview: string; + pathExists: boolean; + gitFileExists: boolean; + }; + +const HARMLESS_MERGE_REMOVE_ERROR_PATTERNS = [ + /validation failed, cannot remove working tree/i, + /is not a \.git file/i, + /is not a working tree/i, + /not a git repository/i, + /No such file or directory/i, +] as const; + +function previewError(error: unknown): string { + const stderr = getErrorStderr(error); + const message = error instanceof Error ? error.message : String(error); + return (stderr || message).slice(0, 4096); +} + +function normalizeComparablePath(value: string): string { + const resolved = resolve(value); + return resolved.startsWith("/private/var/") ? resolved.slice("/private".length) : resolved; +} + +function porcelainContainsWorktree(stdout: string, worktreePath: string): boolean { + const target = normalizeComparablePath(worktreePath); + const privateTarget = target.startsWith("/var/") ? `/private${target}` : target; + for (const line of stdout.split("\n")) { + if (!line.startsWith("worktree ")) continue; + const candidate = normalizeComparablePath(line.slice("worktree ".length).trim()); + if (candidate === target || candidate === privateTarget) return true; + } + return false; +} + +function isMergeTempCleanupCandidate(input: { worktreePath: string; reason: RemovalReason }, error: unknown): boolean { + if (input.reason !== RemovalReason.MergerCleanup && input.reason !== RemovalReason.MergerPostMerge) return false; + const base = basename(input.worktreePath); + const looksLikeFusionMergeTemp = base.startsWith("fusion-ai-merge-") || base.startsWith("post-merge-"); + if (!looksLikeFusionMergeTemp) return false; + const detail = previewError(error); + return HARMLESS_MERGE_REMOVE_ERROR_PATTERNS.some((pattern) => pattern.test(detail)); +} + +async function classifyHarmlessMergeRemoveFailure(input: { + rootDir: string; + worktreePath: string; + reason: RemovalReason; + taskId?: string; + audit?: RunAuditor; +}, error: unknown): Promise<WorktreeRemoveOutcome | null> { + if (!isMergeTempCleanupCandidate(input, error)) return null; + + const stderrPreview = previewError(error); + const pathExists = existsSync(input.worktreePath); + const gitFileExists = existsSync(resolve(input.worktreePath, ".git")); + + let stdout: string; + try { + await execAsync("git worktree prune", { + cwd: input.rootDir, + encoding: "utf-8", + timeout: NATIVE_TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + + const listResult = await execAsync("git worktree list --porcelain", { + cwd: input.rootDir, + encoding: "utf-8", + timeout: 10_000, + maxBuffer: MAX_BUFFER, + }); + stdout = typeof listResult === "string" ? listResult : String(listResult.stdout ?? ""); + } catch (probeError) { + await input.audit?.git({ + type: "worktree:remove-classification-probe-failed", + target: input.worktreePath, + metadata: { + taskId: input.taskId, + reason: input.reason, + stderrPreview, + probeError: previewError(probeError), + pathExists, + gitFileExists, + }, + }); + return null; + } + const registeredAfterPrune = porcelainContainsWorktree(stdout, input.worktreePath); + + if (registeredAfterPrune) { + await input.audit?.git({ + type: "worktree:remove-leaked-registered-worktree", + target: input.worktreePath, + metadata: { + taskId: input.taskId, + reason: input.reason, + registeredAfterPrune: true, + stderrPreview, + pathExists, + gitFileExists, + }, + }); + return null; + } + + const message = pathExists + ? "cleanup remove failed, but no registered worktree remains after prune; leftover directory was not deleted automatically" + : "cleanup remove failed, but no registered worktree remains after prune"; + await input.audit?.git({ + type: "worktree:remove-classified-harmless", + target: input.worktreePath, + metadata: { + taskId: input.taskId, + reason: input.reason, + classification: "not-registered-after-prune", + registeredAfterPrune: false, + stderrPreview, + pathExists, + gitFileExists, + nextAction: pathExists + ? "inspect the leftover temp directory before deleting filesystem residue" + : "no operator action required", + }, + }); + + return { + removed: false, + harmless: true, + classification: "not-registered-after-prune", + message, + stderrPreview, + pathExists, + gitFileExists, + }; +} + /** * worktrunk CLI mapping (verified 2026-05-15 from README + worktrunk.dev docs): * - create -> `wt switch --create <branch> [--base <startPoint>]` @@ -137,6 +282,22 @@ function getErrorExitCode(error: unknown): number | null { return null; } +function getErrorMessageWithStderr(error: unknown): string { + const message = + error instanceof Error + ? error.message + : error && typeof error === "object" && "message" in error + ? String((error as { message?: unknown }).message) + : String(error); + const stderr = getErrorStderr(error); + return stderr ? `${message}\n${stderr}` : message; +} + +function isRecoverableNativeWorktreeRemoveError(error: unknown): boolean { + const message = getErrorMessageWithStderr(error); + return /Directory not empty/i.test(message) || /failed to delete/i.test(message) || /contains modified or untracked files/i.test(message); +} + function findStringByKey(value: unknown, key: string): string | null { if (!value || typeof value !== "object") return null; if (Array.isArray(value)) { @@ -396,12 +557,41 @@ export class NativeWorktreeBackend implements WorktreeBackend { } async remove(input: WorktreeRemoveInput): Promise<void> { - await execAsync(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, { - cwd: input.rootDir, - encoding: "utf-8", - timeout: REMOVE_TIMEOUT_MS, - maxBuffer: MAX_BUFFER, - }); + try { + await execAsync(`git worktree remove --force ${quoteShellArg(input.worktreePath)}`, { + cwd: input.rootDir, + encoding: "utf-8", + timeout: REMOVE_TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + return; + } catch (error) { + if (!isRecoverableNativeWorktreeRemoveError(error)) { + throw error; + } + + const errorMessage = getErrorMessageWithStderr(error); + this.deps.logger?.warn?.( + `[worktree-backend] git worktree remove failed for ${input.worktreePath}: ${errorMessage} — falling back to filesystem removal`, + ); + await this.deps.audit?.git({ + type: "worktree:remove-fallback", + target: input.worktreePath, + metadata: { + fallback: "filesystem-non-empty", + error: errorMessage, + }, + }); + + await rm(input.worktreePath, { recursive: true, force: true }); + await pruneWorktreeAdminEntries({ + rootDir: input.rootDir, + auditor: this.deps.audit, + reason: "remove-non-empty-fallback", + target: input.worktreePath, + logger: this.deps.logger, + }); + } } async sync(input: WorktreeSyncInput): Promise<{ skipped: boolean }> { @@ -777,7 +967,7 @@ export async function removeWorktree(input: { liveOwnerProbe?: LiveBindingProbe; processActiveProbe?: ProcessActiveProbe; reconcileMinIdleMs?: number; -}): Promise<void> { +}): Promise<WorktreeRemoveOutcome> { const logger = { log: (_message: string): void => {}, warn: (_message: string): void => {}, @@ -851,8 +1041,11 @@ export async function removeWorktree(input: { target: input.worktreePath, }); } - return; + return { removed: true, classification: "removed" }; } catch (error) { + const classified = await classifyHarmlessMergeRemoveFailure(input, error); + if (classified) return classified; + if (!(error instanceof WorktrunkOperationError) || input.settings.worktrunk?.onFailure !== "fallback-native") { throw error; } @@ -870,8 +1063,15 @@ export async function removeWorktree(input: { }); const native = new NativeWorktreeBackend({ logger, settings: input.settings }); - await native.remove(removeInput); - await input.audit?.git({ type: "worktree:remove", target: input.worktreePath }); + try { + await native.remove(removeInput); + await input.audit?.git({ type: "worktree:remove", target: input.worktreePath }); + return { removed: true, classification: "removed" }; + } catch (nativeError) { + const classified = await classifyHarmlessMergeRemoveFailure(input, nativeError); + if (classified) return classified; + throw nativeError; + } } } diff --git a/packages/engine/src/worktree-paths.ts b/packages/engine/src/worktree-paths.ts index 18e7c7debe..b399444ed1 100644 --- a/packages/engine/src/worktree-paths.ts +++ b/packages/engine/src/worktree-paths.ts @@ -4,6 +4,23 @@ import type { Settings } from "@fusion/core"; import type { WorktreeBackendKind } from "./worktree-backend.js"; import { canonicalizePath } from "./worktree-pool.js"; +export const AI_MERGE_DIRNAME = ".ai-merge"; + +export function isAiMergeContainerDir(name: string): boolean { + return name === AI_MERGE_DIRNAME; +} + +export function resolveAiMergeRootPath( + rootDir: string, + settings: Pick<Settings, "worktreesDir"> | undefined, +): string { + return join(resolveWorktreesDir(rootDir, settings), AI_MERGE_DIRNAME); +} + +export function resolveLegacyAiMergeRootPath(rootDir: string): string { + return join(rootDir, ".fusion", "ai-merge"); +} + export function resolveWorktreesDir( rootDir: string, settings: Pick<Settings, "worktreesDir"> | undefined, diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 939b5f77d7..58ed42fb9f 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -5,7 +5,7 @@ import { basename, join, relative, resolve, isAbsolute } from "node:path"; import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js"; import { worktreePoolLog } from "./logger.js"; -import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js"; +import { isAiMergeContainerDir, isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js"; import { canonicalFusionBranchName } from "./worktree-names.js"; import { resolveWorktrunkBinary, @@ -704,7 +704,7 @@ export async function scanIdleWorktrees( try { const entries = readdirSync(worktreesDir, { withFileTypes: true }); dirs = entries - .filter((e) => e.isDirectory()) + .filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)) .map((e) => join(worktreesDir, e.name)); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -766,7 +766,7 @@ export async function cleanupOrphanedWorktrees( if (existsSync(worktreesDir)) { try { dirs = readdirSync(worktreesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) + .filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name)) .map((e) => join(worktreesDir, e.name)); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -863,8 +863,8 @@ export async function reapOrphanWorktrees( try { entries = readdirSync(worktreesDir, { withFileTypes: true }) .filter((e) => { - // Only real directories — never symlinks - if (!e.isDirectory()) return false; + // Only real directories — never symlinks; never the dedicated AI-merge container. + if (!e.isDirectory() || isAiMergeContainerDir(e.name)) return false; try { return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink(); } catch { diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 691f8a3286..8cec5c63de 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -74,22 +74,24 @@ export default defineConfig({ "src/__tests__/executor-recovery.test.ts", "src/__tests__/executor-base-commit-capture.test.ts", "src/__tests__/executor-capture-modified-files-attribution.test.ts", - "src/__tests__/triage.test.ts", "src/__tests__/triage-preflight.test.ts", "src/__tests__/scheduler.test.ts", "src/__tests__/scheduler-node-routing.test.ts", "src/__tests__/scheduler-overlap-requeue.test.ts", "src/__tests__/mission-scheduler.test.ts", - "src/__tests__/self-healing.test.ts", "src/__tests__/heartbeat-monitor.test.ts", "src/__tests__/workflow-node-handlers.test.ts", + "src/__tests__/workflow-policy-ownership-map.test.ts", ], // No per-file quarantine excludes needed here: engine-core's // membership is the explicit include allow-list above, so any // quarantined file (e.g. merger-file-scope-invariant.test.ts) is // already absent. The quarantine excludes live in engine-default, // whose `src/**/*.test.ts` glob is what would otherwise pick them up. - exclude: ["node_modules/**", "dist/**"], + exclude: [ + "node_modules/**", + "dist/**", + ], }, }, { @@ -108,6 +110,8 @@ export default defineConfig({ "src/__tests__/merger-file-scope-invariant.test.ts", "src/__tests__/project-engine-manager.test.ts", "src/__tests__/merger-ai-cleanup.test.ts", + "src/__tests__/merger-ai-cleanup-active-session.test.ts", + "src/__tests__/merger-ai.test.ts", ], }, }, @@ -118,7 +122,10 @@ export default defineConfig({ include: ["src/__tests__/reliability-interactions/**/*.test.ts"], // Mirror the engine-default exclusion so reliability slow tests // also tier into engine-slow. - exclude: ["src/**/*.slow.test.ts"], + exclude: [ + "src/**/*.slow.test.ts", + "src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts", + ], // These tests assert event ordering across real worktrees. Parallel // execution under merger load caused subprocess-guard timeouts and // SQLite rowid interleaving (e.g. FN-5521 hit diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index 7abceaccc5..589d6aa389 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/i18n +## 0.39.4 + +### Patch Changes + +- @fusion/core@0.42.0 + ## 0.39.3 ### Patch Changes diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index b693b53bd8..49899baf14 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -564,6 +564,7 @@ "last24h": "Last 24h", "last7d": "Last 7 days", "lastHeartbeat": "Last heartbeat", + "lastHeartbeatAt": "Last: {{time}}", "latestRunLabel": "Latest run", "layoutAuto": "Auto", "layoutAutoAria": "Automatic layout", @@ -656,6 +657,7 @@ "next": "Next", "nextExpected": "Next expected", "nextHeartbeat": "Next heartbeat in {{elapsed}}", + "nextHeartbeatAt": "Next: {{time}}", "noActiveAssignment": "No active assignment", "noActiveEligible": "No active agents eligible to pause", "noActivityYet": "No activity yet", @@ -2507,8 +2509,6 @@ "inline": { "agent": "Agent", "breakDownSubtasks": "Break down into AI-generated subtasks", - "browserVerify": "Browser Verify", - "browserVerifyChecked": "Browser Verify ✓", "clearSelection": "Clear selection", "collapse": "Collapse", "collapseDescription": "Collapse description", @@ -2517,7 +2517,6 @@ "custom": "Custom", "deps": "Deps", "editingDescription": "Editing Description", - "enableBrowserVerification": "Enable browser verification workflow step", "enterDescriptionFirst": "Enter a description first", "expand": "Expand", "expandDescription": "Expand description", @@ -2531,6 +2530,8 @@ "node": "Node", "noExistingTasks": "No existing tasks", "openPlanningMode": "Open planning mode with current description", + "optionalWorkflowStepChecked": "{{name}} ✓", + "optionalWorkflowSteps": "Optional workflow steps", "plan": "Plan", "preset": "Preset", "priority": "Priority", @@ -2542,6 +2543,7 @@ "selectAgent": "Select agent", "selectExecutionNode": "Select execution node", "subtask": "Subtask", + "toggleOptionalWorkflowStep": "Toggle optional workflow step: {{name}}", "useDefault": "Use default", "whatNeedsToBeDone": "What needs to be done?" }, @@ -4299,8 +4301,10 @@ "saving": "Saving..." }, "addCustom": "Add Custom Provider", + "apiKeyKeepPlaceholder": "Leave blank to keep current key", "apiKeyLabel": "API key", "apiTypeAnthropic": "Anthropic-compatible", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "API type is invalid.", "apiTypeLabel": "API type", "apiTypeOpenAi": "OpenAI-compatible", @@ -6096,6 +6100,7 @@ "changes": "Changes", "comments": "Comments", "definition": "Definition", + "chat": "Chat", "documents": "Documents", "logs": "Logs", "model": "Model", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 0e25bd2c8b..3492c793ea 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -564,6 +564,7 @@ "last24h": "Últimas 24h", "last7d": "Últimos 7 días", "lastHeartbeat": "Última: {{time}}", + "lastHeartbeatAt": "Última: {{time}}", "latestRunLabel": "Última ejecución", "layoutAuto": "Auto", "layoutAutoAria": "Diseño automático", @@ -655,7 +656,8 @@ "newAgent": "Nuevo agente", "next": "Siguiente", "nextExpected": "Próximo esperado", - "nextHeartbeat": "Próxima: {{time}}", + "nextHeartbeat": "", + "nextHeartbeatAt": "Próxima: {{time}}", "noActiveAssignment": "Sin asignación activa", "noActiveEligible": "Sin agentes activos elegibles para pausar", "noActivityYet": "Sin actividad aún", @@ -2506,8 +2508,6 @@ "inline": { "agent": "Agente", "breakDownSubtasks": "Desglosar en subtareas generadas por IA", - "browserVerify": "Verificación del navegador", - "browserVerifyChecked": "Verificación del navegador ✓", "clearSelection": "Borrar selección", "collapse": "Contraer", "collapseDescription": "Contraer descripción", @@ -2516,7 +2516,6 @@ "custom": "Personalizado", "deps": "Dependencias", "editingDescription": "Descripción de edición", - "enableBrowserVerification": "Habilitar paso de flujo de verificación del navegador", "enterDescriptionFirst": "Ingrese una descripción primero", "expand": "Expandir", "expandDescription": "Expandir descripción", @@ -4300,6 +4299,7 @@ "addCustom": "Agregar proveedor personalizado", "apiKeyLabel": "Clave de API", "apiTypeAnthropic": "Compatible con Anthropic", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "El tipo de API no es válido.", "apiTypeLabel": "Tipo de API", "apiTypeOpenAi": "Compatible con OpenAI", @@ -6046,6 +6046,7 @@ "changes": "Cambios", "comments": "Comentarios", "definition": "Definición", + "chat": "Chat", "documents": "Documentos", "logs": "Registros", "model": "Modelo", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 86c4aef2a3..1195ed1917 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -564,6 +564,7 @@ "last24h": "Dernières 24h", "last7d": "7 derniers jours", "lastHeartbeat": "Dernière : {{time}}", + "lastHeartbeatAt": "Dernière : {{time}}", "latestRunLabel": "Dernière exécution", "layoutAuto": "Auto", "layoutAutoAria": "Disposition automatique", @@ -655,7 +656,8 @@ "newAgent": "Nouvel agent", "next": "Suivant", "nextExpected": "Prochain attendu", - "nextHeartbeat": "Prochaine : {{time}}", + "nextHeartbeat": "", + "nextHeartbeatAt": "Prochaine : {{time}}", "noActiveAssignment": "Aucune assignation active", "noActiveEligible": "Aucun agent actif éligible à la pause", "noActivityYet": "Aucune activité pour l'instant", @@ -2506,8 +2508,6 @@ "inline": { "agent": "Agent", "breakDownSubtasks": "Décomposer en sous-tâches générées par l'IA", - "browserVerify": "Vérification du navigateur", - "browserVerifyChecked": "Vérification du navigateur ✓", "clearSelection": "Effacer la sélection", "collapse": "Réduire", "collapseDescription": "Décrire la description", @@ -2516,7 +2516,6 @@ "custom": "Personnalisé", "deps": "Dépendances", "editingDescription": "Description d'édition", - "enableBrowserVerification": "Activer l'étape de flux de vérification du navigateur", "enterDescriptionFirst": "Entrez d'abord une description", "expand": "Développer", "expandDescription": "Développer la description", @@ -4300,6 +4299,7 @@ "addCustom": "Ajouter un fournisseur personnalisé", "apiKeyLabel": "Clé API", "apiTypeAnthropic": "Compatible avec Anthropic", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "Le type d'API est invalide.", "apiTypeLabel": "Type d'API", "apiTypeOpenAi": "Compatible avec OpenAI", @@ -6046,6 +6046,7 @@ "changes": "Modifications", "comments": "Commentaires", "definition": "Définition", + "chat": "Chat", "documents": "Documents", "logs": "Journaux", "model": "Modèle", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 8dfa242996..18892afaed 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -558,6 +558,7 @@ "last24h": "최근 24시간", "last7d": "최근 7일", "lastHeartbeat": "마지막 하트비트", + "lastHeartbeatAt": "", "latestRunLabel": "최신 실행", "layoutAuto": "자동", "layoutAutoAria": "자동 레이아웃", @@ -649,6 +650,7 @@ "next": "다음", "nextExpected": "다음 예정", "nextHeartbeat": "다음 하트비트까지 {{elapsed}}", + "nextHeartbeatAt": "", "noActiveAssignment": "활성 할당 없음", "noActiveEligible": "일시정지 가능한 활성 에이전트 없음", "noActivityYet": "아직 활동 없음", @@ -2506,8 +2508,6 @@ "inline": { "agent": "에이전트", "breakDownSubtasks": "AI 생성 하위 작업으로 분해", - "browserVerify": "브라우저 검증", - "browserVerifyChecked": "브라우저 검증 ✓", "clearSelection": "선택 취소", "collapse": "접기", "collapseDescription": "설명 접기", @@ -2516,7 +2516,6 @@ "custom": "사용자 정의", "deps": "의존성", "editingDescription": "설명 편집 중", - "enableBrowserVerification": "브라우저 검증 워크플로 단계 활성화", "enterDescriptionFirst": "먼저 설명을 입력하세요.", "expand": "펼치기", "expandDescription": "설명 펼치기", @@ -4300,6 +4299,7 @@ "addCustom": "사용자 정의 공급자 추가", "apiKeyLabel": "API 키", "apiTypeAnthropic": "Anthropic 호환", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "API 유형이 유효하지 않습니다.", "apiTypeLabel": "API 유형", "apiTypeOpenAi": "OpenAI 호환", @@ -6046,6 +6046,7 @@ "changes": "변경 사항", "comments": "댓글", "definition": "정의", + "chat": "Chat", "documents": "문서", "logs": "로그", "model": "모델", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 0b77256cc2..4dc490a62b 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -558,6 +558,7 @@ "last24h": "最近 24 小时", "last7d": "最近 7 天", "lastHeartbeat": "上次:{{time}}", + "lastHeartbeatAt": "上次:{{time}}", "latestRunLabel": "最新运行", "layoutAuto": "自动", "layoutAutoAria": "自动布局", @@ -648,7 +649,8 @@ "newAgent": "新建智能体", "next": "下一步", "nextExpected": "下次预期", - "nextHeartbeat": "下次:{{time}}", + "nextHeartbeat": "", + "nextHeartbeatAt": "下次:{{time}}", "noActiveAssignment": "无活动任务", "noActiveEligible": "没有符合条件可暂停的活动代理", "noActivityYet": "暂无活动", @@ -2506,8 +2508,6 @@ "inline": { "agent": "代理", "breakDownSubtasks": "分解为 AI 生成的子任务", - "browserVerify": "浏览器验证", - "browserVerifyChecked": "浏览器验证 ✓", "clearSelection": "清除选择", "collapse": "折叠", "collapseDescription": "折叠描述", @@ -2516,7 +2516,6 @@ "custom": "自定义", "deps": "依赖", "editingDescription": "编辑描述", - "enableBrowserVerification": "启用浏览器验证工作流步骤", "enterDescriptionFirst": "先输入描述", "expand": "展开", "expandDescription": "展开描述", @@ -4300,6 +4299,7 @@ "addCustom": "添加自定义提供程序", "apiKeyLabel": "API 密钥", "apiTypeAnthropic": "Anthropic 兼容", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "API 类型无效。", "apiTypeLabel": "API 类型", "apiTypeOpenAi": "OpenAI 兼容", @@ -6046,6 +6046,7 @@ "changes": "变更", "comments": "评论", "definition": "定义", + "chat": "Chat", "documents": "文档", "logs": "日志", "model": "模型", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 419267b7bd..f008ffbc41 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -558,6 +558,7 @@ "last24h": "最近 24 小時", "last7d": "最近 7 天", "lastHeartbeat": "上次:{{time}}", + "lastHeartbeatAt": "上次:{{time}}", "latestRunLabel": "最新執行", "layoutAuto": "自動", "layoutAutoAria": "自動版面", @@ -648,7 +649,8 @@ "newAgent": "新增智能體", "next": "下一步", "nextExpected": "下次預期", - "nextHeartbeat": "下次:{{time}}", + "nextHeartbeat": "", + "nextHeartbeatAt": "下次:{{time}}", "noActiveAssignment": "無活動任務", "noActiveEligible": "沒有符合條件可暫停的活動代理", "noActivityYet": "尚無活動", @@ -2506,8 +2508,6 @@ "inline": { "agent": "代理", "breakDownSubtasks": "分解為 AI 生成的子工作", - "browserVerify": "瀏覽器驗證", - "browserVerifyChecked": "瀏覽器驗證 ✓", "clearSelection": "清除選擇", "collapse": "折疊", "collapseDescription": "折疊說明", @@ -2516,7 +2516,6 @@ "custom": "自訂", "deps": "相依性", "editingDescription": "編輯說明", - "enableBrowserVerification": "啟用瀏覽器驗證工作流程步驟", "enterDescriptionFirst": "先輸入說明", "expand": "展開", "expandDescription": "展開說明", @@ -4300,6 +4299,7 @@ "addCustom": "添加自訂提供者", "apiKeyLabel": "API 密鑰", "apiTypeAnthropic": "Anthropic 相容", + "apiTypeGoogle": "Google Generative AI", "apiTypeInvalid": "API 類型無效。", "apiTypeLabel": "API 類型", "apiTypeOpenAi": "OpenAI 相容", @@ -6046,6 +6046,7 @@ "changes": "變更", "comments": "評論", "definition": "定義", + "chat": "Chat", "documents": "文件", "logs": "日誌", "model": "模型", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index a2cdf24626..2814b421a1 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/i18n", - "version": "0.39.3", + "version": "0.39.4", "license": "MIT", "description": "Fusion i18n: authored translation catalogs and shared i18next configuration for the Fusion dashboard and terminal UI.", "type": "module", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 307209af43..a05b41d398 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -2501,8 +2501,6 @@ export default interface Resources { "inline": { "agent": "Agent", "breakDownSubtasks": "Break down into AI-generated subtasks", - "browserVerify": "Browser Verify", - "browserVerifyChecked": "Browser Verify ✓", "clearSelection": "Clear selection", "collapse": "Collapse", "collapseDescription": "Collapse description", @@ -2511,7 +2509,6 @@ export default interface Resources { "custom": "Custom", "deps": "Deps", "editingDescription": "Editing Description", - "enableBrowserVerification": "Enable browser verification workflow step", "enterDescriptionFirst": "Enter a description first", "expand": "Expand", "expandDescription": "Expand description", @@ -2525,6 +2522,8 @@ export default interface Resources { "noExistingTasks": "No existing tasks", "node": "Node", "openPlanningMode": "Open planning mode with current description", + "optionalWorkflowStepChecked": "{{name}} ✓", + "optionalWorkflowSteps": "Optional workflow steps", "plan": "Plan", "preset": "Preset", "priority": "Priority", @@ -2536,6 +2535,7 @@ export default interface Resources { "selectAgent": "Select agent", "selectExecutionNode": "Select execution node", "subtask": "Subtask", + "toggleOptionalWorkflowStep": "Toggle optional workflow step: {{name}}", "useDefault": "Use default", "whatNeedsToBeDone": "What needs to be done?" }, diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 1ea9da576d..17aa5c8e44 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/mobile +## 0.42.0 + ## 0.41.0 ## 0.40.1 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index ce26fb71ef..47c5a58219 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/mobile", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion mobile: Capacitor wrapper around the Fusion dashboard for iOS and Android.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/pi-claude-cli/CHANGELOG.md b/packages/pi-claude-cli/CHANGELOG.md index b0ae1185e9..15730ec2eb 100644 --- a/packages/pi-claude-cli/CHANGELOG.md +++ b/packages/pi-claude-cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @fusion/pi-claude-cli +## 0.42.0 + ## 0.41.0 ## 0.40.1 diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index bf98ebb866..dd6a054c63 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/pi-claude-cli", - "version": "0.41.0", + "version": "0.42.0", "description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.", "license": "MIT", "private": true, diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index e105980296..12180f8ed2 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/plugin-sdk +## 0.42.0 + +### Patch Changes + +- @fusion/core@0.42.0 + ## 0.41.0 ### Patch Changes diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index e30e0d1cd6..7ae04529f5 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/plugin-sdk", - "version": "0.41.0", + "version": "0.42.0", "license": "MIT", "description": "Fusion plugin SDK: types and helpers for authoring third-party plugins that extend the Fusion dashboard and engine.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md b/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md index f47e35a2a3..fd896c9dbf 100644 --- a/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-auto-label/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/auto-label +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-auto-label/package.json b/plugins/examples/fusion-plugin-auto-label/package.json index c02cb0644d..7d1c2bf8df 100644 --- a/plugins/examples/fusion-plugin-auto-label/package.json +++ b/plugins/examples/fusion-plugin-auto-label/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/auto-label", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Automatically labels tasks based on description content", "keywords": [ diff --git a/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md b/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md index 219567d01a..c338fff96c 100644 --- a/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-ci-status/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/ci-status +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-ci-status/package.json b/plugins/examples/fusion-plugin-ci-status/package.json index 477e75ec59..3a442d2b46 100644 --- a/plugins/examples/fusion-plugin-ci-status/package.json +++ b/plugins/examples/fusion-plugin-ci-status/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/ci-status", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Polls CI status for branches and provides a custom API to query results", "keywords": [ diff --git a/plugins/examples/fusion-plugin-notification/CHANGELOG.md b/plugins/examples/fusion-plugin-notification/CHANGELOG.md index 7e9b351d43..c084da6bbe 100644 --- a/plugins/examples/fusion-plugin-notification/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-notification/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/notification +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-notification/package.json b/plugins/examples/fusion-plugin-notification/package.json index 00e5784636..3a371cc413 100644 --- a/plugins/examples/fusion-plugin-notification/package.json +++ b/plugins/examples/fusion-plugin-notification/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/notification", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Example Fusion plugin that sends webhook notifications on task lifecycle events", "keywords": [ diff --git a/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md b/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md index 6a52543eac..2ea29e7b8b 100644 --- a/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md +++ b/plugins/examples/fusion-plugin-settings-demo/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/settings-demo +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/examples/fusion-plugin-settings-demo/package.json b/plugins/examples/fusion-plugin-settings-demo/package.json index 923600351c..85d6f88aa7 100644 --- a/plugins/examples/fusion-plugin-settings-demo/package.json +++ b/plugins/examples/fusion-plugin-settings-demo/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/settings-demo", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Example Fusion plugin demonstrating settings schema and runtime configuration", "keywords": [ diff --git a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md index 4722e97fc8..82b82072c3 100644 --- a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/acp-runtime +## 0.1.4 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json index 0666d520be..00d898cca2 100644 --- a/plugins/fusion-plugin-acp-runtime/package.json +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/acp-runtime", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "ACP (Agent Client Protocol) runtime plugin for Fusion — drives any ACP-compatible agent over JSON-RPC/stdio", "keywords": [ diff --git a/plugins/fusion-plugin-agent-browser/CHANGELOG.md b/plugins/fusion-plugin-agent-browser/CHANGELOG.md index 266c84bab1..cfd4d2a913 100644 --- a/plugins/fusion-plugin-agent-browser/CHANGELOG.md +++ b/plugins/fusion-plugin-agent-browser/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/agent-browser +## 0.1.24 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.1.23 ### Patch Changes diff --git a/plugins/fusion-plugin-agent-browser/package.json b/plugins/fusion-plugin-agent-browser/package.json index 1367f6bc5c..0670100c84 100644 --- a/plugins/fusion-plugin-agent-browser/package.json +++ b/plugins/fusion-plugin-agent-browser/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/agent-browser", - "version": "0.1.23", + "version": "0.1.24", "type": "module", "description": "Agent Browser runtime and prompt/skill/workflow contributions for Fusion", "private": true, diff --git a/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md b/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md index b8991a365a..fb191346c1 100644 --- a/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md +++ b/plugins/fusion-plugin-cli-printing-press/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/cli-printing-press +## 0.1.21 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.20 ### Patch Changes diff --git a/plugins/fusion-plugin-cli-printing-press/package.json b/plugins/fusion-plugin-cli-printing-press/package.json index b55b64ff37..d6f240e76a 100644 --- a/plugins/fusion-plugin-cli-printing-press/package.json +++ b/plugins/fusion-plugin-cli-printing-press/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/cli-printing-press", - "version": "0.1.20", + "version": "0.1.21", "type": "module", "description": "CLI Printing Press plugin package for Fusion", "private": true, diff --git a/plugins/fusion-plugin-compound-engineering/CHANGELOG.md b/plugins/fusion-plugin-compound-engineering/CHANGELOG.md index 7485e2ac43..954f525cb4 100644 --- a/plugins/fusion-plugin-compound-engineering/CHANGELOG.md +++ b/plugins/fusion-plugin-compound-engineering/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/compound-engineering +## 0.1.4 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md index 9881c2fa66..c3184f03de 100644 --- a/plugins/fusion-plugin-compound-engineering/README.md +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -57,7 +57,10 @@ Lifecycle states are `launching → active → awaiting_input → completed`, pl `error` and `interrupted`. On interrupt or error the orchestrator **auto-saves progress and emits an observable event — never silent loss** — and an `interrupted`/`error` session can be resumed/retried back to its current -question. +question. If the server restarts while a session is already `awaiting_input`, +submitting the pending answer rehydrates the live interactive handle from the +persisted conversation history before continuing, so old answerable sessions do +not require a separate resume action. ### Multiple sessions @@ -71,10 +74,17 @@ activity; from there you can: - **switch** between sessions — the panel stays visible while a flow is open, and a session you switch away from keeps running server-side, - **resume** an `interrupted`/`error` session from where it stopped, +- **cancel** an in-flight (`launching`/`active`/`awaiting_input`) session via + `POST /sessions/:id/cancel`, which stops any live in-process handle, flushes + live progress into history, and keeps the row as `interrupted` with a + `Cancelled by user` marker for inspection/resume, - **discard** a settled (completed/error/interrupted) session via `DELETE /sessions/:id`, which disposes any live handle before deleting the row (pipeline-link rows are kept — board-task provenance survives). +Cancel and discard are intentionally different: cancel stops work but preserves +conversation/progress; discard removes the row entirely. + The list refreshes on any CE push event and falls back to polling `GET /sessions` while any session has a turn in flight. @@ -82,7 +92,9 @@ The list refreshes on any CE push event and falls back to polling Turn execution is **detached**: `POST /sessions`, `/answer`, and `/resume` return as soon as the session row reflects the request, with the agent turn -running in the background. While it runs: +running in the background. Closing the flow does not cancel the server-side +agent; use `POST /sessions/:id/cancel` (or the dashboard cancel icon button) to stop +an in-flight turn while preserving the session as `interrupted`. While it runs: - The engine streams **live progress** through the seam's `onProgress` option (thinking/text deltas + tool start/end markers — a host capability any diff --git a/plugins/fusion-plugin-compound-engineering/package.json b/plugins/fusion-plugin-compound-engineering/package.json index 46bda11f4e..852cfe59c5 100644 --- a/plugins/fusion-plugin-compound-engineering/package.json +++ b/plugins/fusion-plugin-compound-engineering/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/compound-engineering", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "Compound Engineering plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts index 0aacbd045e..7f05475934 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { vi } from "vitest"; @@ -50,7 +50,10 @@ export function makeHarness(): TestHarness { projectRoot, ctx, emitted, - close: () => db.close(), + close: () => { + db.close(); + rmSync(projectRoot, { recursive: true, force: true }); + }, }; } diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts new file mode 100644 index 0000000000..9a0d77544d --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InteractiveAiSession } from "@fusion/core"; +import { CE_EVENTS, CeOrchestrator } from "../session/orchestrator.js"; +import { getCeSessionStore, type CeActivityTurn, type CeSessionStatus } from "../session/session-store.js"; +import { makeHarness, type TestHarness } from "./_harness.js"; + +interface OrchestratorInternals { + live: Map<string, InteractiveAiSession>; + activity: Map<string, CeActivityTurn[]>; +} + +function internals(orch: CeOrchestrator): OrchestratorInternals { + return orch as unknown as OrchestratorInternals; +} + +function liveHandle(): InteractiveAiSession { + return { + prompt: vi.fn(), + answer: vi.fn(), + nextEvent: vi.fn(), + dispose: vi.fn(), + }; +} + +describe("CeOrchestrator.cancel", () => { + let h: TestHarness; + + afterEach(() => { + h?.close(); + }); + + it("interrupts an in-flight session with a live handle, flushes progress, disposes, and emits", () => { + h = makeHarness(); + const store = getCeSessionStore(h.ctx); + const orch = new CeOrchestrator({ ctx: h.ctx }); + const session = store.update(store.create({ stage: "brainstorm" }).id, { status: "active" })!; + const handle = liveHandle(); + internals(orch).live.set(session.id, handle); + internals(orch).activity.set(session.id, [ + { kind: "thinking", text: "drafting cancellable progress", at: new Date().toISOString() }, + ]); + + const cancelled = orch.cancel(session.id)!; + + expect(cancelled.status).toBe("interrupted"); + expect(cancelled.error).toBe("Cancelled by user"); + expect(handle.dispose).toHaveBeenCalledTimes(1); + expect(orch.getLiveActivity(session.id)).toEqual([]); + expect(cancelled.conversationHistory.some((t) => t.text.includes("drafting cancellable progress"))).toBe(true); + expect(h.emitted).toContainEqual({ + event: CE_EVENTS.interrupted, + data: { sessionId: session.id, message: "Cancelled by user" }, + }); + }); + + it.each<CeSessionStatus>(["launching", "active", "awaiting_input"])( + "interrupts %s without requiring a live handle", + (status) => { + h = makeHarness(); + const store = getCeSessionStore(h.ctx); + const orch = new CeOrchestrator({ ctx: h.ctx }); + const session = store.update(store.create({ stage: "brainstorm" }).id, { status })!; + + const cancelled = orch.cancel(session.id)!; + + expect(cancelled.status).toBe("interrupted"); + expect(cancelled.error).toBe("Cancelled by user"); + expect(h.emitted.map((e) => e.event)).toEqual([CE_EVENTS.interrupted]); + }, + ); + + it.each<CeSessionStatus>(["completed", "error", "interrupted"])( + "is idempotent for terminal status %s", + (status) => { + h = makeHarness(); + const store = getCeSessionStore(h.ctx); + const orch = new CeOrchestrator({ ctx: h.ctx }); + const session = store.update(store.create({ stage: "brainstorm" }).id, { + status, + error: status === "completed" ? null : "already settled", + })!; + const handle = liveHandle(); + internals(orch).live.set(session.id, handle); + + const cancelled = orch.cancel(session.id)!; + + expect(cancelled).toEqual(session); + expect(handle.dispose).not.toHaveBeenCalled(); + expect(h.emitted).toEqual([]); + expect(store.get(session.id)!.status).toBe(status); + }, + ); + + it("returns undefined for an unknown session", () => { + h = makeHarness(); + const orch = new CeOrchestrator({ ctx: h.ctx }); + + expect(orch.cancel("missing")).toBeUndefined(); + expect(h.emitted).toEqual([]); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts index c1d9edafa4..3ec210ad9d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts @@ -3,7 +3,7 @@ import type { InteractiveAiSession, InteractiveAiSessionEvent, PlanningQuestion import { vi } from "vitest"; import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; import { CeSessionStore, getCeSessionStore } from "../session/session-store.js"; -import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.js"; /** * CHARACTERIZATION TEST — written first (U5 execution note: cover the @@ -122,6 +122,160 @@ describe("interrupt + resume (no silent loss)", () => { expect(resumed.session.conversationHistory).toHaveLength(2); }); + it("answer() rehydrates an old awaiting_input session with no live handle and drives the answer to completion", async () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + + const rehydrated = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Done\n" } }, + ]); + const factory = scriptedFactory(rehydrated); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const done = await orch.answer(created.id, "q1", "a"); + expect(done.event?.type).toBe("complete"); + expect(done.session.status).toBe("completed"); + expect(factory).toHaveBeenCalledTimes(1); + expect(rehydrated.prompt).toHaveBeenCalledTimes(1); + expect(rehydrated.answer).toHaveBeenCalledTimes(1); + const hasAnswerTurn = done.session.conversationHistory.some( + (t) => t.text === JSON.stringify({ answer: "a", questionId: "q1" }), + ); + expect(hasAnswerTurn).toBe(true); + }); + + it("answer() uses an existing live handle directly without rehydrating", async () => { + const live = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Done\n" } }, + ]); + const factory = scriptedFactory(live); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const started = await orch.start("brainstorm", { openingMessage: "kick off" }); + expect(started.session.status).toBe("awaiting_input"); + expect(factory).toHaveBeenCalledTimes(1); + + const done = await orch.answer(started.session.id, "q1", "a"); + expect(done.session.status).toBe("completed"); + expect(factory).toHaveBeenCalledTimes(1); + expect(live.prompt).toHaveBeenCalledTimes(1); + expect(live.answer).toHaveBeenCalledTimes(1); + }); + + it("answer() without a live handle and without a factory reports an honest error without corrupting the question", async () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + const orch = new CeOrchestrator({ ctx: h.ctx, projectRoot: h.projectRoot, turnTimeoutMs: 5000 }); + + await expect(orch.answer(created.id, "q1", "a")).rejects.toThrow(/cannot be continued in this process/i); + const after = store.get(created.id)!; + expect(after.status).toBe("awaiting_input"); + expect(after.currentQuestion?.id).toBe("q1"); + expect(after.conversationHistory.some((t) => t.text.includes('"answer"'))).toBe(false); + }); + + it("answer() rejects a stale questionId before rehydration and leaves state untouched", async () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + const factory = scriptedFactory(makeScriptedSession([{ type: "question", data: QUESTION }])); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + await expect(orch.answer(created.id, "stale-q", "a")).rejects.toThrow(/q1|stale-q/); + expect(factory).not.toHaveBeenCalled(); + const after = store.get(created.id)!; + expect(after.status).toBe("awaiting_input"); + expect(after.currentQuestion?.id).toBe("q1"); + expect(after.conversationHistory.some((t) => t.text.includes("stale-q"))).toBe(false); + }); + + it("answer() preserves the existing not-awaiting guard before rehydration", async () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.update(created.id, { status: "active", currentQuestion: QUESTION }); + const factory = scriptedFactory(makeScriptedSession([{ type: "question", data: QUESTION }])); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + await expect(orch.answer(created.id, "q1", "a")).rejects.toThrow(/not awaiting input/); + expect(factory).not.toHaveBeenCalled(); + expect(store.get(created.id)!.status).toBe("active"); + }); + + it("detached answer() rehydrates an old awaiting_input session in the background", async () => { + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + const rehydrated = makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Done\n" } }, + ]); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: scriptedFactory(rehydrated), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const returned = await orch.answer(created.id, "q1", "a", { detach: true }); + expect(returned.session.status).toBe("active"); + + await new Promise((resolve) => setImmediate(resolve)); + const after = store.get(created.id)!; + expect(after.status).toBe("completed"); + const hasAnswerTurn = after.conversationHistory.some( + (t) => t.text === JSON.stringify({ answer: "a", questionId: "q1" }), + ); + expect(hasAnswerTurn).toBe(true); + }); + it("Bug 5: an interrupted/awaiting session with a currentQuestion + history can be resumed (rehydrated) and then ANSWERED to continue to completion", async () => { // Simulate the post-interrupt / post-restart state: a session persisted // mid-question (awaiting_input, currentQuestion set, full history) whose live diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts index ca064e135f..f76ea7b8d8 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginContext, PluginRouteResponse } from "@fusion/core"; +import type { PlanningQuestion, PluginContext, PluginRouteResponse } from "@fusion/core"; import { createSessionRoutes } from "../routes/session-routes.js"; -import { makeHarness, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.js"; /** * Routes-level smoke test for the POLLING transport. Exercises validation and @@ -11,6 +11,16 @@ import { makeHarness, type TestHarness } from "./_harness.js"; * which is the correct, non-hanging behavior. */ +const QUESTION: PlanningQuestion = { + id: "q1", + type: "single_select", + question: "Which direction?", + options: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ], +}; + let h: TestHarness; beforeEach(() => { h = makeHarness(); @@ -38,6 +48,7 @@ describe("session routes (polling transport)", () => { "POST /sessions", "POST /sessions/:id/answer", "POST /sessions/:id/resume", + "POST /sessions/:id/cancel", "GET /sessions/:id", "GET /sessions", "DELETE /sessions/:id", @@ -60,6 +71,40 @@ describe("session routes (polling transport)", () => { expect(store.get(keep.id)).toBeDefined(); }); + it("POST /sessions/:id/cancel interrupts an in-flight session", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const created = store.update(store.create({ stage: "brainstorm" }).id, { status: "active" })!; + + const res = await call("POST", "/sessions/:id/cancel", { params: { id: created.id } }, h.ctx); + + expect(res.status).toBe(200); + const session = (res.body as { session: { status: string; error: string | null } }).session; + expect(session.status).toBe("interrupted"); + expect(session.error).toBe("Cancelled by user"); + }); + + it("POST /sessions/:id/cancel returns 404 for an unknown session", async () => { + const res = await call("POST", "/sessions/:id/cancel", { params: { id: "nope" } }, h.ctx); + + expect(res.status).toBe(404); + expect((res.body as { error: string }).error).toMatch(/not found/i); + }); + + it("POST /sessions/:id/cancel is idempotent for terminal sessions", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const created = store.update(store.create({ stage: "brainstorm" }).id, { status: "completed" })!; + + const res = await call("POST", "/sessions/:id/cancel", { params: { id: created.id } }, h.ctx); + + expect(res.status).toBe(200); + const session = (res.body as { session: { status: string; error: string | null } }).session; + expect(session.status).toBe("completed"); + expect(session.error).toBeNull(); + expect(store.get(created.id)!.status).toBe("completed"); + }); + it("GET /sessions lists every session so a client can manage multiple concurrently", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); @@ -72,6 +117,53 @@ describe("session routes (polling transport)", () => { expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]); }); + it("GET /sessions recovers stale active rows that have no live route handle", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const zombie = store.create({ stage: "strategy", turnIntervalMs: 1 }); + store.update(zombie.id, { + status: "active", + currentQuestion: null, + lastActivityAt: Date.now() - 10_000, + }); + + const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); + + expect(res.status).toBe(200); + const sessions = (res.body as { sessions: Array<{ id: string; status: string; error: string | null }> }).sessions; + expect(sessions.find((s) => s.id === zombie.id)).toMatchObject({ + status: "interrupted", + error: "Session interrupted — progress preserved, resume to continue", + }); + expect(store.get(zombie.id)).toMatchObject({ + status: "interrupted", + error: "Session interrupted — progress preserved, resume to continue", + }); + }); + + it("GET /sessions/:id recovers a stale active row before returning it", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const zombie = store.create({ stage: "strategy", turnIntervalMs: 1 }); + store.update(zombie.id, { + status: "active", + currentQuestion: null, + lastActivityAt: Date.now() - 10_000, + }); + + const res = await call("GET", "/sessions/:id", { params: { id: zombie.id } }, h.ctx); + + expect(res.status).toBe(200); + expect((res.body as { session: { status: string; error: string | null } }).session).toMatchObject({ + status: "interrupted", + error: "Session interrupted — progress preserved, resume to continue", + }); + expect(store.get(zombie.id)).toMatchObject({ + status: "interrupted", + error: "Session interrupted — progress preserved, resume to continue", + }); + }); + it("POST /sessions requires a stage", async () => { const res = await call("POST", "/sessions", { body: {} }, h.ctx); expect(res.status).toBe(400); @@ -99,4 +191,62 @@ describe("session routes (polling transport)", () => { const res = await call("POST", "/sessions/:id/answer", { params: { id: "x" }, body: {} }, h.ctx); expect(res.status).toBe(400); }); + + it("POST /sessions/:id/answer rehydrates an old awaiting_input session instead of returning call-resume-first", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm" }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + + h.ctx.createInteractiveAiSession = scriptedFactory( + makeScriptedSession([ + { type: "question", data: QUESTION }, + { type: "complete", data: { artifact: "# Done\n" } }, + ]), + ); + + const res = await call( + "POST", + "/sessions/:id/answer", + { params: { id: created.id }, body: { questionId: "q1", response: "a" } }, + h.ctx, + ); + expect(res.status).toBe(200); + expect((res.body as { session: { status: string } }).session.status).toBe("active"); + + await new Promise((resolve) => setImmediate(resolve)); + expect(store.get(created.id)!.status).toBe("completed"); + }); + + it("POST /sessions/:id/answer returns an honest no-factory error without corrupting an old awaiting_input session", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const created = store.create({ stage: "brainstorm" }); + store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + store.appendHistory(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + + const res = await call( + "POST", + "/sessions/:id/answer", + { params: { id: created.id }, body: { questionId: "q1", response: "a" } }, + h.ctx, + ); + expect(res.status).toBe(409); + expect((res.body as { error: string }).error).toMatch(/cannot be continued in this process/i); + expect((res.body as { error: string }).error).not.toMatch(/call resume\(\) first/i); + const after = store.get(created.id)!; + expect(after.status).toBe("awaiting_input"); + expect(after.currentQuestion?.id).toBe("q1"); + }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/setup-invariant.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/setup-invariant.test.ts new file mode 100644 index 0000000000..cc0f84d386 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/setup-invariant.test.ts @@ -0,0 +1,43 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve, sep } from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeHarness } from "./_harness.js"; + +function workerRoot(): string { + const root = process.env.FUSION_TEST_WORKER_ROOT; + if (!root) throw new Error("FUSION_TEST_WORKER_ROOT is not set"); + return resolve(root); +} + +describe("compound-engineering setup invariants", () => { + it("uses a per-run worker temp root for redirected temp fixtures", () => { + const root = workerRoot(); + + // Regression guard for FN-6282: this must not be the old static + // tmpdir()/fusion-test-workers directory whose one-level redirect sweep made + // setup proportional to stale directories from prior interrupted runs. + expect(basename(root)).toMatch(/^fusion-test-workers-/); + expect(root).not.toBe(resolve(tmpdir(), "fusion-test-workers")); + + const tempFixture = mkdtempSync(join(tmpdir(), "ce-setup-guard-")); + try { + expect(resolve(tempFixture).startsWith(root + sep)).toBe(true); + } finally { + rmSync(tempFixture, { recursive: true, force: true }); + } + }); + + it("closes the CE harness and removes its redirected project root", () => { + const root = workerRoot(); + const harness = makeHarness(); + const projectRoot = resolve(harness.projectRoot); + + expect(projectRoot.startsWith(root + sep)).toBe(true); + expect(existsSync(projectRoot)).toBe(true); + + harness.close(); + + expect(existsSync(projectRoot)).toBe(false); + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx index 63af5f95b6..22ee17f45e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Trash2 } from "lucide-react"; import type { PlanningQuestion } from "@fusion/core"; import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; import { canRenderRichly } from "./ce-question-support.js"; @@ -33,6 +34,8 @@ export interface CeFlowProps { onAnswer: (questionId: string, response: unknown) => void; /** Resume an interrupted/error session. */ onResume?: () => void; + /** Cancel an in-flight session while preserving it as interrupted. */ + onCancel?: () => void; /** Back to the launcher. */ onClose?: () => void; } @@ -506,7 +509,7 @@ function QuestionPanel({ // ── Flow surface ───────────────────────────────────────────────────────────── export function CeFlow(props: CeFlowProps) { - const { session, busy, error, onAnswer, onResume, onClose } = props; + const { session, busy, error, onAnswer, onResume, onCancel, onClose } = props; const question = session?.currentQuestion ?? undefined; @@ -526,6 +529,7 @@ export function CeFlow(props: CeFlowProps) { const status = session.status; const settledTerminal = status === "completed"; const recoverable = status === "interrupted" || status === "error"; + const cancellable = status === "launching" || status === "active" || status === "awaiting_input"; const working = status === "active" || status === "launching"; return ( @@ -535,6 +539,19 @@ export function CeFlow(props: CeFlowProps) { <span className="ce-flow-status" data-testid="ce-flow-status"> {status.replace("_", " ")} </span> + {onCancel && cancellable ? ( + <button + type="button" + className="btn-icon ce-flow-cancel" + data-testid="ce-flow-cancel" + onClick={onCancel} + disabled={Boolean(busy)} + aria-label="Cancel session" + title="Cancel session" + > + <Trash2 size={16} aria-hidden="true" /> + </button> + ) : null} {onClose ? ( <button type="button" className="btn ce-flow-close" onClick={onClose}> Close diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css index 1435b7f3eb..f73a056c28 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css @@ -177,6 +177,22 @@ width: 100%; padding: var(--space-sm); } + + .ce-session-row { + align-items: center; + flex-direction: row; + } + + .ce-session-open { + flex-wrap: wrap; + } + + .ce-session-cancel, + .ce-session-discard, + .ce-flow-cancel, + .ce-flow-close { + margin-left: 0; + } } /* --- Stage launcher (U6) --- */ @@ -228,9 +244,13 @@ color: var(--text-muted); text-transform: capitalize; } +.ce-flow-cancel, .ce-flow-close { margin-left: auto; } +.ce-flow-cancel + .ce-flow-close { + margin-left: 0; +} .ce-flow-transcript { list-style: none; margin: 0; @@ -277,10 +297,29 @@ flex-direction: column; gap: 0.4rem; } +.ce-flow-text textarea, +.ce-flow-guidance-row textarea { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-primary); + outline: none; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} .ce-flow-text textarea { width: 100%; resize: vertical; } +.ce-flow-text textarea:focus, +.ce-flow-guidance-row textarea:focus { + border-color: var(--todo); + box-shadow: var(--focus-ring); +} +.ce-flow-text textarea::placeholder, +.ce-flow-guidance-row textarea::placeholder { + color: var(--text-dim); +} .ce-flow-confirm { display: flex; gap: 0.5rem; @@ -364,7 +403,8 @@ background: color-mix(in srgb, var(--todo) 8%, transparent); } .ce-session-open { - flex: 1; + flex: 1 1 auto; + min-width: 0; display: flex; align-items: baseline; gap: 0.6rem; @@ -406,6 +446,45 @@ font-size: 0.72rem; color: var(--text-dim); } +.ce-session-cancel, +.ce-session-discard { + flex: none; +} +.ce-session-cancel, +.ce-flow-cancel { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(var(--space-xl) + var(--space-sm)); + height: calc(var(--space-xl) + var(--space-sm)); + padding: var(--space-xs); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-error); + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast), + box-shadow var(--transition-fast), + opacity var(--transition-fast); +} +.ce-session-cancel:hover:not(:disabled), +.ce-flow-cancel:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-error) 10%, transparent); + color: var(--color-error); +} +.ce-session-cancel:focus-visible, +.ce-flow-cancel:focus-visible { + box-shadow: var(--focus-ring); + outline: none; +} +.ce-session-cancel:disabled, +.ce-flow-cancel:disabled { + cursor: default; + opacity: 0.6; +} /* ── Q&A transcript bubbles ────────────────────────────────────────────── */ .ce-flow-transcript { diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx index 49506a298a..785461481e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx @@ -74,12 +74,14 @@ function SessionsPanel({ activeSessionId, disabled, onOpen, + onCancel, onDiscard, }: { sessions: CeSession[]; activeSessionId?: string; disabled: boolean; onOpen: (session: CeSession) => void; + onCancel: (session: CeSession) => void; onDiscard: (session: CeSession) => void; }) { if (sessions.length === 0) return null; @@ -124,7 +126,19 @@ function SessionsPanel({ > Discard </button> - ) : null} + ) : ( + <button + type="button" + className="btn-icon ce-session-cancel" + data-testid="ce-session-cancel" + disabled={disabled} + onClick={() => onCancel(s)} + aria-label="Cancel session" + title="Cancel session" + > + <LucideIcons.Trash2 size={16} aria-hidden="true" /> + </button> + )} </li> ); })} @@ -282,6 +296,7 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) { ...(subscribeList ? { subscribe: subscribeList } : {}), }); const [launcherOpen, setLauncherOpen] = useState(false); + const [sessionActionBusy, setSessionActionBusy] = useState(false); const totalArtifacts = result?.totalArtifacts ?? 0; const totalErrors = result?.totalErrors ?? 0; @@ -310,9 +325,23 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) { [ceSession, projectId], ); + const onCancelSession = useCallback( + (s: CeSession) => { + setSessionActionBusy(true); + void ceSessions + .cancel(s.id) + .then(() => { + if (ceSession.session?.id === s.id) ceSession.reset(); + }) + .finally(() => setSessionActionBusy(false)); + }, + [ceSession, ceSessions], + ); + const onDiscardSession = useCallback( (s: CeSession) => { - void ceSessions.remove(s.id); + setSessionActionBusy(true); + void ceSessions.remove(s.id).finally(() => setSessionActionBusy(false)); }, [ceSessions], ); @@ -336,16 +365,18 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) { <SessionsPanel sessions={ceSessions.sessions} activeSessionId={ceSession.session.id} - disabled={ceSession.busy} + disabled={ceSession.busy || sessionActionBusy} onOpen={onOpenSession} + onCancel={onCancelSession} onDiscard={onDiscardSession} /> <CeFlow session={ceSession.session} - busy={ceSession.busy} + busy={ceSession.busy || sessionActionBusy} error={ceSession.error} onAnswer={ceSession.answer} onResume={ceSession.resume} + onCancel={() => onCancelSession(ceSession.session!)} onClose={onCloseFlow} /> </div> @@ -376,8 +407,9 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) { <SessionsPanel sessions={ceSessions.sessions} - disabled={ceSession.busy} + disabled={ceSession.busy || sessionActionBusy} onOpen={onOpenSession} + onCancel={onCancelSession} onDiscard={onDiscardSession} /> diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx index ede600ce43..c49d3db94f 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx @@ -419,6 +419,32 @@ describe("CeFlow — lifecycle surfaces", () => { expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Grep"); }); + it.each(["launching", "active", "awaiting_input"] as const)("offers cancel on a %s session", (status) => { + const onCancel = vi.fn(); + render(<CeFlow session={makeSession({ status, currentQuestion: null })} onAnswer={vi.fn()} onCancel={onCancel} />); + + const cancelButton = screen.getByTestId("ce-flow-cancel"); + expect(cancelButton).toHaveAccessibleName("Cancel session"); + expect(cancelButton).toHaveAttribute("title", "Cancel session"); + expect(screen.getByRole("button", { name: "Cancel session" })).toBe(cancelButton); + expect(cancelButton).not.toHaveTextContent(/cancel/i); + + fireEvent.click(cancelButton); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it.each(["completed", "error", "interrupted"] as const)("hides cancel on a terminal %s session", (status) => { + render(<CeFlow session={makeSession({ status, currentQuestion: null })} onAnswer={vi.fn()} onCancel={vi.fn()} />); + + expect(screen.queryByTestId("ce-flow-cancel")).not.toBeInTheDocument(); + }); + + it("disables cancel while busy", () => { + render(<CeFlow session={makeSession({ status: "active", currentQuestion: null })} busy onAnswer={vi.fn()} onCancel={vi.fn()} />); + + expect(screen.getByTestId("ce-flow-cancel")).toBeDisabled(); + }); + it("offers resume on an interrupted session", () => { const onResume = vi.fn(); render( diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx index 0adffa55b9..015e8bfff6 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CompoundEngineeringView.test.tsx @@ -8,6 +8,9 @@ const listArtifacts = vi.fn(async (): Promise<DiscoveryResult> => { }); const listSessions = vi.fn(async (): Promise<CeSession[]> => []); const deleteSession = vi.fn(async (_id: string, _projectId?: string): Promise<void> => undefined); +const cancelSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => { + throw new Error("cancelSession mock not configured"); +}); const getSession = vi.fn(async (_id: string, _projectId?: string): Promise<CeSession> => { throw new Error("getSession mock not configured"); }); @@ -16,6 +19,7 @@ vi.mock("../hooks/api.js", () => ({ getArtifactPreviewUrl: (id: string) => `/preview/${id}`, listSessions: () => listSessions(), deleteSession: (id: string, projectId?: string) => deleteSession(id, projectId), + cancelSession: (id: string, projectId?: string) => cancelSession(id, projectId), getSession: (id: string, projectId?: string) => getSession(id, projectId), startSession: vi.fn(), answerSession: vi.fn(), @@ -79,6 +83,8 @@ describe("CompoundEngineeringView", () => { listSessions.mockResolvedValue([]); deleteSession.mockReset(); deleteSession.mockResolvedValue(undefined); + cancelSession.mockReset(); + cancelSession.mockImplementation(async (id: string, projectId?: string) => mkCeSession({ id, projectId: projectId ?? null, status: "interrupted", error: "Cancelled by user" })); getSession.mockReset(); }); @@ -193,8 +199,28 @@ describe("CompoundEngineeringView", () => { ]); // Awaiting sessions advertise that they need the user. expect(rows[0].textContent).toMatch(/needs your input/i); - // Only the terminal session can be discarded. + // Only non-terminal sessions can be cancelled; only terminal sessions can be discarded. + const cancelButtons = screen.getAllByTestId("ce-session-cancel"); + expect(cancelButtons).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Cancel session" })).toHaveLength(2); + for (const cancelButton of cancelButtons) { + expect(cancelButton).toHaveAccessibleName("Cancel session"); + expect(cancelButton).toHaveAttribute("title", "Cancel session"); + expect(cancelButton).not.toHaveTextContent(/cancel/i); + } expect(screen.getAllByTestId("ce-session-discard")).toHaveLength(1); + expect(rows[0].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument(); + expect(rows[1].querySelector("[data-testid='ce-session-cancel']")).toBeInTheDocument(); + expect(rows[2].querySelector("[data-testid='ce-session-cancel']")).not.toBeInTheDocument(); + }); + + it("renders no cancel affordance for an empty sessions list", async () => { + listArtifacts.mockResolvedValue(makeResult({})); + listSessions.mockResolvedValue([]); + render(<CompoundEngineeringView projectId="p1" enabledOverride />); + + await screen.findByTestId("ce-empty-state"); + expect(screen.queryByTestId("ce-session-cancel")).not.toBeInTheDocument(); }); it("opens an existing session from the list into the flow (and back without losing it)", async () => { @@ -233,6 +259,38 @@ describe("CompoundEngineeringView", () => { expect(deleteSession).not.toHaveBeenCalled(); }); + it("cancels an in-flight session via the list", async () => { + listArtifacts.mockResolvedValue(makeResult({})); + listSessions.mockResolvedValue([mkCeSession({ id: "running", stage: "plan", status: "active" })]); + render(<CompoundEngineeringView projectId="p1" enabledOverride />); + + await screen.findByTestId("ce-sessions"); + listSessions.mockResolvedValue([mkCeSession({ id: "running", stage: "plan", status: "interrupted", error: "Cancelled by user" })]); + fireEvent.click(screen.getByTestId("ce-session-cancel")); + + await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("running", "p1")); + await waitFor(() => expect(screen.queryByTestId("ce-session-cancel")).not.toBeInTheDocument()); + expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument(); + }); + + it("cancels an open flow and returns to the refreshed sessions overview", async () => { + listArtifacts.mockResolvedValue(makeResult({})); + listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "active" })]); + getSession.mockResolvedValue(mkCeSession({ id: "flow", stage: "plan", status: "active" })); + render(<CompoundEngineeringView projectId="p1" enabledOverride />); + + await screen.findByTestId("ce-sessions"); + fireEvent.click(screen.getByTestId("ce-session-open")); + await screen.findByTestId("ce-flow"); + listSessions.mockResolvedValue([mkCeSession({ id: "flow", stage: "plan", status: "interrupted", error: "Cancelled by user" })]); + fireEvent.click(screen.getByTestId("ce-flow-cancel")); + + await waitFor(() => expect(cancelSession).toHaveBeenCalledWith("flow", "p1")); + await waitFor(() => expect(screen.queryByTestId("ce-flow")).not.toBeInTheDocument()); + expect(screen.getByTestId("ce-sessions")).toBeInTheDocument(); + expect(screen.getByTestId("ce-session-discard")).toBeInTheDocument(); + }); + it("discards a terminal session via the list", async () => { listArtifacts.mockResolvedValue(makeResult({})); listSessions.mockResolvedValue([mkCeSession({ id: "done", stage: "plan", status: "completed" })]); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts index 257b1eeffe..1e8c371424 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts @@ -13,6 +13,31 @@ function selectorBlocks(selector: string): string[] { return css.match(pattern) ?? []; } +function selectorGroupBlocks(selector: string): string[] { + const blocks: string[] = []; + const rulePattern = /([^{}]+)\{([^}]*)\}/g; + for (const match of css.matchAll(rulePattern)) { + const selectors = match[1] + .split(",") + .map((candidate) => candidate.trim()) + .filter(Boolean); + if (selectors.includes(selector)) { + blocks.push(match[0]); + } + } + return blocks; +} + +function expectTextareaThemeTokens(selector: string, surfaceName: string) { + const blocks = selectorGroupBlocks(selector); + expect(blocks, `expected themed textarea block for ${surfaceName} (${selector})`).not.toHaveLength(0); + const block = blocks.join("\n"); + expect(block, `expected ${surfaceName} to set theme surface background`).toMatch(/background:\s*var\(--surface\)\s*;/); + expect(block, `expected ${surfaceName} to set theme text color`).toMatch(/color:\s*var\(--text\)\s*;/); + expect(block, `expected ${surfaceName} to set theme border`).toMatch(/border:\s*1px\s+solid\s+var\(--border\)\s*;/); + expect(block, `expected ${surfaceName} not to rely on a transparent background`).not.toMatch(/background:\s*transparent\s*;/); +} + describe("CompoundEngineeringView theme tokens", () => { it("does not use hardcoded legacy color fallbacks", () => { const forbiddenPatterns = [ @@ -63,6 +88,52 @@ describe("CompoundEngineeringView theme tokens", () => { expect(viewBlock).toMatch(/color:\s*var\(--text\)\s*;/); }); + it("themes every CE free-text textarea with dashboard input tokens", () => { + const textareaSurfaces = [ + { + name: 'standard question/answer textarea (data-testid="ce-flow-text-input")', + selector: ".ce-flow-text textarea", + }, + { + name: 'degraded chat fallback textarea (data-testid="ce-flow-degraded-input")', + selector: ".ce-flow-text textarea", + }, + { + name: 'guidance textarea (data-testid="ce-flow-guidance-input")', + selector: ".ce-flow-guidance-row textarea", + }, + ]; + + for (const surface of textareaSurfaces) { + expectTextareaThemeTokens(surface.selector, surface.name); + } + }); + + it("themes CE textarea focus and placeholder states", () => { + const textFocusBlocks = selectorGroupBlocks(".ce-flow-text textarea:focus"); + const guidanceFocusBlocks = selectorGroupBlocks(".ce-flow-guidance-row textarea:focus"); + const textPlaceholderBlocks = selectorGroupBlocks(".ce-flow-text textarea::placeholder"); + const guidancePlaceholderBlocks = selectorGroupBlocks(".ce-flow-guidance-row textarea::placeholder"); + + for (const [selector, blocks] of [ + [".ce-flow-text textarea:focus", textFocusBlocks], + [".ce-flow-guidance-row textarea:focus", guidanceFocusBlocks], + ] as const) { + expect(blocks, `expected focus block for ${selector}`).not.toHaveLength(0); + const block = blocks.join("\n"); + expect(block, `expected ${selector} to use themed focus border`).toMatch(/border-color:\s*var\(--todo\)\s*;/); + expect(block, `expected ${selector} to use themed focus ring`).toMatch(/box-shadow:\s*var\(--focus-ring\)\s*;/); + } + + for (const [selector, blocks] of [ + [".ce-flow-text textarea::placeholder", textPlaceholderBlocks], + [".ce-flow-guidance-row textarea::placeholder", guidancePlaceholderBlocks], + ] as const) { + expect(blocks, `expected placeholder block for ${selector}`).not.toHaveLength(0); + expect(blocks.join("\n"), `expected ${selector} to use dim text token`).toMatch(/color:\s*var\(--text-dim\)\s*;/); + } + }); + it("does not use opacity to dim text selectors", () => { const textDimmingSelectors = [ ".ce-view-summary", diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSessions.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSessions.test.tsx index da0c740f85..ba26a55d1e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSessions.test.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/__tests__/useCeSessions.test.tsx @@ -41,6 +41,7 @@ function Harness({ <span data-testid="err">{s.error ?? ""}</span> <button onClick={() => void s.refresh()}>refresh</button> <button onClick={() => void s.remove("s1")}>remove</button> + <button onClick={() => void s.cancel("s1")}>cancel</button> </div> ); } @@ -52,7 +53,7 @@ describe("useCeSessions (multi-session list)", () => { it("lists all sessions on mount with the projectId", async () => { const list = vi.fn(async () => [mkSession({ id: "s1" }), mkSession({ id: "s2", stage: "plan" })]); - const transport: CeSessionsTransport = { list, remove: vi.fn() }; + const transport: CeSessionsTransport = { list, remove: vi.fn(), cancel: vi.fn() }; render(<Harness transport={transport} />); await act(async () => {}); @@ -68,6 +69,7 @@ describe("useCeSessions (multi-session list)", () => { remove: vi.fn(async () => { removed = true; }), + cancel: vi.fn(), }; render(<Harness transport={transport} />); await act(async () => {}); @@ -80,6 +82,43 @@ describe("useCeSessions (multi-session list)", () => { expect(screen.getByTestId("ids")).toHaveTextContent("s2"); }); + it("cancel() cancels via the transport then refreshes the list", async () => { + let cancelled = false; + const transport: CeSessionsTransport = { + list: vi.fn(async () => [mkSession({ id: "s1", status: cancelled ? "interrupted" : "active" })]), + remove: vi.fn(), + cancel: vi.fn(async () => { + cancelled = true; + }), + }; + render(<Harness transport={transport} />); + await act(async () => {}); + expect(screen.getByTestId("ids")).toHaveTextContent("s1"); + + await act(async () => { + screen.getByText("cancel").click(); + }); + expect(transport.cancel).toHaveBeenCalledWith("s1", "p1"); + expect(transport.list).toHaveBeenCalledTimes(2); + }); + + it("cancel() surfaces a transport error without crashing", async () => { + const transport: CeSessionsTransport = { + list: vi.fn(async () => [mkSession({ id: "s1", status: "active" })]), + remove: vi.fn(), + cancel: vi.fn(async () => { + throw new Error("cancel failed"); + }), + }; + render(<Harness transport={transport} />); + await act(async () => {}); + + await act(async () => { + screen.getByText("cancel").click(); + }); + expect(screen.getByTestId("err")).toHaveTextContent("cancel failed"); + }); + it("refreshes when a push event fires", async () => { let fire: (() => void) | undefined; const subscribe: CeSessionsSubscribe = (onAnyEvent) => { @@ -92,6 +131,7 @@ describe("useCeSessions (multi-session list)", () => { const transport: CeSessionsTransport = { list: vi.fn(async () => Array.from({ length: n }, (_, i) => mkSession({ id: `s${i + 1}` }))), remove: vi.fn(), + cancel: vi.fn(), }; render(<Harness transport={transport} subscribe={subscribe} />); await act(async () => {}); @@ -114,6 +154,7 @@ describe("useCeSessions (multi-session list)", () => { return [mkSession({ id: "s1", status: calls >= 3 ? "completed" : "active" })]; }), remove: vi.fn(), + cancel: vi.fn(), }; render(<Harness transport={transport} />); await act(async () => { @@ -140,6 +181,7 @@ describe("useCeSessions (multi-session list)", () => { throw new Error("kaput"); }), remove: vi.fn(), + cancel: vi.fn(), }; render(<Harness transport={transport} />); await act(async () => {}); diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts index b2a0b0433b..d0e931e097 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/api.ts @@ -92,6 +92,16 @@ export async function resumeSession(sessionId: string, projectId?: string): Prom return data.session; } +/** Cancel an in-flight session without deleting it. `projectId` must match start (see answerSession). */ +export async function cancelSession(sessionId: string, projectId?: string): Promise<CeSession> { + const data = await request<{ session: CeSession }>(`/sessions/${encodeURIComponent(sessionId)}/cancel`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectId }), + }); + return data.session; +} + /** List CE sessions, newest-activity first (optionally filtered by status/stage). */ export async function listSessions( opts: { projectId?: string; status?: string; stage?: string } = {}, diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSessions.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSessions.ts index 663b7a5f65..2797a0e807 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSessions.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/hooks/useCeSessions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { CeSession } from "../../session/session-store.js"; -import { deleteSession as deleteSessionApi, listSessions as listSessionsApi } from "./api.js"; +import { cancelSession as cancelSessionApi, deleteSession as deleteSessionApi, listSessions as listSessionsApi } from "./api.js"; /** * Injectable list transport so component tests can drive the session list @@ -9,11 +9,15 @@ import { deleteSession as deleteSessionApi, listSessions as listSessionsApi } fr export interface CeSessionsTransport { list(projectId?: string): Promise<CeSession[]>; remove(sessionId: string, projectId?: string): Promise<void>; + cancel(sessionId: string, projectId?: string): Promise<void>; } const defaultTransport: CeSessionsTransport = { list: (projectId) => listSessionsApi({ projectId }), remove: (id, projectId) => deleteSessionApi(id, projectId), + cancel: async (id, projectId) => { + await cancelSessionApi(id, projectId); + }, }; /** @@ -41,6 +45,8 @@ export interface UseCeSessionsResult { refresh(): Promise<void>; /** Discard a session and refresh the list. */ remove(sessionId: string): Promise<void>; + /** Cancel an in-flight session and refresh the list. */ + cancel(sessionId: string): Promise<void>; } /** Statuses with an agent turn in flight — the list keeps polling while any exist. */ @@ -123,5 +129,18 @@ export function useCeSessions(options: UseCeSessionsOptions = {}): UseCeSessions [transport, projectId, refresh], ); - return { sessions, loading, error, refresh, remove }; + const cancel = useCallback( + async (sessionId: string) => { + try { + await transport.cancel(sessionId, projectId); + } catch (err) { + if (mounted.current) setError(err instanceof Error ? err.message : String(err)); + return; + } + await refresh(); + }, + [transport, projectId, refresh], + ); + + return { sessions, loading, error, refresh, remove, cancel }; } diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 7aaf230632..e3cc1f2e13 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -4,6 +4,7 @@ import { installBundledCeSkills } from "./skill-installation.js"; import { ensureCeSchema } from "./schema.js"; import { createSessionRoutes } from "./routes/session-routes.js"; import { createArtifactRoutes } from "./routes/artifact-routes.js"; +import { recoverStaleSessionsForContext } from "./session/session-recovery.js"; import { getCePipelineStore } from "./sync/pipeline-store.js"; import { reconcileCePipelines } from "./sync/reconciler.js"; import { settingsSchema } from "./settings.js"; @@ -128,6 +129,8 @@ const plugin = definePlugin({ const message = error instanceof Error ? error.message : String(error); ctx.logger.error(`Compound Engineering skill install failed: ${message}`); } + + recoverStaleSessionsForContext(ctx, { reason: "load", force: true, emitEvent: true }); }, }, routes: [...createSessionRoutes(), ...createArtifactRoutes()], diff --git a/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts b/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts index 2c6e9d647d..072403ee8a 100644 --- a/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts +++ b/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts @@ -1,5 +1,6 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core"; import { CeOrchestrator } from "../session/orchestrator.js"; +import { recoverStaleSessionsForContext } from "../session/session-recovery.js"; import { asCeSessionStatus, getCeSessionStore } from "../session/session-store.js"; import { getCePipelineStore } from "../sync/pipeline-store.js"; import { asString } from "./route-helpers.js"; @@ -104,12 +105,24 @@ export function createSessionRoutes(): PluginRouteDefinition[] { } }, }, + { + method: "POST", + path: "/sessions/:id/cancel", + description: "Cancel an in-flight CE session (stops the agent, keeps the row as interrupted).", + handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => { + const id = (req as RouteRequest).params.id; + const session = getOrchestrator(ctx).cancel(id); + if (!session) return { status: 404, body: { error: `Session ${id} not found` } }; + return { status: 200, body: { session } }; + }, + }, { method: "GET", path: "/sessions/:id", description: "Get current session state, including in-flight working output (liveActivity).", handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => { const id = (req as RouteRequest).params.id; + recoverStaleSessionsForContext(ctx, { reason: "route" }); const session = getCeSessionStore(ctx).get(id); if (!session) return { status: 404, body: { error: `Session ${id} not found` } }; // Attach the orchestrator's transient mid-turn buffer so a polling @@ -126,6 +139,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] { path: "/sessions", description: "List CE sessions (optionally filtered by status/stage).", handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => { + recoverStaleSessionsForContext(ctx, { reason: "route" }); const query = (req as RouteRequest).query ?? {}; const status = asCeSessionStatus(typeof query.status === "string" ? query.status : undefined); const stage = typeof query.stage === "string" ? query.stage : undefined; diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index 05040c17ee..229423f3bb 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -65,6 +65,9 @@ const MAX_ACTIVITY_TURN_CHARS = 16000; const MAX_PERSISTED_ACTIVITY_TURNS = 50; const MAX_PERSISTED_ACTIVITY_TURN_CHARS = 4000; +const INTERACTIVE_AI_UNAVAILABLE_MESSAGE = + "Session cannot be continued in this process: interactive AI sessions are unavailable (no factory on this context). Resume from a route context with the engine loaded."; + /** * Observable event names emitted via `ctx.emitEvent`. The no-silent-loss * invariant requires that interrupt/error ALWAYS emit one of these AND persist @@ -445,22 +448,52 @@ export class CeOrchestrator { ); } const live = this.live.get(sessionId); - if (!live) { - throw new Error(`Session ${sessionId} has no live handle in this process; call resume() first.`); + if (!live && !this.factory) { + throw new Error(INTERACTIVE_AI_UNAVAILABLE_MESSAGE); } + + const turn = this.runAnswerTurn(session, questionId, response); + if (opts.detach) { + // If the process lost its live handle, rehydration can take time. Mirror + // resume(detach): mark the row active immediately while the background + // turn re-creates the handle and converges through persisted state. + if (!live) { + this.store.update(sessionId, { status: "active", error: null }); + } + // runAnswerTurn never rejects after the preflight guards above (failures + // persist into session state). + void turn; + return { session: this.requireSession(sessionId) }; + } + return turn; + } + + private async runAnswerTurn(session: CeSession, questionId: string, response: unknown): Promise<CeStepResult> { + const sessionId = session.id; + let live = this.live.get(sessionId); + if (!live) { + try { + await this.rehydrate(session); + live = this.live.get(sessionId); + if (!live) { + throw new Error(`Session ${sessionId} could not be rehydrated with a live handle.`); + } + } catch (err) { + const interrupted = this.interruptSession(sessionId, err); + return { + session: interrupted, + event: { type: "error", data: { message: interrupted.error ?? "interrupted", cause: err } }, + }; + } + } + this.store.appendHistory(sessionId, { role: "user", text: JSON.stringify({ answer: response, questionId }), at: new Date().toISOString(), }); this.store.update(sessionId, { status: "active", currentQuestion: null }); - const turn = this.runTurn(sessionId, () => live.answer(questionId, response), live); - if (opts.detach) { - // runTurn never rejects (all failures persist into session state). - void turn; - return { session: this.requireSession(sessionId) }; - } - return turn; + return this.runTurn(sessionId, () => live.answer(questionId, response), live); } /** @@ -512,8 +545,7 @@ export class CeOrchestrator { const next = this.store.update(sessionId, { status: "interrupted", - error: - "Session cannot be continued in this process: interactive AI sessions are unavailable (no factory on this context). Resume from a route context with the engine loaded.", + error: INTERACTIVE_AI_UNAVAILABLE_MESSAGE, }) ?? session; return { session: next }; } @@ -619,6 +651,26 @@ export class CeOrchestrator { return this.store.get(sessionId); } + /** + * Cancel a session: stop any live in-process handle but keep the persisted row + * for inspection/resume by marking it `interrupted`. Unlike discard(), cancel + * preserves the conversation and progress; discard stops the handle AND deletes + * the row. Terminal sessions are idempotent no-ops. + */ + cancel(sessionId: string): CeSession | undefined { + const session = this.store.get(sessionId); + if (!session) return undefined; + if (session.status === "completed" || session.status === "error" || session.status === "interrupted") { + return session; + } + + // Preserve no-silent-loss ordering: interruptSession flushes live activity + // before disposeLive clears the transient buffers (same as runTurn failure). + const interrupted = this.interruptSession(sessionId, new Error("Cancelled by user")); + this.disposeLive(sessionId); + return interrupted; + } + /** * Discard a session: dispose any live in-process handle (so an in-flight * agent doesn't keep running unobserved) and delete the persisted row. diff --git a/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts b/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts new file mode 100644 index 0000000000..63386bf1aa --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts @@ -0,0 +1,49 @@ +import type { PluginContext } from "@fusion/core"; +import { getCeSessionStore } from "./session-store.js"; + +const DEFAULT_RECOVERY_SCAN_TTL_MS = 120_000; + +const lastRecoveryScanAt = new WeakMap<object, number>(); + +interface RecoverStaleSessionsOptions { + reason: "load" | "route"; + force?: boolean; + emitEvent?: boolean; + now?: number; + ttlMs?: number; +} + +/** + * Best-effort stale-session recovery for persisted CE sessions that outlived + * their in-memory agent handle. Route callers use a TTL because the individual + * session endpoint is also the dashboard polling fallback. + */ +export function recoverStaleSessionsForContext( + ctx: PluginContext, + options: RecoverStaleSessionsOptions, +): string[] { + const key = ctx.taskStore as object; + const now = options.now ?? Date.now(); + const ttlMs = options.ttlMs ?? DEFAULT_RECOVERY_SCAN_TTL_MS; + if (!options.force) { + const last = lastRecoveryScanAt.get(key) ?? 0; + if (now - last < ttlMs) return []; + } + lastRecoveryScanAt.set(key, now); + + try { + const recovered = getCeSessionStore(ctx).recoverStaleSessions(now); + if (recovered.length > 0) { + ctx.logger.info(`Compound Engineering recovered stale session(s) during ${options.reason}: ${recovered.join(", ")}`); + if (options.emitEvent) { + ctx.emitEvent("compound-engineering:sessions-recovered", { sessionIds: recovered, reason: options.reason }); + } + } + return recovered; + } catch (err) { + ctx.logger.warn( + `Compound Engineering stale-session recovery skipped during ${options.reason}: ${err instanceof Error ? err.message : String(err)}`, + ); + return []; + } +} diff --git a/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md b/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md index 338ef6f068..4eeb6310ca 100644 --- a/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-cursor-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/cursor-runtime +## 0.1.23 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.1.22 ### Patch Changes diff --git a/plugins/fusion-plugin-cursor-runtime/package.json b/plugins/fusion-plugin-cursor-runtime/package.json index d2cf884a5b..6cf5c816ca 100644 --- a/plugins/fusion-plugin-cursor-runtime/package.json +++ b/plugins/fusion-plugin-cursor-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/cursor-runtime", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "Cursor CLI runtime plugin for Fusion", "keywords": [ diff --git a/plugins/fusion-plugin-dependency-graph/CHANGELOG.md b/plugins/fusion-plugin-dependency-graph/CHANGELOG.md index 4951a1720c..64b3721a39 100644 --- a/plugins/fusion-plugin-dependency-graph/CHANGELOG.md +++ b/plugins/fusion-plugin-dependency-graph/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/dependency-graph +## 0.1.35 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.34 ### Patch Changes diff --git a/plugins/fusion-plugin-dependency-graph/package.json b/plugins/fusion-plugin-dependency-graph/package.json index 7ccff916d7..d212e496d4 100644 --- a/plugins/fusion-plugin-dependency-graph/package.json +++ b/plugins/fusion-plugin-dependency-graph/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/dependency-graph", - "version": "0.1.34", + "version": "0.1.35", "type": "module", "description": "Dependency graph dashboard view plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-droid-runtime/CHANGELOG.md b/plugins/fusion-plugin-droid-runtime/CHANGELOG.md index 22afee3697..7ad152a2a1 100644 --- a/plugins/fusion-plugin-droid-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-droid-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.30 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.1.29 ### Patch Changes diff --git a/plugins/fusion-plugin-droid-runtime/package.json b/plugins/fusion-plugin-droid-runtime/package.json index b0291c05bc..dba779159e 100644 --- a/plugins/fusion-plugin-droid-runtime/package.json +++ b/plugins/fusion-plugin-droid-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/droid-runtime", - "version": "0.1.29", + "version": "0.1.30", "type": "module", "description": "Droid runtime plugin for Fusion", "keywords": [ diff --git a/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md b/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md index 56aec4436d..38333e2421 100644 --- a/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md +++ b/plugins/fusion-plugin-even-realities-glasses/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/even-realities-glasses +## 0.1.23 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.22 ### Patch Changes diff --git a/plugins/fusion-plugin-even-realities-glasses/package.json b/plugins/fusion-plugin-even-realities-glasses/package.json index 04a922a7d8..060d8b9ee4 100644 --- a/plugins/fusion-plugin-even-realities-glasses/package.json +++ b/plugins/fusion-plugin-even-realities-glasses/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/even-realities-glasses", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "Canonical Even Realities Fusion plugin with board/task cards, actions, notifications, and webhook transport", "keywords": [ diff --git a/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md b/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md index 6a886857bb..a2896b6337 100644 --- a/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-hermes-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/hermes-runtime +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/fusion-plugin-hermes-runtime/package.json b/plugins/fusion-plugin-hermes-runtime/package.json index 666a5ffac7..c85a3c28f7 100644 --- a/plugins/fusion-plugin-hermes-runtime/package.json +++ b/plugins/fusion-plugin-hermes-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/hermes-runtime", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Hermes AI runtime plugin for Fusion - provides AI agent execution runtime", "keywords": [ diff --git a/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md b/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md index b1dc39f3f3..235748fde0 100644 --- a/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-openclaw-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/openclaw-runtime +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/fusion-plugin-openclaw-runtime/package.json b/plugins/fusion-plugin-openclaw-runtime/package.json index 2f740a03f2..1d28a152e7 100644 --- a/plugins/fusion-plugin-openclaw-runtime/package.json +++ b/plugins/fusion-plugin-openclaw-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/openclaw-runtime", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Provides OpenClaw runtime for Fusion AI agents", "keywords": [ diff --git a/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md b/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md index 81a89ce3fb..c4110ad6d4 100644 --- a/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-paperclip-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/paperclip-runtime +## 0.2.54 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.2.53 ### Patch Changes diff --git a/plugins/fusion-plugin-paperclip-runtime/package.json b/plugins/fusion-plugin-paperclip-runtime/package.json index 6b8895fd6e..b4e2c3adaa 100644 --- a/plugins/fusion-plugin-paperclip-runtime/package.json +++ b/plugins/fusion-plugin-paperclip-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/paperclip-runtime", - "version": "0.2.53", + "version": "0.2.54", "type": "module", "description": "Paperclip runtime plugin for Fusion — provides AI agent web access capabilities", "keywords": [ diff --git a/plugins/fusion-plugin-reports/CHANGELOG.md b/plugins/fusion-plugin-reports/CHANGELOG.md index 4638d77ed1..e669008d49 100644 --- a/plugins/fusion-plugin-reports/CHANGELOG.md +++ b/plugins/fusion-plugin-reports/CHANGELOG.md @@ -1,5 +1,13 @@ # @fusion-plugin-examples/reports +## 0.1.23 + +### Patch Changes + +- @fusion/dashboard@0.42.0 +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.22 ### Patch Changes diff --git a/plugins/fusion-plugin-reports/package.json b/plugins/fusion-plugin-reports/package.json index aca6abf7ec..569f82e628 100644 --- a/plugins/fusion-plugin-reports/package.json +++ b/plugins/fusion-plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/reports", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "Reports plugin for Fusion", "private": true, diff --git a/plugins/fusion-plugin-roadmap/CHANGELOG.md b/plugins/fusion-plugin-roadmap/CHANGELOG.md index f6a31d2d6a..45bc549a95 100644 --- a/plugins/fusion-plugin-roadmap/CHANGELOG.md +++ b/plugins/fusion-plugin-roadmap/CHANGELOG.md @@ -1,5 +1,12 @@ # @fusion-plugin-examples/roadmap +## 0.1.23 + +### Patch Changes + +- @fusion/core@0.42.0 +- @fusion/plugin-sdk@0.42.0 + ## 0.1.22 ### Patch Changes diff --git a/plugins/fusion-plugin-roadmap/package.json b/plugins/fusion-plugin-roadmap/package.json index 10b339c477..5814c0e467 100644 --- a/plugins/fusion-plugin-roadmap/package.json +++ b/plugins/fusion-plugin-roadmap/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/roadmap", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "Roadmap plugin package for Fusion", "private": true, diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index c15313f134..48bd180997 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,10 +743,10 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 115 after init", () => { + it("schema version is 118 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(115); + expect(db.getSchemaVersion()).toBe(118); }); }); diff --git a/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md b/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md index 400698bb7e..e0ef23e8f9 100644 --- a/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md +++ b/plugins/fusion-plugin-whatsapp-chat/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/whatsapp-chat +## 0.1.23 + +### Patch Changes + +- @fusion/plugin-sdk@0.42.0 + ## 0.1.22 ### Patch Changes diff --git a/plugins/fusion-plugin-whatsapp-chat/package.json b/plugins/fusion-plugin-whatsapp-chat/package.json index fc2e992492..3053cd00bb 100644 --- a/plugins/fusion-plugin-whatsapp-chat/package.json +++ b/plugins/fusion-plugin-whatsapp-chat/package.json @@ -1,6 +1,6 @@ { "name": "@fusion-plugin-examples/whatsapp-chat", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "WhatsApp Web (Baileys) chat bridge for Fusion agents", "keywords": [ diff --git a/scripts/__tests__/agents-md-invariants.test.mjs b/scripts/__tests__/agents-md-invariants.test.mjs index 9068961bc2..98d7a0068a 100644 --- a/scripts/__tests__/agents-md-invariants.test.mjs +++ b/scripts/__tests__/agents-md-invariants.test.mjs @@ -10,8 +10,6 @@ const agentsPath = resolve(rootDir, "AGENTS.md"); const agents = readFileSync(agentsPath, "utf8"); const requiredAnchors = [ - "STANDING DIRECTIVE: Buttons Are Frozen", - "Buttons Are Frozen (2026-05-13)", "Port 4040", "pnpm release --yes", "@runfusion/fusion", diff --git a/scripts/__tests__/ci-test-shard.test.mjs b/scripts/__tests__/ci-test-shard.test.mjs index 734cb6b919..8bc4b6be50 100644 --- a/scripts/__tests__/ci-test-shard.test.mjs +++ b/scripts/__tests__/ci-test-shard.test.mjs @@ -554,6 +554,38 @@ test("U6: enumerateDashboardLanes reads lanes from a fixture package.json shape" assert.deepEqual(lanes, ["test:quality:app:a", "test:quality:app:b", "test:quality:api"]); }); +test("U6: enumerateDashboardLanes expands run-quality-tests delegators to package leaf lanes", () => { + const scripts = { + test: "node scripts/run-quality-tests.mjs", + "test:quality:app": "node scripts/run-quality-tests.mjs --group app", + "test:quality:app:a": "node scripts/run-vitest-with-heap.mjs run --project app-a", + "test:quality:app:b": "node scripts/run-vitest-with-heap.mjs run --project app-b --shard=1/2", + "test:quality:app:aggregate": "pnpm run test:quality:app:a && pnpm run test:quality:app:b", + "test:quality:api": "node scripts/run-quality-tests.mjs --group=api", + "test:quality:api:a": "node scripts/run-vitest-with-heap.mjs run --project api-a", + "test:quality:api:delegator": "node scripts/run-quality-tests.mjs --group api", + "test:quality:misc": "node scripts/run-vitest-with-heap.mjs run --project misc", + "test:deep": "vitest run --project deep", + }; + + assert.deepEqual(enumerateDashboardLanes(scripts, "test"), [ + "test:quality:app:a", + "test:quality:app:b", + "test:quality:api:a", + "test:quality:misc", + ]); + assert.deepEqual(enumerateDashboardLanes(scripts, "test:quality:app"), [ + "test:quality:app:a", + "test:quality:app:b", + ]); + assert.deepEqual(enumerateDashboardLanes(scripts, "test:quality:api"), ["test:quality:api:a"]); +}); + +test("U6: enumerateDashboardLanes preserves single-leaf fallback for non-delegating scripts", () => { + assert.deepEqual(enumerateDashboardLanes({ test: "node custom-runner.mjs" }, "test"), ["test"]); + assert.deepEqual(enumerateDashboardLanes({}, "test"), []); +}); + test("U6: laneProjectNames extracts --project targets including = and space forms", () => { assert.deepEqual(laneProjectNames("vitest run --project foo --project=bar baz"), ["foo", "bar"]); }); diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 1aa9ff284f..8cfeb977a1 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -29,6 +29,7 @@ import { __setCleanupRmSyncForTests, emitModeDecision, pruneFusionTestHomes, + pruneFusionTestWorkers, buildForwardDependencyMap, collectTransitiveDependencies, computeOwnHash, @@ -951,6 +952,164 @@ test("pruneFusionTestHomes: bounded — removes at most maxEntries per call", () } }); +test("pruneFusionTestWorkers: bounded — removes at most maxEntries per call", () => { + const created = []; + try { + for (let i = 0; i < 5; i++) { + const dir = path.join(tmpdir(), `fusion-test-workers-prune-budget-${process.pid}-${i}`); + mkdirSync(dir, { recursive: true }); + created.push(dir); + } + // Cap at 2 → at least 3 of ours survive this call. + pruneFusionTestWorkers(2); + const survivors = created.filter((dir) => existsSync(dir)); + assert.ok(survivors.length >= 3, `expected >=3 survivors with cap=2, got ${survivors.length}`); + } finally { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createNonEmptyPruneRoot(prefix, label) { + const root = mkdtempSync(path.join(tmpdir(), `${prefix}${label}-${process.pid}-`)); + const childDir = path.join(root, `w-${process.pid}-busy`); + mkdirSync(childDir, { recursive: true }); + writeFileSync(path.join(childDir, "busy.txt"), "busy\n"); + return root; +} + +function capturePruneWarnings(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (msg) => warnings.push(String(msg)); + try { + fn(warnings); + } finally { + console.warn = originalWarn; + } + return warnings; +} + +function withTransientPruneFailure(root, pruneFn) { + const error = Object.assign(new Error("simulated ENOTEMPTY"), { code: "ENOTEMPTY" }); + let calls = 0; + __setCleanupRmSyncForTests((target, options) => { + if (target === root) { + calls += 1; + if (calls === 1) throw error; + } + return rmSync(target, options); + }); + + try { + const warnings = capturePruneWarnings(() => pruneFn(64, { retries: 3, delayMs: 0 })); + assert.equal(existsSync(root), false); + assert.equal(calls, 2); + assert.deepEqual(warnings, []); + } finally { + __setCleanupRmSyncForTests(null); + rmSync(root, { recursive: true, force: true }); + } +} + +function withPersistentPruneFailure(root, pruneFn) { + const error = Object.assign(new Error("simulated EBUSY"), { code: "EBUSY" }); + let calls = 0; + __setCleanupRmSyncForTests((target, options) => { + if (target === root) { + calls += 1; + throw error; + } + return rmSync(target, options); + }); + + try { + const warnings = capturePruneWarnings(() => pruneFn(1024, { retries: 3, delayMs: 0 })); + assert.equal(existsSync(root), true); + assert.equal(calls, 3); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /failed to prune leftover/); + assert.match(warnings[0], /after 3 attempts/); + } finally { + __setCleanupRmSyncForTests(null); + rmSync(root, { recursive: true, force: true }); + } +} + +test("pruneFusionTestWorkers: skips active per-invocation worker roots", () => { + const root = createNonEmptyPruneRoot("fusion-test-workers-", "active"); + try { + writeFileSync(path.join(root, ".fusion-test-worker-root-owner"), `${process.pid}\n`); + pruneFusionTestWorkers(1024); + assert.equal(existsSync(root), true, "active worker root must not be pruned"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("pruneFusionTestWorkers: skips markerless roots with live redirect sinks", () => { + const root = mkdtempSync(path.join(tmpdir(), `fusion-test-workers-active-redir-${process.pid}-`)); + try { + mkdirSync(path.join(root, `redir-${process.pid}`), { recursive: true }); + writeFileSync(path.join(root, `redir-${process.pid}`, "payload.txt"), "active\n"); + pruneFusionTestWorkers(1024); + assert.equal(existsSync(root), true, "live redir-pid root must not be pruned"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("pruneFusionTestWorkers: reclaims non-empty root after transient ENOTEMPTY", () => { + const root = createNonEmptyPruneRoot("fusion-test-workers-", "transient"); + withTransientPruneFailure(root, pruneFusionTestWorkers); +}); + +test("pruneFusionTestWorkers: persistent busy root warns once after bounded retries", () => { + const root = createNonEmptyPruneRoot("fusion-test-workers-", "persistent"); + withPersistentPruneFailure(root, pruneFusionTestWorkers); +}); + +test("pruneFusionTestHomes: reclaims non-empty root after transient ENOTEMPTY", () => { + const root = createNonEmptyPruneRoot("fusion-test-home-root-", "transient"); + withTransientPruneFailure(root, pruneFusionTestHomes); +}); + +test("pruneFusionTestHomes: persistent busy root warns once after bounded retries", () => { + const root = createNonEmptyPruneRoot("fusion-test-home-root-", "persistent"); + withPersistentPruneFailure(root, pruneFusionTestHomes); +}); + +function withEnoentPruneSuccess(root, pruneFn) { + let calls = 0; + __setCleanupRmSyncForTests((target, options) => { + if (target === root) { + calls += 1; + rmSync(root, { recursive: true, force: true }); + throw Object.assign(new Error("simulated ENOENT"), { code: "ENOENT" }); + } + return rmSync(target, options); + }); + + try { + const warnings = capturePruneWarnings(() => pruneFn(1024, { retries: 3, delayMs: 0 })); + assert.equal(existsSync(root), false); + assert.equal(calls, 1); + assert.deepEqual(warnings, []); + } finally { + __setCleanupRmSyncForTests(null); + rmSync(root, { recursive: true, force: true }); + } +} + +test("pruneFusionTestWorkers: ENOENT during prune is success without warning", () => { + const root = createNonEmptyPruneRoot("fusion-test-workers-", "enoent"); + withEnoentPruneSuccess(root, pruneFusionTestWorkers); +}); + +test("pruneFusionTestHomes: ENOENT during prune is success without warning", () => { + const root = createNonEmptyPruneRoot("fusion-test-home-root-", "enoent"); + withEnoentPruneSuccess(root, pruneFusionTestHomes); +}); + // --------------------------------------------------------------------------- // U4: real-git-fixture integration (dirty working tree + transitive deps). // @@ -1357,3 +1516,18 @@ test("pruneFusionTestHomes: only targets the fusion-test-home-root- prefix", () rmSync(foreign, { recursive: true, force: true }); } }); + +test("pruneFusionTestWorkers: only targets the fusion-test-workers- prefix", () => { + const ours = path.join(tmpdir(), `fusion-test-workers-prune-prefix-${process.pid}`); + const foreign = path.join(tmpdir(), `not-ours-workers-prune-prefix-${process.pid}`); + mkdirSync(ours, { recursive: true }); + mkdirSync(foreign, { recursive: true }); + try { + pruneFusionTestWorkers(); + assert.equal(existsSync(ours), false, "orphaned worker root should be pruned"); + assert.equal(existsSync(foreign), true, "foreign dir must be left untouched"); + } finally { + rmSync(ours, { recursive: true, force: true }); + rmSync(foreign, { recursive: true, force: true }); + } +}); diff --git a/scripts/ci-test-shard.mjs b/scripts/ci-test-shard.mjs index aba7f31500..ee1af00412 100644 --- a/scripts/ci-test-shard.mjs +++ b/scripts/ci-test-shard.mjs @@ -565,12 +565,40 @@ export function enumerateDashboardLanes(scripts, entryScript = "test") { while ((match = re.exec(command)) !== null) names.push(match[1]); return names; }; + const delegatedGroup = (command) => { + const match = command.match(/--group(?:=|\s+)(app|api)\b/); + return match?.[1] ?? null; + }; + const isQualityLeaf = ([name, command]) => ( + name.startsWith("test:quality:") + && command.includes("--project") + && !command.includes("run-quality-tests") + && referencedRuns(command).length === 0 + ); + const pushLane = (lane) => { + if (seen.has(lane)) return; + seen.add(lane); + lanes.push(lane); + }; + const expandQualityDelegation = (command) => { + // The dashboard package's quality-test runner owns the current lane manifest; + // expand delegators back to real package.json leaf scripts so CI can shard them. + const group = delegatedGroup(command); + const prefix = group ? `test:quality:${group}:` : "test:quality:"; + for (const [name, leafCommand] of Object.entries(scripts ?? {})) { + if (name.startsWith(prefix) && isQualityLeaf([name, leafCommand])) pushLane(name); + } + }; const visit = (scriptName) => { if (seen.has(scriptName)) return; seen.add(scriptName); const command = scripts?.[scriptName]; if (typeof command !== "string") return; + if (command.includes("run-quality-tests")) { + expandQualityDelegation(command); + return; + } const children = referencedRuns(command); if (children.length === 0) { // Leaf: a lane that actually invokes a test runner. diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 7750ff7a8e..8f4d140626 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -16,10 +16,35 @@ "reason": "Flake: same mock-contention mode as the sibling changeset-file test above (vi.mock('node:child_process') not taking under concurrent load). FN-6206.", "quarantinedAt": "2026-06-10" }, + { + "file": "packages/engine/src/__tests__/merger-ai-cleanup-active-session.test.ts", + "reason": "Flake: pruneExistingAiMergeWorktrees skips active-session paths — active-session temp AI merge dir was unexpectedly pruned during pnpm --filter @fusion/engine test in FN-6206 verification, while the same file passed standalone. Root cause suspected: realpathSync resolution mismatch or readdirSync mock interaction with activeSessionRegistry singleton under concurrent engine suite load. Discovered during FN-6206.", + "quarantinedAt": "2026-06-10" + }, { "file": "packages/engine/src/__tests__/merger-ai-cleanup.test.ts", "reason": "Flake observed during FN-6206 verification: `pruneExistingAiMergeWorktrees skips active-session paths` failed in full `pnpm --filter @fusion/engine test` runs while the file passed standalone, indicating suite-order/concurrency sensitivity. Follow-up FN-6207.", "quarantinedAt": "2026-06-10" + }, + { + "file": "packages/engine/src/__tests__/merger-ai.test.ts", + "reason": "Flake observed during FN-6238 verification: full `pnpm --filter @fusion/engine test` failed in two merger-ai tests with git ENOENT / unable to read current working directory after a temp checkout disappeared, while the file passed standalone (23/23). Follow-up FN-6248.", + "quarantinedAt": "2026-06-11" + }, + { + "file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx", + "reason": "Flake observed during FN-6239 verification: broad `pnpm test` in dashboard backfill shard 4/4 could not find `quick-entry-priority-button` immediately after a successful task creation, while the named test passed standalone. Indicates suite-order/concurrency sensitivity unrelated to QuickChatFAB coverage.", + "quarantinedAt": "2026-06-11" + }, + { + "file": "packages/engine/src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts", + "reason": "Flake observed during FN-6294 verification and reproduced during FN-6319 broad `pnpm --filter @fusion/engine test`: `clearStaleBlockedBy handles missed task:deleted event with soft-deleted-blocker reason` failed because the log entry was absent, while the same file passed standalone and the narrow three-file reproduction passed. Product-code cross-check: `clearStaleBlockedBy` still has the soft-deleted-blocker branch and soft-delete-deadlock-scan-exclusion.test.ts covers it via a deterministic store double, indicating suite-order/concurrency sensitivity in this reliability-interactions fixture rather than a confirmed product bug.", + "quarantinedAt": "2026-06-12" + }, + { + "file": "packages/dashboard/src/__tests__/routes-settings.test.ts", + "reason": "Flake observed during FN-6354 broad `pnpm test`: `GET /api/memory/audit > preserves extraction metadata across extract then audit requests` received HTTP 503 instead of 200 in the dashboard api:curated lane, while the same named test passed standalone immediately afterward. FN-6354 only changed the task-detail Chat composer UI/tests, so this is classified as unrelated suite-order/concurrency sensitivity in the dashboard API quality lane.", + "quarantinedAt": "2026-06-13" } ] } diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 59a07aa1ed..32f8df27d9 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -169,8 +169,94 @@ export function shouldRunIsolationGuard(env = process.env) { // can't spend unbounded time rm-rf'ing a tmpdir that accumulated thousands of // stale homes — and so the cache-fresh fast path can skip it entirely. const PRUNE_MAX_ENTRIES = 64; +let cleanupRmSync = rmSync; +const PRUNE_REMOVE_RETRIES = 3; +const PRUNE_REMOVE_DELAY_MS = 75; +const PRUNE_DIAGNOSTIC_CHILD_LIMIT = 8; +const FUSION_WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner"; -export function pruneFusionTestHomes(maxEntries = PRUNE_MAX_ENTRIES) { +function isEnoentError(err) { + return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT"); +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && typeof error === "object" && error.code === "EPERM"; + } +} + +function readWorkerRootOwnerPid(rootPath) { + try { + const raw = readFileSync(path.join(rootPath, FUSION_WORKER_ROOT_OWNER_FILE), "utf8").trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isActiveFusionWorkerRoot(rootPath) { + const ownerPid = readWorkerRootOwnerPid(rootPath); + if (ownerPid !== null && isProcessAlive(ownerPid)) return true; + + // Backward-compatible guard for worker roots created before the owner marker + // landed, or marker writes that failed: an alive redir-<pid> child means a + // Vitest worker still owns temp workspaces beneath this root. + try { + for (const child of readdirSync(rootPath, { withFileTypes: true })) { + if (!child.isDirectory()) continue; + const match = /^redir-(\d+)$/.exec(child.name); + if (match && isProcessAlive(Number.parseInt(match[1], 10))) return true; + } + } catch { + // If we cannot inspect it, fall through to normal best-effort pruning. + } + return false; +} + +function listImmediateChildrenForPruneWarning(rootPath) { + try { + const children = readdirSync(rootPath).slice(0, PRUNE_DIAGNOSTIC_CHILD_LIMIT); + if (children.length === 0) return ""; + const suffix = children.length === PRUNE_DIAGNOSTIC_CHILD_LIMIT ? ", ..." : ""; + return `; remaining children: ${children.join(", ")}${suffix}`; + } catch { + return ""; + } +} + +function removePrunedRootWithRetry(rawPath, { retries = PRUNE_REMOVE_RETRIES, delayMs = PRUNE_REMOVE_DELAY_MS } = {}) { + if (!existsSync(rawPath)) return true; + + let lastError = null; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + // FN-6371/FN-6360: macOS can report a transient ENOTEMPTY/EBUSY while + // child handles inside an orphaned fusion-test-* root are still closing. + // Keep this a short bounded retry (not a long live-root deletion loop) and + // keep the surrounding scan single-level/prefix-capped. + cleanupRmSync(rawPath, { recursive: true, force: true }); + return true; + } catch (err) { + if (isEnoentError(err)) return true; + lastError = err; + if (attempt < retries) { + sleepMsSync(delayMs); + } + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + const children = listImmediateChildrenForPruneWarning(rawPath); + console.warn(`[test-changed] failed to prune leftover ${rawPath} after ${retries} attempts: ${message}${children}`); + return false; +} + +function pruneFusionTestRoots(prefix, maxEntries = PRUNE_MAX_ENTRIES, retryOptions = {}) { let tmpEntries = []; try { tmpEntries = readdirSync(tmpdir(), { withFileTypes: true }); @@ -178,26 +264,30 @@ export function pruneFusionTestHomes(maxEntries = PRUNE_MAX_ENTRIES) { return; } - let removed = 0; + let processed = 0; for (const entry of tmpEntries) { - if (removed >= maxEntries) break; - if (!entry.isDirectory() || !entry.name.startsWith("fusion-test-home-root-")) continue; + if (processed >= maxEntries) break; + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue; + processed++; const rawPath = path.join(tmpdir(), entry.name); try { realpathSync(rawPath); } catch { // Keep raw path fallback. } - try { - rmSync(rawPath, { recursive: true, force: true }); - removed++; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn(`[test-changed] failed to prune leftover ${rawPath}: ${message}`); - } + if (isActiveFusionWorkerRoot(rawPath)) continue; + removePrunedRootWithRetry(rawPath, retryOptions); } } +export function pruneFusionTestHomes(maxEntries = PRUNE_MAX_ENTRIES, retryOptions = {}) { + pruneFusionTestRoots("fusion-test-home-root-", maxEntries, retryOptions); +} + +export function pruneFusionTestWorkers(maxEntries = PRUNE_MAX_ENTRIES, retryOptions = {}) { + pruneFusionTestRoots("fusion-test-workers-", maxEntries, retryOptions); +} + function runMaybeIsolated(command, commandArgs, options = {}) { const enabled = shouldRunIsolationGuard(); const env = options.env ?? process.env; @@ -210,6 +300,7 @@ function runMaybeIsolated(command, commandArgs, options = {}) { onBeforeAfterCheck(); } pruneFusionTestHomes(); + pruneFusionTestWorkers(); if (enabled) runIsolationCheck(false, env); } } @@ -916,8 +1007,6 @@ const isolatedHomesToCleanup = new Set(); // unconditionally, even if cleanup's rm silently failed. export const knownIsolatedHomeBasenames = new Set(); -let cleanupRmSync = rmSync; - export function __setCleanupRmSyncForTests(nextRmSync) { cleanupRmSync = typeof nextRmSync === "function" ? nextRmSync : rmSync; }