diff --git a/.changeset/compound-engineering-plugin-scaffold.md b/.changeset/compound-engineering-plugin-scaffold.md new file mode 100644 index 0000000000..a41e934342 --- /dev/null +++ b/.changeset/compound-engineering-plugin-scaffold.md @@ -0,0 +1,12 @@ +--- +"@runfusion/fusion": minor +--- + +Add the Compound Engineering bundled plugin: a dedicated dashboard surface for compound-engineering artifacts and interactive `ce-*` sessions, a work→board bridge, and bidirectional board↔pipeline sync. Sessions are fully multi-session: a Sessions panel lists every run with stage/status/last-activity, lets you open and switch between concurrent sessions (each keeps running server-side), resume interrupted ones, and discard settled ones (`DELETE /sessions/:id` disposes the live handle before deleting the row). + +Sessions show the agent's full working output live (streamed thinking/tool activity with an inactivity-based stall timeout instead of a fixed turn timeout), the user can steer mid-stage with free-text guidance (attached to an answer or sent on its own), and the transcript renders past questions/answers/working traces as a proper chat surface. + +This also adds two reusable host capabilities that any plugin benefits from: + +- **Interactive agent sessions for plugin routes** (`ctx.createInteractiveAiSession`), with skill-discovery forwarding (`requestedSkillNames` / `additionalSkillPaths`) and live mid-turn progress streaming (`onProgress`: thinking/text deltas + tool markers) so a plugin can load a bundled skill into a live session and surface its work in real time. +- **Real plugin event push over SSE**: a plugin's `ctx.emitEvent` calls are forwarded to connected `/api/events` clients as project-scoped `plugin:custom` events, and dashboard views can consume them via the new `subscribePluginEvents` view-context capability. diff --git a/.changeset/fix-branch-group-name-collision-triage.md b/.changeset/fix-branch-group-name-collision-triage.md new file mode 100644 index 0000000000..2c503d15bc --- /dev/null +++ b/.changeset/fix-branch-group-name-collision-triage.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mission triage silently stranding features when two missions share a base branch. + +`branch_groups.branchName` is globally unique, but `ensureBranchGroupForSource` only checked for an existing group by `(sourceType, sourceId)`. When a second mission's shared-branch triage resolved to a base branch (e.g. `main`) that another mission already owned a branch group for, `createBranchGroup` threw `UNIQUE constraint failed: branch_groups.branchName`. That error escaped `triageFeature` and was swallowed by both of its callers (the validation-failure auto-triage and the startup/maintenance reconcile sweep), leaving the mission's `defined` features — including auto-generated fix features — permanently un-triaged and the mission unable to progress. + +`ensureBranchGroupForSource` now reuses an existing open group for the same branch name (matching the established `getBranchGroupByBranchName(...) ?? ensureBranchGroupForSource(...)` idiom) instead of colliding on the unique constraint. diff --git a/.changeset/fix-stranded-done-feature-recovery.md b/.changeset/fix-stranded-done-feature-recovery.md new file mode 100644 index 0000000000..222c1d437d --- /dev/null +++ b/.changeset/fix-stranded-done-feature-recovery.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": patch +--- + +Fix missions stalling when a feature is marked `done` but stranded mid-loop. + +A mission feature could be left `status: "done"` while its `loopState` never advanced past `"implementing"` and it had no linked board task (so it was never validated). The slice-completion gate (`MissionStore.computeSliceStatus`) correctly refuses to count an assertion-linked `done` feature until its validator passes, but nothing re-drove a task-less feature, so the slice — and the whole mission — could never auto-progress. + +Active-mission recovery now detects these stranded `done` features and re-runs assertion validation directly (no board task), so the gate can resolve: on pass the feature becomes legitimately complete, on fail the normal fix-feature flow takes over. The feature-validation path was extracted into a shared `runFeatureValidation` helper used by both task-completion and recovery. diff --git a/.changeset/fn-5907-planning-create-fetch.md b/.changeset/fn-5907-planning-create-fetch.md new file mode 100644 index 0000000000..03a08faecd --- /dev/null +++ b/.changeset/fn-5907-planning-create-fetch.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix a Planning Mode reliability bug where creating a single task could fail with a browser-level `Failed to fetch` error when post-create side effects threw or rejected before the dashboard finished responding. diff --git a/.changeset/fn-5936-auto-merge-mobile-fix.md b/.changeset/fn-5936-auto-merge-mobile-fix.md new file mode 100644 index 0000000000..83a144b12a --- /dev/null +++ b/.changeset/fn-5936-auto-merge-mobile-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard auto-merge toggle blanking on mobile by keeping board stabilization tied to viewport events instead of a one-shot resize listener. + +The in-review board now stays visible when auto-merge is toggled across Android mobile, iOS mobile, tablet, and desktop layouts, with regression coverage for populated and empty columns plus rollback and error-boundary paths. diff --git a/.changeset/fn-5937-clear-auto-pause-retry.md b/.changeset/fn-5937-clear-auto-pause-retry.md new file mode 100644 index 0000000000..a45833d969 --- /dev/null +++ b/.changeset/fn-5937-clear-auto-pause-retry.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Clear the in-review stall deadlock auto-pause on user-initiated retry so dashboard, CLI, and extension retries can actually resume merge/execution work without overriding manual pauses. diff --git a/.changeset/fn-5949-pr-conflict-resolution.md b/.changeset/fn-5949-pr-conflict-resolution.md new file mode 100644 index 0000000000..2500c1c0c6 --- /dev/null +++ b/.changeset/fn-5949-pr-conflict-resolution.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add AI-assisted conflict resolution to the dashboard Create PR flow so users can resolve task-branch merge conflicts against the selected base branch, push the updated branch, and continue PR creation without leaving Fusion. diff --git a/.changeset/per-task-automerge-override.md b/.changeset/per-task-automerge-override.md new file mode 100644 index 0000000000..ae495efe7e --- /dev/null +++ b/.changeset/per-task-automerge-override.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Respect per-task auto-merge overrides when the global auto-merge setting is off. Tasks with auto-merge explicitly enabled now get enqueued for merge and covered by the in-review self-healing sweeps (stall surfacing, merged-task finalization, retry recovery) even when the project-level setting is disabled; tasks without an explicit override keep the PR-based/manual review flow untouched. diff --git a/.changeset/vitest-autokill-guard.md b/.changeset/vitest-autokill-guard.md new file mode 100644 index 0000000000..841ae063cd --- /dev/null +++ b/.changeset/vitest-autokill-guard.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the vitest memory-pressure auto-kill firing on a garbage metric and killing innocent processes. The guard probed `os.availableMemory` (which does not exist) and silently fell back to `os.freemem()`, which on macOS reads ~99% used on an idle machine — so with the toggle on, every vitest process was SIGKILLed every 30 seconds regardless of real memory pressure. It now reads `process.availableMemory()` (Node 22+) and refuses to auto-kill when only the unreliable freemem fallback is available. Kill targeting is also fixed: `pgrep -f vitest` matches full command lines (wrapper shells, monitors, editors that merely mention vitest); the TUI auto-kill/manual kill and the dashboard `POST /api/kill-vitest` + system-stats count now filter matches to actual node processes via a shared `findVitestProcessIds` helper. diff --git a/AGENTS.md b/AGENTS.md index faf2316fd8..401c38436d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,10 +93,6 @@ pnpm verify:workspace - Motivating incidents: streamed-response spacing was fixed three times before the invariant was fully covered (FN-5787, FN-5789, FN-5803), the usage "Show hidden" button regressed three times before broader coverage stuck (FN-5797, FN-5875, FN-5919), and the auto-merge blank-dashboard fix re-opened after desktop-only coverage missed mobile Android (FN-5751). - If a regression test only proves the exact reported case, it is incomplete; extend it until the invariant holds across all known surfaces. -### STANDING DIRECTIVE: Buttons Are Frozen - -- Buttons Are Frozen (2026-05-13): when touching dashboard button styling or behavior, preserve the existing sizing/layout contract unless the task explicitly changes it and the affected docs/tests are updated together. - ### Port 4040 is Reserved Never kill processes on port 4040 and never start test servers on 4040. Use `--port 0` or another free port. @@ -178,8 +174,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix. - `./docs/cli-reference.md` — CLI and terminal UI reference. - `./docs/contributing.md` — contributing conventions and release-adjacent context. -- `./docs/solutions/` — documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. -- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts) — relevant when orienting to the codebase or discussing domain concepts. +- `./docs/solutions/` — documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. +- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts. ### Lazy-Loaded Heavy Views diff --git a/CONCEPTS.md b/CONCEPTS.md index 79bcf46c3a..98c107a0c5 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -2,23 +2,99 @@ Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. -## Branch Groups +## Missions -### Branch Group -A cohort of tasks that share one integration branch and one managed pull request. The group — not its member tasks — owns the shared branch name, the PR identity, and the group lifecycle (open, finalized, abandoned). Members reference their group by the group's stored id, never by a derivable string. +### Relationships -A Branch Group's shared branch is only ever a merge *target*; it is never any member task's working branch. Each member works on its own per-task branch and lands onto the group branch. +A Mission owns an ordered list of Milestones; a Milestone owns an ordered list of Slices; a Slice owns a set of Features. Status rolls **up**, not down: a Slice's status is derived from its Features, a Milestone's from its Slices, and a Mission's from its Milestones. Autopilot acts at the Slice boundary — it advances a Mission by activating the next Slice once the current one is complete. -### Branch Assignment Mode -The strategy by which a task acquires its working branch and merge target. Shared mode gives the task a per-task working branch derived from the group's shared branch and sets the shared branch as merge target; per-task-derived mode gives a derived working branch with no shared target; the remaining modes (project default, existing, custom new) bind the task directly to a named branch. Only shared mode creates Branch Group membership. +### Mission +A unit of autonomous, multi-step work the system plans and then drives to completion on its own, decomposed into Milestones. A Mission may run under Autopilot or be advanced manually. + +### Milestone +An ordered phase of a Mission, containing Slices and optionally depending on earlier Milestones. A Milestone is complete only when all of its Slices are complete. + +### Slice +A vertically-scoped, independently-completable chunk of a Milestone, containing Features. A Slice's status is derived from its Features and reaches *complete* only when every Feature counts as done — which, for a Feature carrying Contract Assertions, requires a passing Validator Run. + +### Feature +The smallest unit of mission work: a single deliverable evaluated against its Contract Assertions. A Feature carries both a board status (its workflow column, e.g. done) and a loop state (its execution phase); the two are distinct and can legitimately disagree mid-flight, but a done Feature that never reached a terminal loop state is an invariant violation that will stall its Slice. + +### Fix Feature +A Feature auto-generated from a failed Validator Run to carry the remediation work for the assertions that failed, linked back to the Feature it descends from. + +## Mission execution + +### Autopilot +The named process that watches an active Mission and advances it — activating the next pending Slice once the current Slice completes — while tracking its own watching/activating lifecycle and handling retries. When Autopilot is not watching a Mission, slice advancement falls back to a compatibility path. + +### Contract Assertion +A checkable acceptance criterion linked to a Feature that an AI validator judges to decide whether the Feature is genuinely done. Every Feature is validator-evaluated — a Feature missing an assertion has one lazily linked before validation — and counts toward Slice completion only after a passing Validator Run. + +### Validator Run +A single execution of the AI judge that evaluates a Feature's Contract Assertions and yields a pass, fail, blocked, or error outcome. The validator is read-only — it inspects the implementation and records a verdict, creating no board task and editing no code. A run left running after its owner disappears is reaped to a terminal error state. + +### loop state +A Feature's position in the execution loop (being implemented, awaiting or undergoing validation, awaiting a fix, passed, or blocked), distinct from its board status. Logic that gates on loop state must treat it as possibly stale and possibly contradictory with status — a Feature can be marked done while its loop state was never advanced past implementing. + +## Merge lifecycle + +### Task +The core board entity: a unit of work that moves through columns (triage, todo, in-progress, in-review, done, archived) and is executed by agents. A Task carries its own per-task settings that can override project-level defaults. + +### Auto-merge +The named process that automatically lands a completed Task's branch onto its merge target once the Task reaches In-review and passes its merge blockers. Gated twice: a project-level setting enables it globally, and each Task may carry an explicit per-task override. + +The per-task override takes precedence in both directions: an explicit per-task enable proceeds even when the global setting is off, and an explicit per-task disable routes the merge to Manual-required even when the global setting is on. Trigger-layer gates (enqueue, Self-healing sweeps) must evaluate additively — global on lets everything through for downstream routing; global off admits only explicit per-task enables — rather than collapsing the override to a single effective value, which would starve Manual-required routing. + +### In-review +The Task status column between execution and completion: work is done and the branch awaits merging. An In-review Task either auto-merges, waits for a human merge (PR-based/manual flow), or surfaces a stall diagnostic when it sits unprocessed longer than expected. Tasks not eligible for Auto-merge processing intentionally remain In-review until a human acts — recovery sweeps must not move them. + +### Merge queue +The ordered line of In-review Tasks awaiting Auto-merge, with a single merge active at a time. Tasks enter only through trigger gates (engine startup sweep, periodic retry, unpause, and the moved-to-review fast path); a Task filtered out at a gate is invisible to the merger regardless of its own settings. + +### Manual-required +The merge-request state for a Task whose merge needs an explicit human go-ahead — typically a Task with auto-merge explicitly disabled under a globally-enabled project. Reaching this state requires the Task to flow through the Merge queue trigger gates; upstream filtering that excludes such Tasks strands them In-review instead of parking them here. + +### Self-healing sweep +A recurring background scan that detects and repairs stuck Task states — stalled In-review Tasks, confirmed merges never finalized, ghost or limbo states, exhausted retries. Sweeps respect the same Auto-merge eligibility as the Merge queue: they may inspect any Task but mutate only those eligible for auto-merge processing. + +Sweeps must honor the same merge-target rules as the normal path — a Shared branch group member is always evaluated against its group branch, never the project default — and attribution of already-merged work must be anchored to commit ownership markers, not free-text matches. + +### Shared branch group +A cohort of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. The group — not its member Tasks — owns the shared branch name, the managed PR identity, and the group lifecycle (open, finalized, abandoned); members reference their group by the group's stored id, never by a derivable string. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; Group promotion (shared branch → default branch) is gated separately. + +The group's shared branch is only ever a merge *target*; it is never any member Task's working branch. Each member works on its own per-task branch and lands onto the group branch. + +### Branch assignment mode +The strategy by which a Task acquires its working branch and merge target. Shared mode gives the Task a per-task working branch derived from the group's shared branch and sets the shared branch as merge target; per-task-derived mode gives a derived working branch with no shared target; the remaining modes (project default, existing, custom new) bind the Task directly to a named branch. Only shared mode creates Shared branch group membership. ### Landed -The status of a Branch Group member whose work is merge-confirmed onto *its own group's* shared branch via the branch-group integration path. A member merged onto any other branch — a sibling task branch, the project default — is not Landed, regardless of its column. A group is complete when it has at least one member and every member is Landed; completeness gates Promotion. +The status of a Shared branch group member whose work is merge-confirmed onto *its own group's* shared branch via the branch-group integration path. A member merged onto any other branch — a sibling task branch, the project default — is not Landed, regardless of its column. A group is complete when it has at least one member and every member is Landed; completeness gates Group promotion. -### Group Promotion -The completion-gated, idempotent act of carrying a complete Branch Group forward: merging the group branch toward the project's integration branch and, in pull-request mode, creating-or-reusing the group's single managed PR. Re-running a Promotion never creates a second PR. Under disabled auto-merge, Promotion is an explicit user action; member-to-group landing may still proceed without triggering it. +### Group promotion +The completion-gated, idempotent act of carrying a complete Shared branch group forward: merging the group branch toward the project's integration branch and, in pull-request mode, creating-or-reusing the group's single managed PR. Re-running a promotion never creates a second PR. Under disabled auto-merge, promotion is an explicit user action; member-to-group landing may still proceed without triggering it. -## Engine Processes +## Compound Engineering sessions -### Self-Healing -The engine's family of recovery sweeps that detect and repair stuck or inconsistent task states (interrupted merges, already-merged work in review, misbound branches). Self-healing must honor the same merge-target rules as the normal path — a shared-group member is always evaluated against its group branch, never the project default — and attribution of already-merged work must be anchored to commit ownership markers, not free-text matches. +### CE Stage +A registered step of the compound-engineering pipeline (e.g. brainstorm, plan, work, compound), each mapped to a bundled skill and a conventional artifact location. Adding a stage is a registry data entry, not new code surface. + +### CE Session +A single interactive run of a CE Stage: an agent drives a question/answer flow with the user and produces the stage's artifact on completion. Sessions are independent pipeline runs — many can exist concurrently, each with its own lifecycle (launching, active, awaiting-input, completed, error, interrupted) and conversation history. A completed work-stage CE Session lands derived Tasks on the board, linked back to the session for provenance. + +### Detached turn +The execution posture for CE Session agent turns: the request that triggers a turn returns as soon as the session reflects it, and the turn runs in the background while clients converge through push events and polling. A detached turn never rejects — every failure persists into session state and emits an observable event, so progress is never silently lost. + +### Live activity +The transient working output of an in-flight agent turn — accumulated thinking, streamed text, and tool execution markers. It is observable while the turn runs but is not session state; when the turn settles or is interrupted, a condensed trace is folded into the conversation history so the transcript keeps the story. + +### Steering +The user's mid-stage feedback channel: free-text guidance attached to an answer, or sent on its own without answering the pending question. Agents treat steering as first-class input — incorporate it, adjust course, and either re-ask or proceed. + +### Rehydration +Re-establishing a live agent handle for a paused CE Session by replaying its recorded conversation against the model. Replay is side-effect-suppressed: it reconstructs the agent's context without re-emitting events, re-streaming Live activity, or re-writing artifacts. + +## Flagged ambiguities + +- "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/README.md b/docs/README.md index 521335f10d..ee581eb3a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Task Management](./task-management.md) | Task creation modes, lifecycle, prompt specs, comments, archiving, and GitHub integration | | [Todo View](./todo-view.md) | Canonical guide for the experimental Todo View, including enablement, usage, API routes, and storage | | [Missions](./missions.md) | Mission hierarchy, planning flow, activation, progress tracking, and autopilot behavior | +| [Goals Refinement Gate](./goals-refinement-gate.md) | Evidence gate for activating the conditional post-v1 goals refinement slice only after real usage pain is documented | | [Research](./research.md) | Research runs, provider setup, dashboard/CLI usage, findings, exports, and task integration | | [Research View UX Spec](./research-view-ux-spec.md) | Canonical layout and capability-state messaging spec for the Research dashboard view (FN-4138, informs FN-4134/FN-4135) | | [Workflow Steps](./workflow-steps.md) | Reusable quality gates, templates, pre/post-merge phases, and workflow execution results | diff --git a/docs/architecture.md b/docs/architecture.md index 694e66504e..c768df610d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1720,7 +1720,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Stale active branches**: self-healing's `reclaim-stale-active-branches` stage prunes a `fusion/` branch with zero unique commits when no usable worktree mapping exists, then clears `task.branch`/`task.worktree`/`task.baseCommitSha`. It must defer reclaim (emit `branch:stale-active-reclaim-deferred`) when the task worktree is in `activeSessionRegistry`, when `executionStartedAt` is within `STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS` (10 minutes), or when the mapped worktree has uncommitted changes. - **Worktree metadata reconcile ordering (FN-4962)**: `reconcile-task-worktree-metadata` must run before `reclaim-stale-active-branches`; stale `task.worktree` metadata is rebound to live `fusion/` worktrees when present (`task:auto-recover-worktree-metadata-rebound`) or cleared (`task:auto-recover-worktree-metadata-cleared`) when absent. - **Completion fan-out is synchronous**: `SelfHealingManager.reconcileCompletedTask()` runs on `in-review → done`. Downstream stale `blockedBy` links and residual `fusion/` branch/worktree artifacts are reconciled immediately, not on a periodic sweep. -- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`. +- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`. User-initiated retry paths (dashboard retry, `fn_task_retry`, and CLI `task retry`) clear that automatic deadlock pause so the retry can execute, but they never override explicit/manual pauses or unrelated automatic pause reasons. - **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating. - **Executor pre-session liveness gate (FN-4935)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. FN-5772 adds a bounded nested-root self-heal: when `task.worktree` points at a strict descendant of a registered worktree root inside the configured worktrees dir, executor re-anchors `task.worktree` to the git top-level, emits `worktree:reanchored` (`fromPath`, `toPath`, `source`), and proceeds; repo-root/outside-dir/unregistered top-level mismatches still fail. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path. - **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 233470e20b..bac1ef0fb4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -613,6 +613,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. +- In the **Create Pull Request** modal, if preflight detects `conflictsWithBase`, the modal now offers **Resolve conflicts with AI**. Fusion uses an AI coding agent to resolve merge markers on the task branch, commits the result, pushes `fusion/` to `origin`, and refreshes preflight so normal PR creation can continue once conflicts are gone. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. - **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. - Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call). @@ -1089,7 +1090,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`. +- **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. - **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`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f8ae327790..ab0ca6b047 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -4,12 +4,13 @@ Executor, heartbeat, and planning runs emit one goal-injection diagnostic with outcome `applied`, `no-goals`, or `disabled-or-failed`. -- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`. +- Run-audit event: `prompt:goal-injection` (`database` domain, target lane) with metadata `{ lane, outcome, goalCount, goalIds, provenanceGoalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }`. - Goal anchoring events also persist `metadata.goalIds` (alongside existing count/tool fields): - `goal:injection-applied` / `goal:injection-skipped` → `{ lane, count, goalIds, truncated?, reason? }` - `goal:retrieval-invoked` → `{ toolName, count, goalIds, notFound }` - Run cited-goals read path: `GET /api/agents/:id/runs/:runId/cited-goals` returns `{ runId, taskId?, injectedGoalIds, retrievedGoalIds, citedGoalIds }` aggregated from `goal:*` + `prompt:goal-injection` run-audit events. -- Task log (executor lane with `taskId`): `[goal-injection] count= ids= truncated= ...`. +- Task log (executor lane with `taskId`): `[goal-injection] count= ids= provenance= truncated= ...`. +- `goalIds` / `goalCount` describe the active goals injected into the prompt; `provenanceGoalIds` additively records mission-derived task provenance and does not affect prompt selection. - Guardrail: diagnostics persist goal IDs/counts only; never prompt text, goal titles, or goal descriptions. ## Insight run sweeper (`[insight-sweeper]`) diff --git a/docs/goals-refinement-gate.md b/docs/goals-refinement-gate.md new file mode 100644 index 0000000000..ff85f0f0c7 --- /dev/null +++ b/docs/goals-refinement-gate.md @@ -0,0 +1,104 @@ +# Goals Refinement Gate + +[← Docs index](./README.md) + +This document defines the evidence gate for activating **Slice 4: Schema/Focus-Set Refinement (Conditional)** in the Goals mission (`M-MP32KU9Y-0001-2ADN`). It is a governance artifact, not an implementation plan. + +## Purpose + +Slice 4 stays pending and intentionally under-specified until Slices 1–3 produce **observed pain from real use**. Fusion must not build a structured `successMetric` schema, a focus-set concept, or richer goal-progress/reporting surfaces because they seem plausible in advance. Refinement starts only when operators can point to real usage evidence showing the v1 shape is insufficient. + +## Locked guardrails carried forward from the mission + +The gate inherits the mission guardrails already locked by CEO + CTO + PM: + +1. **Hard cap of 5 active goals** remains the v1 operating limit. +2. **Success metrics live in slice/feature text for v1** rather than a structured `successMetric` schema. +3. **Only Slice 1 was activated up front**; later slices were not meant to auto-start just because earlier work shipped. +4. Slice 4 is conditional follow-up work, not an automatic continuation of the v1 Goals rollout. + +Until this gate is satisfied, Slice 4 remains pending in practice: no schema-expansion, focus-set, or reporting implementation work should begin. + +## Acceptable trigger evidence + +A written activation rationale may recommend Slice 4 only when it cites real usage evidence in one or more of these categories. + +### 1. Operator friction + +Observed operator pain using the v1 goals workflow may justify refinement when teams repeatedly struggle to create, maintain, interpret, or operationalize goals with the existing surfaces. + +**Corresponding Slice 4 direction:** general post-v1 refinement work, but only where the pain is demonstrated rather than speculative. + +### 2. Prompt-budget or context-window pressure from goal injection + +If active-goal injection creates measurable prompt-budget pressure, context-window crowding, or citation noise during actual agent use, that is valid trigger evidence. + +**Corresponding Slice 4 direction:** candidate focus-set or narrowing mechanisms so only the most relevant goals are injected or emphasized. + +### 3. Unclear prioritization or unclear mission ↔ goal ownership + +If real usage shows agents or operators cannot tell which goals should drive a mission, or cannot reliably distinguish active strategic priorities from background goals, that is valid trigger evidence. + +**Corresponding Slice 4 direction:** candidate focus-set concepts, richer linkage semantics, or prioritization aids. + +### 4. Free-text success-metric limitations that fail agent reasoning + +If goals expressed only through free-text slice/feature descriptions cause repeated ambiguity, weak planning, poor validation, or unreliable agent reasoning, that is valid trigger evidence. + +**Corresponding Slice 4 direction:** candidate structured `successMetric` schema, but only to solve the observed reasoning failure. + +### 5. The hard 5-active-goal cap proving too tight + +If real operating practice shows the fixed five-goal cap blocks necessary work, forces unhealthy churn, or hides the difference between globally active goals and a smaller currently emphasized subset, that is valid trigger evidence. + +**Corresponding Slice 4 direction:** candidate focus-set concept or related prioritization model. + +### 6. Reporting or visibility gaps + +If operators cannot answer basic progress, coverage, linkage, or adoption questions with the v1 read surfaces, and the gap is observed in real workflows, that is valid trigger evidence. + +**Corresponding Slice 4 direction:** candidate goal-progress or reporting views. + +## Activation rule + +Slice 4 may be activated only after a **written rationale** is recorded that: + +- references **real usage evidence**, not anticipated future needs; +- identifies which trigger-evidence category or categories were observed; +- explains why the observed pain is significant enough to justify refinement now; and +- cites the structured evidence collected in the **FN-5963 conditional refinement trigger evidence pack/template**. + +That written rationale must exist **before** anyone calls `fn_slice_activate` for `SL-MP32LAJW-0009-RHJQ`. + +## Hard constraint: no automatic refinement + +The existence of Slice 4 in the mission does **not** authorize automatic follow-on work. + +- No structured `successMetric` schema work starts automatically. +- No focus-set concept starts automatically. +- No reporting or visibility expansion starts automatically. +- No schema or expansion task should be treated as pre-approved merely because Slices 1–3 shipped. + +Without the written rationale and evidence trigger above, Slice 4 remains pending and unspecified. + +## Separation of concerns + +This gate intentionally stays narrow: + +- **FN-5961 (this artifact):** defines *when* refinement may start. +- **FN-5962:** maintains the conditional refinement **options backlog** describing candidate directions. +- **FN-5963:** defines the **evidence pack/template** used to gather and cite the real-usage evidence behind an activation request. + +This document should reference those sibling deliverables rather than duplicate them. + +## Decision rule summary + +Use this checklist before any Slice 4 activation: + +- Is there observed pain from real use of Slices 1–3? +- Does the evidence fit one or more accepted trigger categories above? +- Has the evidence been captured in the FN-5963 evidence pack/template? +- Has a written rationale been recorded citing that evidence and naming the proposed refinement direction? +- Has all of that happened **before** `fn_slice_activate` is called for Slice 4? + +If any answer is no, do not activate Slice 4. diff --git a/docs/missions.md b/docs/missions.md index cf137f9852..5ca0da17f7 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -52,6 +52,25 @@ Mission ↔ goal links are created and removed deliberately as part of normal pl Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association. +### Task → Goal provenance + +When a mission feature is linked or triaged into a task, Fusion does **not** copy goal ids onto the task row. Instead, task goal provenance is always derived from the mission link owned by `MissionStore`: + +- `listGoalIdsForTask(taskId)` resolves the owning mission from the linked feature hierarchy first (`feature -> slice -> milestone -> mission`), then falls back to the live task row's `missionId` when needed. +- `listGoalsForTask(taskId)` maps those ids back to full `Goal` records using the same goals-table read path as `getMissionWithHierarchy`, so mission reads and task provenance stay in sync. +- Unknown, unlinked, or partially missing hierarchy state resolves fail-soft to `[]`. +- Archived goals remain part of provenance; only missing goal rows are dropped. + +This derived bridge lets downstream systems recover which strategic goals a task serves without duplicating mission-goal linkage during task creation. + +### Goal-injection diagnostics provenance field + +The engine's `resolveAndEmitGoalContext` seam still injects only the always-on active-goal context into prompts, but diagnostics now add `provenanceGoalIds: string[]` alongside the existing injected `goalIds` / `goalCount` fields. + +- `goalIds` / `goalCount` continue to describe the active goals injected into the prompt. +- `provenanceGoalIds` records which mission-linked goals the task serves. +- Diagnostics and run-audit metadata persist ids/counts only — never goal titles, descriptions, or prompt text. + ## Creating Missions ### Mission base branch defaults diff --git a/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md b/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md index 73183f739d..cb3c544a1c 100644 --- a/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md +++ b/docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md @@ -1,6 +1,6 @@ --- title: "feat: Compound Engineering plugin with end-to-end UI" -status: active +status: completed type: feat date: 2026-06-02 origin: docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md new file mode 100644 index 0000000000..2447daae98 --- /dev/null +++ b/docs/plugins/compound-engineering.md @@ -0,0 +1,114 @@ +# Compound Engineering Plugin + +A dedicated dashboard surface for the compound-engineering (CE) workflow — an +artifact hub, interactive `ce-*` skill sessions, a work→board bridge, and +event-driven bidirectional sync. It runs alongside Fusion's native pipeline. + +## Install + +1. Open **Settings → Plugins → Fusion Plugins**. +2. In **Bundled Plugins**, click **Install** for **Compound Engineering**. +3. Enable the plugin if it is not already started. + +When installed and enabled, the plugin registers the **Compound Engineering** +dashboard view destination and installs its bundled `ce-*` skills into a +plugin-local, discoverable directory (never a global `~/.claude/skills` path). + +## Dashboard view + +The Compound Engineering view is registered as a primary plugin destination +(`viewId: "compound-engineering"`). + +It provides: +- An **artifact hub** that discovers CE artifacts from conventional locations + (`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, + `CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit + empty / partial / error states. +- Self-contained artifact previews read through plugin routes under + `/api/plugins/fusion-plugin-compound-engineering/`. +- A **stage launcher** listing the registered, operator-enabled stages. + +## Sessions + +Each stage maps to a bundled skill via the stage registry +(`{ stageId, skillId, artifactLocation, icon, label }`). Launching a stage starts +an interactive agent session on the host's `createInteractiveAiSession` seam. + +The orchestrator streams `thinking`/`text` turns, surfaces a structured +`question` (pausing in `awaiting_input`), accepts a structured answer, and on +`complete` writes the artifact to the stage's conventional location. Lifecycle: +`launching → active → awaiting_input → completed`, plus `error` and +`interrupted`. Interrupt/error auto-saves progress and emits an observable event; +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 +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 +turns are never killed; on settle/interrupt the working trace is condensed into +the conversation history. Users can also **steer** mid-stage: answers may carry +free-text guidance (`{value, comment}`) or be guidance-only (`{feedback}`). + +Updates are **pushed** over the shared `/api/events` SSE stream: the orchestrator +emits via `ctx.emitEvent`, the host forwards them as project-scoped +`plugin:custom` events, and the view subscribes through the host +`subscribePluginEvents` capability (no raw `EventSource`). Polling +`GET /sessions/:id` remains a fallback. The `projectId` from `start` is threaded +through every answer/resume/poll so they resolve the session's owning store. + +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`) +- `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 + +## Sync model + +Two separate state machines are kept in sync, never merged: + +- **Board-task ownership** → the task `column`. The **board is authoritative for + task state**. +- **CE-pipeline ownership** → `ce_pipeline_state.{currentStage, status}`. The + **CE flow is authoritative for artifact/pipeline content**. + +**Inbound:** `onTaskMoved` / `onTaskCompleted` hooks resolve the link and enqueue +a sync signal under the 5s hook budget — no inline advancement. + +**Reconcile:** `reconcileCePipelines(ctx)` is a single on-demand sweep (not a +poll loop). It drains the queue and independently re-derives transitions from +live board state, so a dropped or never-enqueued event still converges. + +**Outbound:** when a pipeline advances to a stage that produces board work, the +reconciler creates the next-stage board task and links it. + +**Conflict policy:** the reconciler only reads already-terminal board columns and +only writes CE-owned fields plus a new board task, so the two writers never +contend over the same cell. + +The work bridge tags every CE-originated board task (source `workflow_step` with +CE markers in `sourceMetadata`) and records an authoritative pipeline-link row; +created tasks then run the normal lifecycle untouched. + +## Settings + +Settings render under **Settings → Plugins → Compound Engineering**. + +**Sessions** +- `defaultProvider` (string) — provider for CE interactive sessions; blank uses + the host default. Consumed by the orchestrator's factory call. +- `defaultModelId` (string) — model within the provider; blank uses the host + default. Consumed by the orchestrator's factory call. +- `enabledStages` (string[], default = full registry) — only these stage IDs may + be launched; the orchestrator rejects others. + +**Sync** +- `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep + after task move/complete hooks. When off, the hook still enqueues so an + on-demand sweep converges later. +- `reconcileIntervalMinutes` (number, default `15`) — cadence hint for an + on-demand refresh surface; not a continuous poll loop. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 08dcf82ad7..179225d0b2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -430,7 +430,7 @@ Default notes: | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). | | `chatAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-cleanup retention window for idle chat sessions and chat rooms. `0` is off (default). When enabled, periodic self-healing maintenance deletes rows with `updatedAt` older than the configured day window. | | `mailAutoCleanupDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `0` | Auto-prune retention window for inbox/outbox mail messages. `0` is off (default). When enabled, periodic self-healing maintenance deletes `messages` rows where `updatedAt < cutoff` for the configured day window. Suggested setting: `7`. | -| `operationalLogRetentionDays` | `number` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). Periodic maintenance prunes rows older than this many days using each row's `timestamp`. Set `0` to disable pruning. | +| `operationalLogRetentionDays` | `0 \| 7 \| 14 \| 30 \| 60 \| 90` | `30` | Retention window for SQLite operational-log tables (`activityLog`, `runAuditEvents`, `agentHeartbeats`). `0` is off. Lower values mean Reliability metrics/charts and the Activity feed will not show history older than the configured window; per-task task detail history is unaffected. Periodic maintenance prunes rows older than this many days using each row's `timestamp`. | | `agentLogFileRetentionDays` | `number` | `0` | Retention window for per-task `.fusion/tasks/{ID}/agent-log.jsonl` files after a task is soft-deleted or archived. Periodic maintenance removes JSONL entries older than this many days; active tasks are never pruned. Set `0` to disable pruning. | | `chatRoomRecentVerbatimMessages` | `number` | `25` | Number of newest chat-room messages kept verbatim in responder context before older entries are compacted (about 2× prior default history). | | `chatRoomCompactionFetchLimit` | `number` | `200` | Upper bound on room messages fetched for transcript compaction per responder turn (raised to support larger retained context windows). | diff --git a/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md b/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md new file mode 100644 index 0000000000..4390553f27 --- /dev/null +++ b/docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md @@ -0,0 +1,166 @@ +--- +title: "Observable long-running agent turns through a blocking plugin-route seam" +date: 2026-06-03 +category: architecture-patterns +module: fusion-plugin-compound-engineering +problem_type: architecture_pattern +component: service_object +severity: high +applies_when: + - "A plugin/HTTP route drives a long-running interactive agent turn behind a blocking request/response seam" + - "Mid-turn agent output (thinking, tool calls, streamed text) is swallowed by a pull-based settle-only event API" + - "A fixed per-turn timeout risks killing legitimately long, tool-heavy turns that are still actively working" + - "Clients need live visibility into agent work without persisting transient activity into durable state" + - "A paused, stateful agent session must be resumable across process restarts without re-emitting prior output" +symptoms: + - "Client blocks on a single POST for minutes with zero visibility into agent progress" + - "All mid-turn thinking, tool calls, and streamed text are swallowed; only question/complete/error surface" + - "A fixed 120s per-turn timeout interrupts legitimately long tool-heavy turns while the agent is still working" +root_cause: async_timing +resolution_type: code_fix +related_components: + - tooling + - frontend_stimulus +tags: + - agent-observability + - sse + - streaming + - detached-execution + - plugin-routes + - interactive-session + - inactivity-timeout + - live-activity + - compound-engineering +--- + +# Observable long-running agent turns through a blocking plugin-route seam + +## Context + +The compound-engineering bundled plugin runs interactive CE-stage agent sessions through plugin routes. The host exposes a deliberately minimal **pull-based** interactive seam (`packages/core/src/plugin-types.ts`): the caller drives one `prompt`/`answer` per turn and awaits `nextEvent()`, which resolves only when the turn *settles* (`question` | `complete` | `error`). That contract is simple to drive deterministically from a route or a scripted test — but it had three structural consequences that surfaced as user-visible failures: + +1. **All mid-turn output was swallowed.** `nextEvent()` does not resolve on intermediate thinking/text/tool activity, so a multi-minute tool-heavy turn produced *nothing* observable until it finished. +2. **Routes blocked blind.** The POST handler ran the whole turn synchronously inside the request, so clients waited minutes with no feedback (and, for the opening turn, no session id to poll). +3. **A fixed 120s turn timeout killed turns that were actively working** — long, legitimately-busy turns hit the wall and died. + +The fix made the agent's work live-streamable, made routes non-blocking (detached turns), made the timeout inactivity-based, and persisted the working trace into the transcript across settle/interrupt and process restarts. + +## Guidance + +### 1. Keep the pull-based settle contract; add a SEPARATE push channel + +Don't convert `nextEvent()` into a stream. Live visibility is a *new, optional, additive* callback (`onProgress`) on the session options — the terminal-only pull semantics are untouched. Scripted test fakes that drive `prompt`/`nextEvent` are completely unaffected, and factories that can't stream simply ignore the option. + +```ts +// packages/core/src/plugin-types.ts +export interface CreateInteractiveAiSessionOptions { + // ... + /** Live progress callback, invoked WHILE a turn runs (the pull-based + * nextEvent() only resolves once the turn settles). Must not throw — + * implementations should swallow callback errors. */ + onProgress?: (event: InteractiveAiSessionProgressEvent) => void; +} +``` + +### 2. Deltas, not snapshots; the consumer accumulates + +Progress events carry incremental *deltas*. The consumer owns accumulation, merge-by-kind, and capping — the protocol stays tiny and the producer holds no buffer state. + +```ts +export type InteractiveAiSessionProgressEvent = + | { type: "thinking"; delta: string } // incremental DELTA, not a snapshot + | { type: "text"; delta: string } + | { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean }; +``` + +The engine adapter (`packages/engine/src/index.ts`) maps the underlying agent hooks (`onText`/`onThinking`/`onToolStart`/`onToolEnd`) into these deltas, and **every callback is wrapped in try/catch so a consumer error can never break the agent turn**. The consumer (`orchestrator.handleProgress`) merges consecutive same-kind deltas into one activity turn, opens/closes discrete tool turns, and caps both per-turn chars and turn count, dropping the oldest (the tail is what the user is watching). + +### 3. Detached turns must NEVER reject + +Routes return immediately after the session row exists; the turn runs as a floating background promise (`void turn`). For that to be void-safe, the background promise can have *no* rejection path: factory-create failure, driver throw, and timeout all resolve into a persisted state transition (`failSession` / `interruptSession` / `applyEvent`) plus an emitted observable event. No unhandled rejections, no silent loss. + +```ts +// orchestrator.start +const turn = this.runOpeningTurn(session.id, stage, opts.openingMessage); +if (opts.detach) { + void turn; // never rejects (failures persist into state) + return { session: this.requireSession(session.id) }; +} +``` + +### 4. Inactivity watchdog, not a fixed turn timeout + +The watchdog rejects only after `turnTimeoutMs` of *no progress*; each progress event stamps `lastProgressAt` and re-arms it, so an actively-working turn survives indefinitely. With a non-streaming factory (no progress ever arrives), it degrades cleanly to the old fixed per-turn timeout. + +```ts +const check = () => { + if (cancelled) return; // cancel() stops the loop when the turn settles + const elapsed = Date.now() - (this.lastProgressAt.get(sessionId) ?? 0); + if (elapsed >= this.turnTimeoutMs) { reject(new CeTurnTimeoutError(this.turnTimeoutMs)); return; } + timer = setTimeout(check, this.turnTimeoutMs - elapsed); // re-arm to the remaining window + timer.unref?.(); +}; +``` + +A watchdog rejection is caught and becomes a preserved-progress `interrupted` state — never silent. + +### 5. Live activity is transient; flush a condensed trace into history on settle/interrupt + +The mid-turn buffer lives only in memory; the GET route reads it from the orchestrator (`getLiveActivity(id)`) and attaches it as a transient `liveActivity` field on the response — never written as session state during the turn. + +On settle (`question`/`complete`/`error`) or interrupt, `flushActivity` writes a condensed copy into conversation history **before** the settling record, so the transcript retains the working trace across restarts. + +### 6. Suppress progress (and all side effects) during rehydration replay + +Resume re-creates a live handle by replaying recorded user turns against the model. That replay re-streams old output — which must not be re-emitted as new work. A `replaying` set gates `handleProgress`, and the replay drains one event per drive but **discards** it (no persist/emit/artifact-write). + +### 7. Throttle push emits and bump the staleness anchor on the same beat + +Progress is high-frequency; per-delta SSE emits would flood clients. Throttle to one emit per interval (500ms here), and on that same beat bump the persisted liveness anchor (`lastActivityAt`) so the stale-session recovery rubric sees an actively-working turn as alive rather than abandoned. The client converges via **push + poll**: an SSE event triggers an immediate refetch (low latency), and a poll interval runs while the turn is mid-flight as a fallback — stopping the moment the session settles. + +## Why This Matters + +- **Observability without protocol churn.** A push side-channel gives live visibility while preserving a settle contract that's trivial to drive deterministically from routes and tests. Converting `nextEvent()` into a stream would have rewritten every consumer and every scripted fake for a purely additive feature. +- **Non-blocking routes need void-safe background work.** Detaching a turn is only safe if the background promise has no rejection path. Routing *every* failure into persisted state + an emitted event is what makes `void turn` correct rather than a latent unhandled-rejection bug. +- **Activity is the liveness signal.** An inactivity watchdog encodes the real intent ("is it still working?") instead of a proxy ("has it taken too long?"), and folding the same signal into the staleness anchor keeps two independent health rubrics coherent. +- **Resilience across restarts.** Persisting a condensed trace on settle/interrupt, plus side-effect-suppressed rehydration, means a paused session resumes in a fresh process with its history intact and without double-streaming. + +## When to Apply + +- Surfacing live agent (or any long-running job) work through a request/response or pull-based seam that only resolves on terminal events +- A route runs a multi-minute operation and clients currently block with no progress and no handle to poll +- A fixed timeout is killing work that is legitimately still active +- Resuming a paused, stateful session across process restarts without re-emitting prior output + +Apply the *push-channel-alongside-pull-contract* and *void-safe-detached-turn* patterns together; they're complementary. Don't reach for this when the operation is short and synchronous — the transient buffer, watchdog, and rehydration machinery are overhead you don't need. + +## Examples + +Before/after, distilled: + +- **Before:** route `await`s the entire turn inside POST; client gets nothing for minutes; mid-turn output is dropped because `nextEvent()` only resolves on settle; a fixed 120s timeout kills busy turns. +- **After:** POST returns `201 {session}` immediately with `detach: true`; `onProgress` deltas accumulate into a transient buffer attached at GET; an inactivity watchdog re-armed by progress lets busy turns run; failures persist into state + emit; resume rehydrates with replay suppressed. + +The regression tests (`plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts`) lock the load-bearing behaviors: + +- A busy turn pumped with `thinking` deltas at ~45ms intervals for ~3× the 120ms test timeout stays `active`; once quiet, it flips to `interrupted` with the activity trace present in history. +- `answer(detach)` returns immediately (`status: active`, `currentQuestion: null`), then the background turn converges to the next question. +- `start(detach)` with an exploding factory converges to `status: error` with the message preserved and an observable error event emitted — never silent. + +Failure modes this prevents: + +1. Silent mid-turn blackout (pull-only API resolves nothing until terminal) +2. Blocked-blind routes (request held open for the full turn, no handle to poll) +3. Killed-while-working timeouts (fixed timeout vs. activity-based liveness) +4. Unhandled rejection / silent loss from floating detached turns +5. Replay double-streaming during rehydration +6. SSE flooding from per-delta emits +7. Stale-rubric false positives on busy sessions (liveness not bumped with activity) +8. Lost transcript on interrupt/settle (transient buffer never condensed into history) +9. Consumer `onProgress` errors breaking the agent turn (guarded at the adapter) + +## Related + +- [Plugin-bundled skills silently fail to load in interactive sessions](../integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md) — sibling learning on the same `CreateInteractiveAiSessionOptions` seam (it added `requestedSkillNames`/`additionalSkillPaths`; this one adds `onProgress`) +- `docs/plugins/compound-engineering.md` §Sessions — the reference doc for the CE session transport (push + poll) +- Key files: `packages/core/src/plugin-types.ts`, `packages/engine/src/index.ts` (interactive adapter), `plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts`, `src/routes/session-routes.ts`, `src/dashboard/hooks/useCeSession.ts` diff --git a/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md new file mode 100644 index 0000000000..6265456976 --- /dev/null +++ b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md @@ -0,0 +1,124 @@ +--- +title: "Plugin-bundled skills silently fail to load in interactive sessions" +date: 2026-06-03 +category: integration-issues +module: packages/engine +problem_type: integration_issue +component: tooling +severity: high +symptoms: + - "Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never load into live interactive agent sessions" + - "No error is raised — the requested skill name silently matches nothing and is dropped" + - "The `SKILL.md` files are physically bundled in the plugin yet remain undiscoverable to the session" + - "Interim workaround (set session cwd to the install root + name the skill in the system prompt) does not make the skill discoverable" +root_cause: incomplete_setup +resolution_type: code_fix +related_components: + - assistant + - development_workflow +tags: + - skills + - plugin + - skill-resolver + - additional-skill-paths + - resource-loader + - interactive-session + - compound-engineering +--- + +# Plugin-bundled skills silently fail to load in interactive sessions + +## Problem + +Bundled `ce-*` skills declared via `PluginSkillContribution.skillFiles` never loaded into live interactive agent sessions. The engine's skill resolver only *filters* skills it already discovered on disk and never ingests a contribution's `skillFiles`, so a name-only contribution produced no loadable skill — silently. + +## Symptoms + +- A stage session runs, but the agent behaves as if the `ce-*` skill is absent — its instructions are never applied. +- The resolver returns an empty/unchanged skill set for the requested name; the filter has nothing matching to keep. +- **No error is raised.** A `PluginSkillContribution` is name-only (`{ skillId, name, skillFiles }`), so declaring it is structurally valid; the session just starts without the skill. +- Tests using a scripted/fake session pass, hiding the gap — only a *real* resource loader surfaces it. + +## What Didn't Work + +**1. Declaring `skills: PluginSkillContribution[]` alone.** The engine resolver (`skill-resolver.ts`) computes an allow/exclude *filter* over skills the loader already discovered on disk. `createSkillsOverrideFromSelection` returns a callback that only ever runs `base.skills.filter(...)` — it never *adds* skills. If the bundled `SKILL.md` was never physically on a discoverable path, it isn't in `base.skills`, so filtering by its name yields `[]`. The contribution's `skillFiles` are never read for live sessions. + +**2. Setting the session `cwd` to the install root + naming the skill in the system prompt.** `DefaultResourceLoader` discovers skills by scanning *standard skill roots* (e.g. `/.claude/skills//SKILL.md`), not by treating an arbitrary `cwd` as a skills directory. Pointing `cwd` at `` (which holds `/SKILL.md` directly) does not match the layout the loader scans, so the skill still isn't discovered — and it relocates the session away from the project root where it must read context and write artifacts. A prompt mention cannot inject skill content the loader never loaded. + +## Solution + +Two parts: **physically install** the bundled skill to a discoverable plugin-local dir, and **forward both the requested name and the install dir** through a new seam option, end to end. + +**Physical install** (`skill-installation.ts`) — copy each bundled `/SKILL.md` into a plugin-local target, with a hard isolation guard (never a global `~/.claude|.codex|.gemini/skills`), idempotently: + +```ts +assertPluginLocalTarget(targetRoot); // isolation invariant: never a global skills dir +if (!existsSync(join(targetRoot, skillId))) { // skip-if-exists + mkdirSync(targetRoot, { recursive: true }); + cpSync(join(sourceRoot, skillId), join(targetRoot, skillId), { recursive: true }); +} +``` + +**Layer 1 — engine loader seam (`pi.ts`).** A new `AgentOptions.additionalSkillPaths`, forwarded into `DefaultResourceLoader` as a real *discovery* path (distinct from the filtering `skillsOverride`): + +```ts +// AgentOptions +skills?: string[]; // convenience → auto-builds a SkillSelectionContext (requestedSkillNames) +additionalSkillPaths?: string[]; // extra dirs (each holding /SKILL.md) for the loader to SCAN + +const resourceLoader = new DefaultResourceLoader({ + cwd: resolvedProjectRoot, + ...(options.additionalSkillPaths?.length + ? { additionalSkillPaths: [...options.additionalSkillPaths] } : {}), + ...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}), +}); +``` + +**Layer 2 — core seam type (`plugin-types.ts`).** `CreateInteractiveAiSessionOptions` gains the matching fields so a plugin route can request them: + +```ts +requestedSkillNames?: string[]; // names the session should load +additionalSkillPaths?: string[]; // dirs to scan so requestedSkillNames are discoverable +``` + +**Layer 3 — engine adapter (`index.ts`).** Forwards both into `createFnAgent`, mapping `requestedSkillNames` → the convenience `skills` param: + +```ts +...(opts.requestedSkillNames?.length ? { skills: opts.requestedSkillNames } : {}), +...(opts.additionalSkillPaths?.length ? { additionalSkillPaths: opts.additionalSkillPaths } : {}), +``` + +**Caller — orchestrator (`orchestrator.ts`).** Passes the stage's skill id as the requested name AND the plugin-local install root as a discovery path, while keeping `cwd` on the project root: + +```ts +private buildSessionOptions(stage: CeStageDefinition) { + return { + cwd: this.projectRoot, // project root — NOT the skills dir + requestedSkillNames: [stage.skillId], + additionalSkillPaths: resolveStageSkillPaths(), // [resolveDefaultInstallTargetRoot()] + // ...systemPrompt, tools, model + }; +} +``` + +## Why This Works + +- `skillsOverride` (built by `createSkillsOverrideFromSelection`) is purely a **filter** over `base.skills`. To make a new skill *exist* in `base.skills`, **discovery** must be fed the path — exactly what `additionalSkillPaths` does on `DefaultResourceLoader`: it scans those dirs for the `/SKILL.md` layout, so the physically-installed skill now appears in `base.skills`. +- The convenience `skills` / `requestedSkillNames` param auto-builds a `SkillSelectionContext`, which makes the filter *include* that name instead of passing everything or nothing. +- Discovery (add via `additionalSkillPaths`) and selection (keep via `requestedSkillNames`) are now both satisfied, so the skill is loaded **and** retained — with `cwd` still on the project root, so context reads and artifact writes are unaffected. + +## Prevention + +A plugin author shipping a bundled skill should: + +1. **Physically install** the `SKILL.md` to a **plugin-local, discoverable** dir laid out as `//SKILL.md` (use `cpSync` + skip-if-exists). Never install into a global `~/.claude|.codex|.gemini/skills`; keep an explicit `assertPluginLocalTarget()` guard so a global install is never clobbered. +2. **Forward both** seam options when starting the session: `requestedSkillNames: [skillId]` (so the resolver keeps it) **and** `additionalSkillPaths: [installRoot]` (so the loader discovers it). One without the other silently no-ops — a name with no discovered file filters to `[]`; a discovered file with no requested name can be filtered out. +3. Remember `skillsOverride` only filters — declaring a `PluginSkillContribution` is **name-only** and never injects skill content into a live session. +4. **Prove it with a real `DefaultResourceLoader`** (see `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`) that asserts the skill actually appears in the resolved session skills — a scripted/fake session cannot catch a discovery gap. + +## Related Issues + +- `docs/PLUGIN_AUTHORING.md` (§skills) presents `skillFiles` as sufficient for surfacing bundled skills in sessions — now misleading; warrants a note that plugins must physically install + forward `additionalSkillPaths`. +- `docs/plans/2026-06-02-001-feat-compound-engineering-plugin-plan.md` assumed "`PluginSkillContribution.skillFiles` covers bundled skills" (KTD5) — corrected by this learning. +- `docs/brainstorms/2026-06-02-compound-engineering-plugin-requirements.md` (R11–R13) defines the plugin-local, never-global install rules this fix implements. +- No related GitHub issue exists (searched `plugin skill discovery`, `compound engineering skill` — zero matches). diff --git a/docs/solutions/logic-errors/branch-group-name-collision-strands-mission-triage.md b/docs/solutions/logic-errors/branch-group-name-collision-strands-mission-triage.md new file mode 100644 index 0000000000..d970439557 --- /dev/null +++ b/docs/solutions/logic-errors/branch-group-name-collision-strands-mission-triage.md @@ -0,0 +1,83 @@ +--- +title: "Branch-group name collision silently strands mission triage" +date: 2026-06-03 +category: docs/solutions/logic-errors +module: "core/store (branch_groups) + engine mission triage" +problem_type: logic_error +component: database +symptoms: + - "Mission's defined features (incl. auto-generated fix features) are never triaged into tasks and the mission stops progressing" + - "No Fix: tasks exist and no triage audit event is emitted, despite repeated startups" + - "Engine log shows 'UNIQUE constraint failed: branch_groups.branchName' (only in stdout — never persisted)" + - "Triage works for one mission but fails for another that shares the same base branch" +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - "packages/core/src/store.ts (ensureBranchGroupForSource, createBranchGroup, getBranchGroupByBranchName)" + - "packages/core/src/mission-store.ts (triageFeature)" + - "packages/engine/src/mission-execution-loop.ts (handleValidationFail auto-triage)" + - "packages/engine/src/scheduler.ts (reconcileAllMissionFeatures)" +tags: + - mission-system + - branch-groups + - triage + - unique-constraint + - swallowed-error + - idempotency +--- + +# Branch-group name collision silently strands mission triage + +## Problem + +`MissionStore.triageFeature` throws `UNIQUE constraint failed: branch_groups.branchName` for a mission whose shared-branch base collides with a branch group another mission already owns. The throw is swallowed by both triage callers, so the mission's `defined` features — including auto-generated **Fix** features from failed validations — are never turned into tasks and the mission silently stops progressing. + +## Symptoms + +- A mission stops advancing; `defined`/Fix features accumulate in active slices and never become tasks. +- No `Fix:` tasks exist and no triage audit event (`mission:stranded-feature-triaged`) is emitted, even across many engine restarts. +- The only trace is in engine **stdout** (never persisted): `Error triaging fix feature …: UNIQUE constraint failed: branch_groups.branchName` and `Failed to triage stranded feature … during reconciliation: …`. +- Triage succeeds for one mission but consistently fails for another — the one whose shared base resolves to a branch name (e.g. `main`) already claimed by the first mission's branch group. + +## What Didn't Work + +- **Reasoning from code alone** suggested `triageFeature` looked robust (the branch-assignment helpers don't obviously throw), which nearly led to dismissing the triage-throw hypothesis. The error sites are also silent (logged, not persisted), so the audit/activity tables showed nothing. +- The breakthrough was **reproducing against a `VACUUM INTO` snapshot of the live mission DB**: instantiating a real `TaskStore`, pulling its `MissionStore`, and calling `triageFeature` on a stuck fix feature surfaced the exact exception and stack immediately. + +## Solution + +`ensureBranchGroupForSource` was only idempotent by `(sourceType, sourceId)`, but `branch_groups.branchName` is globally **UNIQUE**. When the source had no group yet and another source already owned a group with that branch name, `createBranchGroup` violated the unique constraint and threw. + +Reuse an existing open group for the same branch name before creating one (the idiom already used in `register-task-workflow-routes.ts`): + +```ts +// packages/core/src/store.ts — ensureBranchGroupForSource +const existing = this.getBranchGroupBySource(sourceType, sourceId); +if (existing) return existing; + +// branch_groups.branchName is globally UNIQUE — one open group per branch. +// Reuse it instead of colliding on the constraint. +const existingByBranch = this.getBranchGroupByBranchName(init.branchName); +if (existingByBranch) return existingByBranch; + +return this.createBranchGroup({ sourceType, sourceId, ...init }); +``` + +The low-level `createBranchGroup` still enforces uniqueness (unchanged). + +## Why This Works + +The mission had an empty `branchStrategy`, so `missionBranchStrategyDefaults(undefined)` returned `assignmentMode: "shared"`, and the shared base fell through to `settings.defaultBranch = "main"`. Triaging any `defined` feature then called `ensureBranchGroupForSource("mission", missionId, { branchName: "main" })`; a different mission already owned the `"main"` group, so the insert threw. The error escaped `triageFeature` into its two callers — the validation-failure auto-triage (`mission-execution-loop.ts`) and the reconcile sweep (`scheduler.ts`) — both of which catch-and-log without persisting, so features stayed `defined` forever. Reusing the existing open group removes the only failing operation; verified against the live snapshot (`triageFeature` threw before, returned `status: triaged` with a new task after). + +## Prevention + +- **An "ensure"-named helper keyed on one identity can still violate a UNIQUE constraint on a *different* column.** Make idempotency cover every uniqueness dimension the table enforces — here, both `(sourceType, sourceId)` and the unique `branchName`. +- **Swallowed errors in triage/reconcile paths cause silent stalls.** When a catch-and-continue site guards a step that work depends on (triage, validation, advancement), emit a persisted signal (audit event / mission event), not just a stdout log — otherwise the failure is invisible in the DB and impossible to diagnose post-hoc. +- **When a state machine stalls with no error, snapshot the live DB read-only (`VACUUM INTO` / `?mode=ro`) and drive the real code path against it.** Code-reading alone misled this investigation; the exact exception came from reproduction. +- Known limitation / follow-up: this reuses an *open* same-name group; a *closed/finalized* group on the same branch would still hit the UNIQUE constraint (branch-name retirement is a separate, arguably by-design concern). + +## Related Issues + +- `docs/solutions/logic-errors/mission-autopilot-stalled-by-stranded-done-feature.md` — sibling mission-stall learning (PR #1345). Same family: a mission silently wedges and an error/edge in a triage/recovery path is the cause. Both reinforce "swallowed triage-path errors → silent mission stalls." +- PR #1348 — the fix for this bug. diff --git a/docs/solutions/logic-errors/mission-autopilot-stalled-by-stranded-done-feature.md b/docs/solutions/logic-errors/mission-autopilot-stalled-by-stranded-done-feature.md new file mode 100644 index 0000000000..5238346833 --- /dev/null +++ b/docs/solutions/logic-errors/mission-autopilot-stalled-by-stranded-done-feature.md @@ -0,0 +1,124 @@ +--- +title: "Mission autopilot stalls forever on a done+implementing feature with no task" +date: 2026-06-03 +category: docs/solutions/logic-errors +module: "engine/mission-execution-loop + core/mission-store" +problem_type: logic_error +component: background_job +symptoms: + - "A mission silently stops advancing — no error, no crash, just no progress" + - "Autopilot cycles watching to activating to watching indefinitely in mission_events, never advancing the milestone" + - "A slice stays stuck active even though all of its features report status=done" + - "Wedged feature shows the contradictory combo: status=done plus loopState=implementing plus null lastValidatorStatus plus a linked assertion plus no taskId" +root_cause: missing_workflow_step +resolution_type: code_fix +severity: high +related_components: + - "packages/engine/src/mission-execution-loop.ts (recoverActiveMissions, runFeatureValidation)" + - "packages/core/src/mission-store.ts (computeSliceStatus)" +tags: + - mission-system + - autopilot + - recovery + - slice-completion + - assertion-validation + - loop-state +--- + +# Mission autopilot stalls forever on a done+implementing feature with no task + +## Problem + +A mission feature could be left `status="done"` while its `loopState` stayed `"implementing"`, with no linked board task (`taskId`) and never validated (`lastValidatorStatus` null). The slice-completion gate correctly refuses to count an unvalidated, assertion-linked `done` feature, so the slice — and therefore the milestone and the whole mission — could never auto-progress. The mission stalled silently and indefinitely. + +## Symptoms + +- A mission stops advancing entirely — no error, no crash, just no forward motion. +- Autopilot cycles `watching → activating → watching` forever in `mission_events`, never advancing the milestone. +- A slice stays `active` even though every feature in it reports `status="done"`. +- The wedged features carry the contradictory combination: `status="done"` + `loopState="implementing"` + `lastValidatorStatus=null` + at least one linked assertion + no `taskId`. + +## What Didn't Work + +The first hypothesis came from reading code alone: an early `return` in the scheduler — the `reconciliation.kind === "blocked"` branch in `handleMissionTaskMove` — looked like it could swallow the transition before the completion handler ran. Plausible on inspection, but **not** what wedged this mission. + +The real cause only surfaced by inspecting the live per-project DB read-only (`file:.../.fusion/fusion.db?mode=ro`) and looking at the actual stored feature rows. The diagnosis was then confirmed by contrast: an already-**completed** older mission also had many `done`+`implementing` features, but with **zero** assertions — so the gate let them through. That isolated the *assertion gate* as the active ingredient, not the `done`+`implementing` pairing by itself. + +Lesson: reasoning from code alone pointed at the wrong early-return; observed data found the orphan state. + +## Solution + +Two independent, individually-correct facts interlocked into a deadlock: + +1. **The slice gate is strict (by design).** `MissionStore.computeSliceStatus` (`packages/core/src/mission-store.ts:3866-3880`, added by FN-5715) refuses to count an assertion-linked `done` feature toward slice completion unless its validator passed *or* its `loopState` is idle/undefined. +2. **The recovery sweep had a gap.** `MissionExecutionLoop.recoverActiveMissions` only re-drove `implementing` features that still carried a `taskId` (`feature.loopState === "implementing" && feature.taskId`). A task-less stranded `done` feature matched none of the recovery branches (`validating` / `needs_fix` / `implementing && taskId`), so it could never be validated. + +The fix adds a recovery branch for the orphan and extracts the validation path into a shared helper. Validation is a read-only judge (no board task created, no code edited), so it is safe to run directly from the recovery sweep. + +```ts +// packages/engine/src/mission-execution-loop.ts — recoverActiveMissions, +// after the existing implementing+taskId branch +if ( + feature.loopState === "implementing" + && !feature.taskId + && feature.status === "done" + && feature.lastValidatorStatus !== "passed" + && !this.activeValidations.has(feature.id) +) { + const currentFeature = this.missionStore.getFeature(feature.id) ?? feature; + // Live re-check: skip if it has since passed (avoids racing a concurrent pass) + if ( + currentFeature.loopState === "passed" + || currentFeature.lastValidatorStatus === "passed" + ) { + continue; + } + recoveredCount++; + await this.runFeatureValidation(currentFeature); +} +``` + +The validation execution path was lifted out of `processTaskOutcome` into a reusable private method (behavior-preserving for the existing task-completion path): + +```ts +// processTaskOutcome's inline block becomes a single call: +await this.runFeatureValidation(feature); + +// shared helper used by both task-completion and recovery: +private async runFeatureValidation(feature: MissionFeature): Promise { + const assertions = this.missionStore.listAssertionsForFeature(feature.id); + if (assertions.length === 0) { + await this.handleValidationPass(feature.id, undefined, "No assertions linked"); + return; + } + this.activeValidations.add(feature.id); + try { + const run = this.missionStore.startValidatorRun(feature.id, "task_completion"); + const result = await this.runValidation(feature, assertions, run); + // dispatch pass / fail / blocked / error as before + } finally { + this.activeValidations.delete(feature.id); + } +} +``` + +Shipped in PR #1345 (commit `c2604d5`). Tests added in `packages/engine/src/__tests__/mission-execution-loop.test.ts`; full mission-execution-loop suite plus self-healing/validator-reaper suites stayed green. + +## Why This Works + +The mission stalled because the validator never ran → `lastValidatorStatus` stayed null → `computeSliceStatus` never let the slice reach `complete` → the milestone never completed → autopilot looped forever. The gate was right to block; the bug was that nothing ever *satisfied* the gate for a task-less feature. Re-driving validation gives the orphan a terminal validator status either way: on pass it becomes legitimately complete and the slice resolves; on fail the existing fix-feature flow takes over. The live `getFeature` re-check before validating avoids racing a concurrent pass. + +## Prevention + +- **Treat `loopState` as possibly-stale and possibly-contradictory with `status`.** The `done` + non-terminal-`loopState` pairing is an invariant violation worth asserting/reconciling at write time, not just tolerating downstream. Any logic that *gates* on `loopState` inherits this fragility. +- **Recovery/self-healing sweeps keyed on `taskId` must handle the task-less orphan.** Conditions like `loopState === "implementing" && feature.taskId` silently skip any feature missing the key. Enumerate the orphan states explicitly. +- **When two individually-correct rules can interlock into a deadlock** (a strict gate + an incomplete recovery sweep), add an explicit reconciliation path rather than weakening the gate. +- **Diagnostic tip:** when a state machine stalls with no error, inspect the live DB read-only (`?mode=ro`) and read the actual stored values; contrast a wedged instance against a healthy/completed one to isolate the active ingredient. Code-reading alone misdirected this investigation. + +## Related Issues + +- `docs/missions-completion-contract.md` — the canonical FN-5715 completion-gate contract. It already covers (a) zero-assertion features going to `loopState="passed"` and (b) `taskId == null` features being re-triaged, but does **not** yet cover this specific orphan: `done` + `implementing` + no `taskId` + never validated. This learning extends that contract; the invariant belongs folded into its "Slice Status / Autopilot Advance" and "Validator/loop behavior" sections. +- `docs/missions.md:297` — documents stranded-feature (`taskId == null`) reconciliation and the `mission:stranded-feature-triaged` audit event. +- FN-5721 (#1183) — "Implement mission completion gate contract" (FN-5715 enforcement baseline); closest companion issue. +- FN-5901 — "reap stale mission validator runs": the sibling self-healing pattern for stale *validator* runs. This fix is the analogous self-heal for stranded *implementing* features. (session history) +- FN-5902 (in flight as of 2026-06-02) — "make ALL mission validation AI-run; eliminate zero-assertion auto-pass". Touches the same validation pipeline (`mission-execution-loop.ts` auto-pass branch); changing zero-assertion behavior interacts with this gate. (session history) diff --git a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md new file mode 100644 index 0000000000..4aeb77af47 --- /dev/null +++ b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md @@ -0,0 +1,113 @@ +--- +title: Per-task auto-merge override ignored by trigger-layer gates +date: 2026-06-03 +category: logic-errors +module: engine +problem_type: logic_error +component: background_job +symptoms: + - "Tasks with per-task autoMerge:true never auto-merged when global settings.autoMerge was off" + - "Override tasks reached in-review and sat there indefinitely with no error surfaced" + - "In-review self-healing sweeps short-circuited on the global setting and never enqueued the merge" +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - merger + - self-healing + - store +tags: + - auto-merge + - per-task-override + - merge-queue + - self-healing + - engine + - trigger-gate +--- + +# Per-task auto-merge override ignored by trigger-layer gates + +## Problem + +A per-task `autoMerge: true` override was honored only by the merger itself, but every *trigger-layer* gate (engine enqueue, 19 self-healing sweeps, store stall-signal hydration) checked the global `settings.autoMerge` alone. With global auto-merge OFF, override tasks were never enqueued and sat in `in-review` forever. Fixed in PR Runfusion/Fusion#1356. + +## Symptoms + +- User disabled auto-merge globally but enabled it on individual tasks. +- Those individually-enabled tasks reached `in-review` and stayed there indefinitely — never picked up, never merged. +- No error surfaced: the tasks were simply never *triggered* into the merge pipeline, so the merger's per-task handling never ran. + +## What Didn't Work + +- **Assuming the downstream merger check was enough.** The only code consulting `task.autoMerge` was the merger (`packages/engine/src/merger.ts` ~7958: `task.autoMerge === false` → `manual-required`). That runs *after* enqueue. The enqueue gate `allowInReviewMergeProcessing` (`packages/engine/src/project-engine.ts:1386`) and 19 self-healing sweeps short-circuited on `settings.autoMerge` before the task ever reached the merger — so the per-task flag was dead code from the user's perspective. Notably, the feature issues (Runfusion/Fusion#1150, #1152, #1153) shipped the data model, a resolver (`resolveEffectiveAutoMerge`), and the dashboard control — #1152 even claimed engine merge-gating used the resolved value — but no trigger gate actually consulted it. +- **Reaching for `resolveEffectiveAutoMerge` at the gates.** The existing resolver `task.autoMerge ?? settings.autoMerge` (`packages/core/src/task-merge.ts`) looks like the natural gate, but using it would *regress* the global-ON + `autoMerge:false` case: those tasks must still flow into the merger so it can park them as `manual-required` (and so merged-task finalization sweeps still finalize them). Plain resolution would skip them at the trigger, stranding manually-merged tasks in `in-review`. +- **Slim-projection gotcha.** Per-task gating reads `task.autoMerge` off rows from slim task projections. If the `autoMerge` column were missing from `getTaskSelectClause` (`packages/core/src/store.ts` ~1976), the gate would silently see `undefined` and the override would fail with no error. (Verified present — but a real trap when adding per-row predicates.) + +## Solution + +New core predicate, **additive** to the global setting (`packages/core/src/task-merge.ts`): + +```ts +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} +``` + +Applied at three trigger layers: + +1. **Enqueue gate** (`project-engine.ts:1386`), which fronts all four enqueue paths (startup sweep, periodic retry, unpause, task-moved fast path): + + ```ts + // before + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + } + // after + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); + } + ``` + +2. **All 19 self-healing sweeps** (`self-healing.ts`): the function-level early returns (`if (settings.autoMerge === false) return 0;`) were replaced by per-task filtering inside each sweep's candidate set, e.g.: + + ```ts + const candidates = tasks.filter((t) => + t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && + !t.paused && /* ... */); + ``` + +3. **Store stall-signal hydration** (`store.ts`, 6 sites): `autoMerge: settings.autoMerge` → `autoMerge: allowsAutoMergeProcessing(task, settings)` in the `getInReviewStallReason` / `getInReviewStalledSignal` contexts, so board diagnostics reflect that override tasks *are* being processed. + +The self-healing contract also changed: from "skip the whole sweep when global is off" to "list tasks, but mutate nothing without a per-task override." FN-5147 tests that asserted `listTasks` was never called were updated to assert the mutation-free guarantee instead. This extends — and stays consistent with — the AGENTS.md `autoMerge: false` callout (FN-5147): self-healing still never moves override-less `in-review` tasks when auto-merge is off. + +## Why This Works + +The root cause was a flag consulted only where the *action* runs, not where processing is *triggered*. Adding the override evaluation to every trigger gate closes the gap. + +Additive (`settings.autoMerge !== false || task.autoMerge === true`) is deliberately chosen over resolution (`task.autoMerge ?? settings.autoMerge`): + +- **Global ON:** `settings.autoMerge !== false` is already `true`, so the predicate is a no-op — every task flows through exactly as before, including `autoMerge:false` tasks that the merger then parks as `manual-required`. Resolution would have excluded those, breaking manual-required parking and finalization. +- **Global OFF:** the first term is `false`, so only `task.autoMerge === true` tasks proceed — exactly the missing override path. + +It changes nothing when global is ON and adds only the explicit-true path when global is OFF. + +## Prevention + +When adding a per-entity override to a behavior that's gated on a global setting, the override must be consulted **where the behavior is TRIGGERED, not just where the action runs.** A check at the merger (the action) is invisible if upstream enqueue/sweep gates already filtered the entity out. + +- **Grep every gate on the global setting** before declaring the override wired: here `settings.autoMerge` appeared at 1 enqueue gate, 19 sweep guards, and 6 hydration sites — all needed updating. A search for the global key, not just the new override field, surfaces the dead-flag sites. +- **Prefer additive gating over effective-value resolution for *processing* gates.** Resolution collapses three states (global-on/off × per-task true/false/unset) into one boolean and can starve a needed downstream branch (the manual-required parking path). Gate on "should this be processed at all," resolve the actual behavior later. +- **Check existing regression contracts before re-scoping a gate.** Review of the fix PR suggested exempting `todo`/`in-progress` candidates (execution-stage repair) from the auto-merge gate — but the repo's FN-5704 regression test ("short-circuits reclaim when autoMerge is false") deliberately keeps execution-stage reclaim inert in manual-review projects. Per-task gating applied uniformly preserves that contract while enabling overrides; exempting execution-stage recovery would be a separate, deliberate behavior change. +- **Watch slim projections:** per-row predicates require the override column in the SELECT clause, or they silently read `undefined`. +- **Test matrix must cross global × per-task.** The fix shipped red-first unit tests for the predicate (`packages/core/src/__tests__/task-merge.test.ts`), the gate including the shared-group exemption (`packages/engine/src/__tests__/project-engine.test.ts`), and a self-healing test proving an **override task is processed while an override-less sibling stays skipped** (`packages/engine/src/__tests__/self-healing.test.ts`) — the latter is the canonical shape: two tasks differing only in `autoMerge` under global-OFF, asserting divergent outcomes. + +## Related Issues + +- Runfusion/Fusion#1356 — the fix PR +- Runfusion/Fusion#1150, Runfusion/Fusion#1152, Runfusion/Fusion#1153 — the per-task auto-merge feature trio (data model + resolver, engine gating, dashboard control); #1152's gating claim is the gap this bug exposed +- Runfusion/Fusion#753 (FN-5147), Runfusion/Fusion#690 (FN-5052) — prior global `autoMerge:false` stall/lifecycle handling that the sweeps' guards came from +- AGENTS.md → "`autoMerge: false` callout (FN-5147)" — standing lifecycle rule this fix extends to per-task granularity diff --git a/docs/storage.md b/docs/storage.md index 492fadc239..55e98d9700 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -22,6 +22,14 @@ - Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) is unchanged in spirit: archive payloads still embed a capped agent-log snapshot, now sourced from the JSONL file instead of `fusion.db`. - Retention is now independent from SQLite operational-log pruning. `settings.agentLogFileRetentionDays` controls age-based pruning of JSONL entries for soft-deleted and archived tasks only. Default: `0` (disabled). +### Activity-log no-op `task:moved` cleanup (FN-5940) + +- `TaskStore` now defends the invariant that `activityLog` never records a `task:moved` row when `metadata.from === metadata.to`. +- Defense is layered: the `task:moved` listener skips same-column transitions, and source emitters skip no-op `archived -> archived` / same-column polling re-emits before subscribers see them. +- Existing junk rows are removed by a one-time init migration guarded by `__meta.noOpTaskMovedActivityCleanupVersion = "1"`. +- The cleanup deletes only rows matching `type = 'task:moved'` where `json_extract(metadata, '$.from') = json_extract(metadata, '$.to')`; legitimate distinct-column moves are preserved. +- The migration does **not** run `VACUUM` automatically. After the delete lands on a large disk-backed DB, run `fn db --vacuum` manually to reclaim the freed space from the SQLite file. + ### Dashboard delete-event handling (FN-5135) - Dashboard clients treat any SSE payload with `deletedAt != null` (`task:created`, `task:updated`, `task:moved`, `task:merged`) as a delete-equivalent and remove/suppress that task locally. diff --git a/docs/task-management.md b/docs/task-management.md index d459f5c983..fd81282534 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -688,6 +688,7 @@ Manual/non-auto-merge behavior: - `Finish & Close` (PR already merged) - Manual PR creation first checks for an existing PR on that branch and links it when found. - If no PR exists, Fusion pushes the task branch to `origin` before creating the PR. +- In the dashboard Create-PR modal, if preflight detects merge conflicts with the selected base branch, you can choose **Resolve conflicts with AI**. Fusion resolves the task branch in-place, commits the result, pushes the updated branch to `origin`, and then lets you retry PR creation. - When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded. ## GitHub Tracking Issues diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 2b4f7fd380..36261b1058 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -2601,6 +2601,51 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(task.retrySummary?.total ?? 0).toBe(0); }; + it("clears the deadlock auto-pause for execution-failed in-review retries", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + + const task = await store.createTask({ + title: "deadlock-paused execution-failed task", + description: "test", + column: "todo", + }); + await store.updateTask(task.id, { + steps: [ + { name: "Step 0", status: "done" }, + { name: "Step 1", status: "in-progress" }, + { name: "Step 2", status: "pending" }, + ], + }); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.updateTask(task.id, { + status: "failed", + error: "executor stalled after deadlock pause", + paused: true, + pausedReason: "in-review-stall-deadlock", + mergeRetries: 0, + nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(), + ...nonZeroRetryCounters, + }); + + const retryTool = api.tools.get("fn_task_retry")!; + const result = await retryTool.execute("retry-deadlock-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); + + expect(result.isError).toBeFalsy(); + expect(result.details.newColumn).toBe("todo"); + + const updated = await store.getTask(task.id); + expect(updated?.column).toBe("todo"); + expect(updated?.status).toBeFalsy(); + expect(updated?.error).toBeFalsy(); + expect(updated?.paused).toBeUndefined(); + expect(updated?.pausedReason).toBeUndefined(); + expect(updated?.steps[1].status).toBe("in-progress"); + expectRetryCountersReset(updated); + expect(updated?.mergeRetries).toBe(0); + }); + it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => { const store = new TaskStore(tmpDir); await store.init(); @@ -2671,6 +2716,87 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(updated?.mergeRetries).toBe(0); }); + it("clears the deadlock auto-pause for merge-failed in-review retries", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + + const task = await store.createTask({ + title: "deadlock-paused merge-failed task", + description: "test", + column: "todo", + }); + await store.updateTask(task.id, { + steps: [ + { name: "Step 0", status: "done" }, + { name: "Step 1", status: "done" }, + ], + }); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.updateTask(task.id, { + status: "failed", + error: "merge deadlock", + paused: true, + pausedReason: "in-review-stall-deadlock", + mergeRetries: 3, + nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(), + ...nonZeroRetryCounters, + }); + + const retryTool = api.tools.get("fn_task_retry")!; + const result = await retryTool.execute("retry-deadlock-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); + + expect(result.isError).toBeFalsy(); + expect(result.details.newColumn).toBe("in-review"); + + const updated = await store.getTask(task.id); + expect(updated?.column).toBe("in-review"); + expect(updated?.status).toBeFalsy(); + expect(updated?.error).toBeFalsy(); + expect(updated?.paused).toBeUndefined(); + expect(updated?.pausedReason).toBeUndefined(); + expectRetryCountersReset(updated); + expect(updated?.mergeRetries).toBe(0); + }); + + it("does not clear manual pauses for merge-failed in-review retries", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + + const task = await store.createTask({ + title: "user-paused merge-failed task", + description: "test", + column: "todo", + }); + await store.updateTask(task.id, { + steps: [ + { name: "Step 0", status: "done" }, + { name: "Step 1", status: "done" }, + ], + }); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.updateTask(task.id, { + status: "failed", + error: "merge deadlock", + paused: true, + pausedReason: "manual", + mergeRetries: 3, + }); + + const retryTool = api.tools.get("fn_task_retry")!; + const result = await retryTool.execute("retry-user-paused-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir)); + + expect(result.isError).toBeFalsy(); + expect(result.details.newColumn).toBe("in-review"); + + const updated = await store.getTask(task.id); + expect(updated?.paused).toBe(true); + expect(updated?.pausedReason).toBe("manual"); + expect(updated?.status).toBeFalsy(); + expect(updated?.mergeRetries).toBe(0); + }); + it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => { const store = new TaskStore(tmpDir); await store.init(); diff --git a/packages/cli/src/__tests__/task-retry.test.ts b/packages/cli/src/__tests__/task-retry.test.ts new file mode 100644 index 0000000000..8e18a28d65 --- /dev/null +++ b/packages/cli/src/__tests__/task-retry.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { runTaskRetry } from "../commands/task.js"; + +describe("runTaskRetry", () => { + const originalCwd = process.cwd(); + let tmpDir: string; + let consoleLogSpy: ReturnType; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "fusion-task-retry-")); + process.chdir(tmpDir); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + consoleLogSpy.mockRestore(); + process.chdir(originalCwd); + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function createStore() { + const store = new TaskStore(tmpDir); + await store.init(); + return store; + } + + it("clears the deadlock auto-pause when retrying a failed task", async () => { + const store = await createStore(); + const task = await store.createTask({ + title: "deadlock-paused task", + description: "test", + column: "todo", + }); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.updateTask(task.id, { + status: "failed", + error: "merge deadlock", + paused: true, + pausedReason: "in-review-stall-deadlock", + mergeRetries: 4, + }); + + await runTaskRetry(task.id); + + const updated = await store.getTask(task.id); + expect(updated.column).toBe("todo"); + expect(updated.status).toBeUndefined(); + expect(updated.error).toBeUndefined(); + expect(updated.paused).toBeUndefined(); + expect(updated.pausedReason).toBeUndefined(); + expect(updated.mergeRetries).toBe(0); + }); + +}); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/available-memory.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/available-memory.test.ts new file mode 100644 index 0000000000..1dd934f926 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/available-memory.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import os from "node:os"; +import { getAvailableMemoryInfo } from "../controller.js"; + +type ProcessWithAvailableMemory = NodeJS.Process & { availableMemory?: () => number }; + +describe("getAvailableMemoryInfo", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports a reliable reading from process.availableMemory when present", () => { + const proc = process as ProcessWithAvailableMemory; + if (typeof proc.availableMemory !== "function") { + // Older runtime without the API — covered by the fallback test below. + return; + } + const spy = vi.spyOn(proc, "availableMemory").mockReturnValue(123_456_789); + + expect(getAvailableMemoryInfo()).toEqual({ bytes: 123_456_789, reliable: true }); + expect(spy).toHaveBeenCalled(); + }); + + it("falls back to os.freemem and flags the reading unreliable when the API is missing", () => { + const proc = process as ProcessWithAvailableMemory; + const original = proc.availableMemory; + // Simulate a runtime without process.availableMemory (Node < 22). The + // freemem fallback must be flagged unreliable: on macOS freemem reads + // ~99% used on an idle machine, and treating it as a pressure signal made + // the vitest auto-kill fire every 30s (2026-06-03 incident). + Reflect.deleteProperty(proc, "availableMemory"); + const freememSpy = vi.spyOn(os, "freemem").mockReturnValue(42); + try { + expect(getAvailableMemoryInfo()).toEqual({ bytes: 42, reliable: false }); + } finally { + if (original) proc.availableMemory = original; + freememSpy.mockRestore(); + } + }); + + it("falls back unreliable when process.availableMemory throws", () => { + const proc = process as ProcessWithAvailableMemory; + if (typeof proc.availableMemory !== "function") return; + vi.spyOn(proc, "availableMemory").mockImplementation(() => { + throw new Error("not supported"); + }); + const freememSpy = vi.spyOn(os, "freemem").mockReturnValue(7); + try { + expect(getAvailableMemoryInfo()).toEqual({ bytes: 7, reliable: false }); + } finally { + freememSpy.mockRestore(); + } + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/controller.ts b/packages/cli/src/commands/dashboard-tui/controller.ts index acd812f4a6..f4862db202 100644 --- a/packages/cli/src/commands/dashboard-tui/controller.ts +++ b/packages/cli/src/commands/dashboard-tui/controller.ts @@ -1,24 +1,39 @@ import os from "node:os"; import v8 from "node:v8"; -import { execFile } from "node:child_process"; import { appendFileSync } from "node:fs"; +import { findVitestProcessIds } from "@fusion/core"; // `os.freemem()` on macOS only counts truly-free pages and excludes the large // "inactive"/cached pool that the OS will reclaim on demand — so total-free -// reads ~95%+ used on an otherwise-idle machine. `os.availableMemory()` (Node -// 22+) reports memory the OS considers available, matching Activity Monitor's -// notion of "used". Fall back to freemem on older runtimes. -function getAvailableMemory(): number { - const fn = (os as unknown as { availableMemory?: () => number }).availableMemory; - if (typeof fn === "function") { +// reads ~95%+ used on an otherwise-idle machine. `process.availableMemory()` +// (Node 22+ — NOT `os.availableMemory`, which does not exist and silently +// fell through to the freemem trap this function was written to avoid) +// reports memory the OS considers available, matching Activity Monitor's +// notion of "used". The freemem fallback is flagged unreliable so pressure- +// triggered actions can refuse to fire on a garbage ratio: with freemem, an +// idle 256GB Mac reads ~99% used and the vitest auto-kill fired every 30s +// regardless of real pressure (2026-06-03 incident). +interface AvailableMemoryReading { + bytes: number; + /** False when only `os.freemem()` was available — unusable as a pressure signal. */ + reliable: boolean; +} + +export function getAvailableMemoryInfo(): AvailableMemoryReading { + const processFn = (process as unknown as { availableMemory?: () => number }).availableMemory; + if (typeof processFn === "function") { try { - const v = fn.call(os); - if (Number.isFinite(v) && v >= 0) return v; + const v = processFn.call(process); + if (Number.isFinite(v) && v >= 0) return { bytes: v, reliable: true }; } catch { // fall through } } - return os.freemem(); + return { bytes: os.freemem(), reliable: false }; +} + +function getAvailableMemory(): number { + return getAvailableMemoryInfo().bytes; } const TUI_DEBUG_LOG = process.env.FUSION_TUI_DEBUG_LOG; @@ -299,8 +314,10 @@ export class DashboardTUI { if (this.autoKillVitestOnPressure) { const total = os.totalmem(); - const free = getAvailableMemory(); - if (total > 0) { + const { bytes: free, reliable } = getAvailableMemoryInfo(); + // Without a reliable availability reading the ratio is garbage (freemem + // on macOS ≈ always >90% used) — never SIGKILL on a garbage signal. + if (total > 0 && reliable) { const usedRatio = (total - free) / total; // 30s minimum gap between auto-kills — vitest restart and OS reclaim // both take a few seconds; firing every 2s would flap. @@ -325,24 +342,13 @@ export class DashboardTUI { * gone by the time we send the signal). */ async killVitestProcesses(): Promise<{ killed: number; pids: number[] }> { - // pgrep is POSIX-only; Windows path is a no-op above. - if (process.platform === "win32") { - return { killed: 0, pids: [] }; - } - const selfPid = process.pid; - // execFile (not execSync) so the TUI render loop stays responsive while - // pgrep walks the process table — that walk can take 100ms+ on a busy - // machine and previously froze the UI on every memory-pressure check. - const stdout: string = await new Promise((resolve) => { - execFile("pgrep", ["-f", "vitest"], { encoding: "utf8" }, (err, out) => { - // pgrep exits non-zero when no matches — treat as empty result. - resolve(err ? "" : (typeof out === "string" ? out : "")); - }); - }); - const pids = stdout - .split("\n") - .map((s) => Number.parseInt(s.trim(), 10)) - .filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid); + // findVitestProcessIds is pgrep-based (POSIX-only; no-op on Windows) and + // uses async execFile so the TUI render loop stays responsive while the + // process table is walked. Crucially it filters matches to actual node + // processes: a bare `pgrep -f vitest` also matches wrapper shells whose + // command line mentions vitest, monitors, and editors — SIGKILLing those + // took out unrelated process trees (2026-06-03 incident). + const pids = await findVitestProcessIds(); let killed = 0; for (const pid of pids) { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 248c1b347f..79db647e37 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { aiMergeTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -1039,6 +1039,9 @@ export async function runTaskRetry(id: string, projectName?: string) { throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`); } + const autoPauseClearPatch = buildAutoPauseClearPatch(task); + const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0; + // Clear failure state and stale branch refs so retry can choose a fresh base. await store.updateTask(id, { status: null, @@ -1047,6 +1050,7 @@ export async function runTaskRetry(id: string, projectName?: string) { branch: null, baseBranch: null, baseCommitSha: null, + ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); @@ -1054,7 +1058,11 @@ export async function runTaskRetry(id: string, projectName?: string) { await store.moveTask(id, 'todo'); // Log the retry action - await store.logEntry(id, "Retry requested from CLI", "Task reset to todo for retry"); + await store.logEntry( + id, + clearedDeadlockAutoPause ? "Retry requested from CLI (cleared deadlock auto-pause)" : "Retry requested from CLI", + "Task reset to todo for retry", + ); console.log(); console.log(` ✓ Retried ${id} → todo (failure state cleared)`); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7e5f5476b7..7c801bd5ac 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -5,6 +5,7 @@ import { TaskStore, COLUMNS, COLUMN_LABELS, + buildAutoPauseClearPatch, buildManualRetryResetPatch, validateNodeOverrideChange, type Task, @@ -990,6 +991,10 @@ export default function kbExtension(pi: ExtensionAPI) { }; } + const autoPauseClearPatch = buildAutoPauseClearPatch(task); + const clearedDeadlockAutoPause = Object.keys(autoPauseClearPatch).length > 0; + const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : ""; + // In-review retry: distinguish between execution failures and merge failures. if (task.column === 'in-review') { const hasIncompleteSteps = task.steps.some( @@ -1004,9 +1009,10 @@ export default function kbExtension(pi: ExtensionAPI) { await store.updateTask(params.id, { status: null, error: null, + ...autoPauseClearPatch, ...buildManualRetryResetPatch(), }); - await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)"); + await store.logEntry(params.id, `Retry requested via Fusion extension (execution failure in-review → todo, preserving progress${retryLogSuffix})`); await store.moveTask(params.id, "todo", { preserveProgress: true }); return { content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }], @@ -1017,9 +1023,10 @@ export default function kbExtension(pi: ExtensionAPI) { await store.updateTask(params.id, { status: null, error: null, + ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); - await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)"); + await store.logEntry(params.id, `Retry requested via Fusion extension (in-review merge retry, mergeRetries reset${retryLogSuffix})`); return { content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }], details: { taskId: params.id, newColumn: 'in-review' }, @@ -1030,6 +1037,7 @@ export default function kbExtension(pi: ExtensionAPI) { await store.updateTask(params.id, { status: null, error: null, + ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index 9b6dbc0de2..90887da9af 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -44,6 +44,7 @@ const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime"; const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap"; const REPORTS_PLUGIN_ID = "fusion-plugin-reports"; const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press"; +const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering"; function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) { return { @@ -315,6 +316,10 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => { it("includes reports plugin in bundled plugin ids", () => { expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID); }); + + it("includes compound engineering plugin in bundled plugin ids", () => { + expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID); + }); it("fresh install: registers and loads the plugin when not in DB", async () => { setupBundleExists(); const store = makePluginStore(); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 506235bc9f..7ddf24a102 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -17,6 +17,7 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-cli-printing-press", + "fusion-plugin-compound-engineering", ] as const; export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number]; diff --git a/packages/core/src/__tests__/activity-log-no-op-moved.test.ts b/packages/core/src/__tests__/activity-log-no-op-moved.test.ts new file mode 100644 index 0000000000..81eb11f0fc --- /dev/null +++ b/packages/core/src/__tests__/activity-log-no-op-moved.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rm } from "node:fs/promises"; + +import { TaskStore } from "../store.js"; +import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; + +describe("activity log task:moved no-op guard", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("does not record same-column task:moved emits and still records distinct moves", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + (store as any).emit("task:moved", { task, from: "archived", to: "archived", source: "engine" }); + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([]); + + (store as any).emit("task:moved", { task, from: "triage", to: "todo", source: "engine" }); + + const activity = await store.getActivityLog({ type: "task:moved" }); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + type: "task:moved", + taskId: task.id, + metadata: { from: "triage", to: "todo" }, + }); + }); + + it("does not record activity for same-column moveTask calls", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.moveTask(task.id, "triage"); + + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([]); + }); + + it("records legitimate moveTask transitions exactly once", async () => { + const store = harness.store(); + const task = await harness.createTestTask(); + + await store.moveTask(task.id, "todo"); + + expect(await store.getActivityLog({ type: "task:moved" })).toEqual([ + expect.objectContaining({ + taskId: task.id, + metadata: { from: "triage", to: "todo" }, + }), + ]); + }); + + it("does not emit or record archived-to-archived polling replication no-ops", async () => { + const rootDir = makeTmpDir(); + const globalDir = makeTmpDir(); + const writer = new TaskStore(rootDir, globalDir); + const observer = new TaskStore(rootDir, globalDir); + + try { + await writer.init(); + await observer.init(); + + const task = await writer.createTask({ column: "done", description: "archive me" }); + const archived = await writer.archiveTask(task.id, false); + const movedEvents: Array<{ from: string; to: string }> = []; + observer.on("task:moved", ({ from, to }) => movedEvents.push({ from, to })); + (observer as any).taskCache.set(archived.id, { ...archived }); + (observer as any).lastKnownModified = 0; + + await (observer as any).checkForChanges(); + + expect(movedEvents).toEqual([]); + expect(await observer.getActivityLog({ type: "task:moved" })).toEqual([ + expect.objectContaining({ + taskId: task.id, + metadata: { from: "done", to: "archived" }, + }), + ]); + } finally { + writer.close(); + observer.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it("does not emit or record same-column polling observations", async () => { + const rootDir = makeTmpDir(); + const globalDir = makeTmpDir(); + const writer = new TaskStore(rootDir, globalDir); + const observer = new TaskStore(rootDir, globalDir); + + try { + await writer.init(); + await observer.init(); + + const task = await writer.createTask({ column: "todo", description: "same-column poll" }); + const movedEvents: Array<{ from: string; to: string }> = []; + observer.on("task:moved", ({ from, to }) => movedEvents.push({ from, to })); + (observer as any).taskCache.set(task.id, { ...task }); + (observer as any).lastKnownModified = 0; + + await writer.updateTask(task.id, { title: "still todo" }); + await (observer as any).checkForChanges(); + + expect(movedEvents).toEqual([]); + expect(await observer.getActivityLog({ type: "task:moved" })).toEqual([]); + } finally { + writer.close(); + observer.close(); + await rm(rootDir, { 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__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index 2779985922..74c8405b16 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -67,6 +67,34 @@ describe("TaskStore branch groups", () => { expect(second.autoMerge).toBe(true); }); + it("reuses an existing open group with the same branchName across sources instead of throwing", () => { + // Regression: branch_groups.branchName is globally UNIQUE. When one mission + // already owns an open group for a shared base branch, a second source whose + // triage resolves to the same branch must reuse that group rather than crash + // on the UNIQUE constraint. (Mission triage discards the result and only needs + // it not to throw; a thrown error there silently strands "defined" features.) + const owner = store.createBranchGroup({ sourceType: "mission", sourceId: "M-OWNER", branchName: "main" }); + + let reusedByMission!: ReturnType; + expect(() => { + reusedByMission = store.ensureBranchGroupForSource("mission", "M-OTHER", { + branchName: "main", + autoMerge: true, + }); + }).not.toThrow(); + expect(reusedByMission.id).toBe(owner.id); + + // Invariant holds across the other source types that share this helper. + const reusedByNewTask = store.ensureBranchGroupForSource("new-task", "shared/main", { branchName: "main" }); + expect(reusedByNewTask.id).toBe(owner.id); + + const reusedByPlanning = store.ensureBranchGroupForSource("planning", "PS-main", { branchName: "main" }); + expect(reusedByPlanning.id).toBe(owner.id); + + // No duplicate rows were created for the shared branch. + expect(store.listBranchGroups().filter((g) => g.branchName === "main")).toHaveLength(1); + }); + it("supports new-task branch group sources and round-trips through lookups", () => { const group = store.ensureBranchGroupForSource("new-task", "shared/onboarding", { branchName: "shared/onboarding", diff --git a/packages/core/src/__tests__/interactive-ai-session-seam.test.ts b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts new file mode 100644 index 0000000000..3fe017a9b5 --- /dev/null +++ b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCreateInteractiveAiSessionFactory, + setCreateInteractiveAiSessionFactory, +} from "../ai-engine-loader.js"; +import { PluginLoader } from "../plugin-loader.js"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSession, + InteractiveAiSessionEvent, +} from "../plugin-types.js"; +import type { PlanningQuestion } from "../types.js"; + +/** + * A scripted fake interactive session: drives question → answer → complete + * deterministically so the route-context seam can be integration-tested + * without a live engine/model. + */ +function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession { + let cursor = -1; + return { + prompt: vi.fn(async () => { + cursor++; + }), + answer: vi.fn(async () => { + cursor++; + }), + nextEvent: vi.fn(async () => script[Math.min(cursor, script.length - 1)]), + dispose: vi.fn(), + } as InteractiveAiSession; +} + +afterEach(() => { + setCreateInteractiveAiSessionFactory(undefined); +}); + +describe("ai-engine-loader: interactive factory DI", () => { + it("returns undefined before registration", async () => { + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); + + it("stores, returns, and clears the factory", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBe(factory); + setCreateInteractiveAiSessionFactory(undefined); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); +}); + +describe("interactive session injection boundary", () => { + function makeLoader() { + const pluginStore = { + getPlugin: vi.fn().mockResolvedValue({ settings: {} }), + } as never; + const taskStore = { getRootDir: () => "/tmp" } as never; + return new PluginLoader({ pluginStore, taskStore }); + } + + it("route context exposes createInteractiveAiSession when engine registered it; absent otherwise", async () => { + const loader = makeLoader(); + + // Not registered → undefined on route context. + const before = await loader.createRouteContext("fusion-plugin-x"); + expect(before.createInteractiveAiSession).toBeUndefined(); + + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + + const after = await loader.createRouteContext("fusion-plugin-x"); + expect(after.createInteractiveAiSession).toBe(factory); + }); + + it("drives a full question → answer → complete round trip from a route context", async () => { + const question: PlanningQuestion = { id: "q1", type: "single_select", question: "Pick", options: [{ id: "a", label: "A" }] }; + const session = makeScriptedSession([ + { type: "question", data: question }, + { type: "complete", data: { title: "ok" } }, + ]); + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ session, sessionFile: "/tmp/s.json" })); + setCreateInteractiveAiSessionFactory(factory); + + const loader = makeLoader(); + const ctx = await loader.createRouteContext("fusion-plugin-x"); + expect(ctx.createInteractiveAiSession).toBeDefined(); + + const { session: s } = await ctx.createInteractiveAiSession!({ cwd: "/tmp", systemPrompt: "protocol" }); + + await s.prompt("start"); + const ev1 = await s.nextEvent(); + expect(ev1.type).toBe("question"); + expect(ev1.type === "question" && ev1.data.id).toBe("q1"); + + await s.answer("q1", "a"); + const ev2 = await s.nextEvent(); + expect(ev2.type).toBe("complete"); + + s.dispose(); + expect(s.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/__tests__/manual-retry-reset.test.ts b/packages/core/src/__tests__/manual-retry-reset.test.ts index 46e455e78a..5b6a249fb1 100644 --- a/packages/core/src/__tests__/manual-retry-reset.test.ts +++ b/packages/core/src/__tests__/manual-retry-reset.test.ts @@ -1,9 +1,51 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { buildManualRetryResetPatch, MANUAL_RETRY_RESET_COUNTER_KEYS } from "../manual-retry-reset.js"; +import { + IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON, + MANUAL_RETRY_RESET_COUNTER_KEYS, + buildAutoPauseClearPatch, + buildManualRetryResetPatch, +} from "../manual-retry-reset.js"; const RETRY_SUMMARY_COUNTER_REGEX = /toCount\(task\.(\w+)\)/g; +describe("buildAutoPauseClearPatch", () => { + it("clears the deadlock auto-pause for auto-paused tasks", () => { + expect(buildAutoPauseClearPatch({ + paused: true, + userPaused: undefined, + pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON, + })).toEqual({ + paused: false, + pausedReason: null, + }); + }); + + it("does not clear an explicit user pause", () => { + expect(buildAutoPauseClearPatch({ + paused: true, + userPaused: true, + pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON, + })).toEqual({}); + }); + + it("does not clear unrelated automatic pause reasons", () => { + expect(buildAutoPauseClearPatch({ + paused: true, + userPaused: undefined, + pausedReason: "branch-conflict-unrecoverable", + })).toEqual({}); + }); + + it("is a no-op when the task is not paused", () => { + expect(buildAutoPauseClearPatch({ + paused: undefined, + userPaused: undefined, + pausedReason: IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON, + })).toEqual({}); + }); +}); + describe("buildManualRetryResetPatch", () => { it("resets all manual retry counters to zero", () => { const patch = buildManualRetryResetPatch(); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 6bafd91275..53b719616e 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1940,6 +1940,129 @@ describe("MissionStore", () => { }); }); + describe("task goal provenance", () => { + async function createStoreWithTaskStore() { + const { TaskStore } = await import("../store.js"); + const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true }); + return { ts, ms: ts.getMissionStore(), goals: ts.getGoalStore() }; + } + + it("returns empty arrays for unknown and unlinked tasks", async () => { + const { ts, ms } = await createStoreWithTaskStore(); + const task = await ts.createTask({ title: "Standalone task", description: "No mission link" }); + + expect(ms.listGoalIdsForTask("FN-DOES-NOT-EXIST")).toEqual([]); + expect(ms.listGoalsForTask("FN-DOES-NOT-EXIST")).toEqual([]); + expect(ms.listGoalIdsForTask(task.id)).toEqual([]); + expect(ms.listGoalsForTask(task.id)).toEqual([]); + }); + + it("returns an empty array for mission-linked tasks when the mission has no goals", async () => { + const { ts, ms } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([]); + expect(ms.listGoalsForTask(task.id)).toEqual([]); + }); + + it("preserves stable ordering for multiple linked goals and matches hierarchy mapping", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goalA = goals.createGoal({ title: "Goal A" }); + const goalB = goals.createGoal({ title: "Goal B" }); + + ms.linkGoal(mission.id, goalA.id); + ms.linkGoal(mission.id, goalB.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goalA.id, goalB.id]); + expect(ms.listGoalsForTask(task.id)).toEqual(ms.getMissionWithHierarchy(mission.id)?.linkedGoals ?? []); + }); + + it("keeps archived linked goals in task provenance", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goal = goals.createGoal({ title: "Archived goal" }); + ms.linkGoal(mission.id, goal.id); + const archivedGoal = goals.archiveGoal(goal.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([archivedGoal]); + }); + + it("falls back through feature linkage when tasks.missionId is unset", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const mission = ms.createMission({ title: "Mission" }); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const goal = goals.createGoal({ title: "Fallback goal" }); + ms.linkGoal(mission.id, goal.id); + + const task = await ts.createTask({ title: "Task", description: "Linked task" }); + ms.linkFeatureToTask(feature.id, task.id); + db.prepare("UPDATE tasks SET missionId = NULL WHERE id = ?").run(task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([goal]); + }); + + it("resolves provenance for triaged tasks without storing goal ids on the task row", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const goal = goals.createGoal({ title: "Goal title" }); + const mission = ms.createMission({ title: "Mission" }); + ms.linkGoal(mission.id, goal.id); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature", description: "Desc" }); + + const triaged = await ms.triageFeature(feature.id); + const task = await ts.getTask(triaged.taskId!); + + expect(ms.listGoalsForTask(triaged.taskId!)).toEqual([ + expect.objectContaining({ id: goal.id, title: goal.title }), + ]); + expect(task?.missionId).toBe(mission.id); + expect(task).not.toHaveProperty("goalId"); + expect(task).not.toHaveProperty("goalIds"); + }); + + it("resolves provenance identically for manual feature linkage", async () => { + const { ts, ms, goals } = await createStoreWithTaskStore(); + const goal = goals.createGoal({ title: "Manual goal" }); + const mission = ms.createMission({ title: "Mission" }); + ms.linkGoal(mission.id, goal.id); + const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); + const slice = ms.addSlice(milestone.id, { title: "Slice" }); + const feature = ms.addFeature(slice.id, { title: "Feature" }); + const task = await ts.createTask({ title: "Manual task", description: "Manual" }); + + ms.linkFeatureToTask(feature.id, task.id); + + expect(ms.listGoalIdsForTask(task.id)).toEqual([goal.id]); + expect(ms.listGoalsForTask(task.id)).toEqual([ + expect.objectContaining({ id: goal.id, title: goal.title }), + ]); + }); + }); + // ── Transaction Tests ──────────────────────────────────────────────── describe("Transaction Handling", () => { diff --git a/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts b/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts new file mode 100644 index 0000000000..d8cca1d6f3 --- /dev/null +++ b/packages/core/src/__tests__/no-op-moved-cleanup-migration.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("no-op task:moved activity cleanup migration", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + await harness.reopenDiskBackedStore(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + it("deletes only no-op task:moved rows once and leaves later rows untouched", async () => { + const store = harness.store(); + const db = store.getDatabase(); + const task = await harness.createTestTask(); + const insert = db.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + + insert.run( + "noop-1", + "2026-06-03T00:00:01.000Z", + "task:moved", + task.id, + task.title ?? null, + "noop archived", + JSON.stringify({ from: "archived", to: "archived" }), + ); + insert.run( + "noop-2", + "2026-06-03T00:00:02.000Z", + "task:moved", + task.id, + task.title ?? null, + "noop todo", + JSON.stringify({ from: "todo", to: "todo" }), + ); + insert.run( + "move-1", + "2026-06-03T00:00:03.000Z", + "task:moved", + task.id, + task.title ?? null, + "real move", + JSON.stringify({ from: "triage", to: "todo" }), + ); + insert.run( + "created-1", + "2026-06-03T00:00:04.000Z", + "task:created", + task.id, + task.title ?? null, + "created", + null, + ); + db.prepare("DELETE FROM __meta WHERE key = ?").run("noOpTaskMovedActivityCleanupVersion"); + + await harness.reopenDiskBackedStore(); + + const migratedDb = harness.store().getDatabase(); + const movedRows = migratedDb.prepare( + "SELECT id, metadata FROM activityLog WHERE type = 'task:moved' ORDER BY id", + ).all() as Array<{ id: string; metadata: string | null }>; + const migrationRow = migratedDb + .prepare("SELECT value FROM __meta WHERE key = ?") + .get("noOpTaskMovedActivityCleanupVersion") as { value: string } | undefined; + + expect(movedRows).toEqual([ + { + id: "move-1", + metadata: JSON.stringify({ from: "triage", to: "todo" }), + }, + ]); + const createdRows = migratedDb.prepare( + "SELECT id FROM activityLog WHERE type = 'task:created' ORDER BY id", + ).all() as Array<{ id: string }>; + expect(createdRows.map((row) => row.id)).toContain("created-1"); + expect(migrationRow?.value).toBe("1"); + + migratedDb.prepare("DELETE FROM activityLog WHERE id = ?").run("move-1"); + migratedDb.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, 'task:moved', ?, ?, ?, ?)`, + ).run( + "noop-after", + "2026-06-03T00:00:05.000Z", + task.id, + task.title ?? null, + "post-migration noop", + JSON.stringify({ from: "archived", to: "archived" }), + ); + + await harness.reopenDiskBackedStore(); + + const reopenedDb = harness.store().getDatabase(); + const postReopenRows = reopenedDb.prepare( + "SELECT id FROM activityLog WHERE type = 'task:moved' ORDER BY id", + ).all() as Array<{ id: string }>; + + expect(postReopenRows).toEqual([{ id: "noop-after" }]); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 8c314969f8..a2e13b78b9 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -117,6 +117,13 @@ describe("settings key parity", () => { expect(PROJECT_SETTINGS_KEYS).toContain("mailAutoCleanupDays"); }); + it("defaults operationalLogRetentionDays to 30 and keeps it project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.operationalLogRetentionDays).toBe(30); + expect(isProjectSettingsKey("operationalLogRetentionDays")).toBe(true); + expect(isGlobalSettingsKey("operationalLogRetentionDays")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).toContain("operationalLogRetentionDays"); + }); + it("keeps heartbeatScopeDiscipline project-scoped with strict default", () => { expect(DEFAULT_PROJECT_SETTINGS.heartbeatScopeDiscipline).toBe("strict"); expect(isProjectSettingsKey("heartbeatScopeDiscipline")).toBe(true); diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 24c7c17178..4f0668a325 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -108,6 +108,31 @@ describe("task creation hook", () => { expect(created2.id).toMatch(/^FN-/); }); + it("does not leak async task:updated listener rejections during create follow-up updates", async () => { + const store = harness.store(); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + store.on("task:updated", async (task) => { + if (task.id.startsWith("FN-")) { + throw new Error(`listener boom for ${task.id}`); + } + }); + + try { + const task = await store.createTask({ description: "planning create listener safety" }); + await store.updateTask(task.id, { size: "M" }); + await store.logEntry(task.id, "Created via Planning Mode", "Initial plan: test"); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandledRejections).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + it("can clear hook with undefined", async () => { const store = harness.store(); const hook = vi.fn(); diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index b9a3854406..c7e26e981f 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -8,6 +8,7 @@ import { getTaskHardMergeBlocker, getTaskMergeBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, @@ -46,6 +47,23 @@ describe("resolveEffectiveAutoMerge", () => { }); }); +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("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); + }); + + it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { + expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true); + expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true); + }); +}); + describe("resolveEffectiveGroupAutoMerge", () => { it("prefers explicit true over global false", () => { expect(resolveEffectiveGroupAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true); diff --git a/packages/core/src/__tests__/vitest-processes.test.ts b/packages/core/src/__tests__/vitest-processes.test.ts new file mode 100644 index 0000000000..f3e92f59ca --- /dev/null +++ b/packages/core/src/__tests__/vitest-processes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import type { execFile as nodeExecFile } from "node:child_process"; +import { findVitestProcessIds } from "../vitest-processes.js"; + +type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void; + +function makeExecFileMock(responses: { pgrep?: string; ps?: string; pgrepError?: boolean }) { + const calls: Array<{ cmd: string; args: string[] }> = []; + const impl = ((cmd: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + calls.push({ cmd, args }); + if (cmd === "pgrep") { + if (responses.pgrepError) { + cb(new Error("pgrep: no matches"), "", ""); + } else { + cb(null, responses.pgrep ?? "", ""); + } + return {} as never; + } + if (cmd === "ps") { + cb(null, responses.ps ?? "", ""); + return {} as never; + } + cb(new Error(`unexpected command ${cmd}`), "", ""); + return {} as never; + }) as unknown as typeof nodeExecFile; + return { impl, calls }; +} + +describe("findVitestProcessIds", () => { + it("returns only pids whose executable is node — wrapper shells and monitors are spared", async () => { + const { impl, calls } = makeExecFileMock({ + // pgrep -f vitest matches the runner, two workers, a zsh wrapper whose + // command line contains "npx vitest run", and a watch loop grepping for + // "node (vitest". + pgrep: "101\n102\n103\n104\n105\n", + ps: [ + " 101 /opt/homebrew/bin/node", + " 102 node", + " 103 /usr/local/bin/node", + " 104 zsh", + " 105 /bin/zsh", + ].join("\n"), + }); + + const pids = await findVitestProcessIds({ execFileImpl: impl }); + + expect(pids).toEqual([101, 102, 103]); + expect(calls[0]).toEqual({ cmd: "pgrep", args: ["-f", "vitest"] }); + expect(calls[1]?.cmd).toBe("ps"); + expect(calls[1]?.args).toEqual(["-o", "pid=,comm=", "-p", "101,102,103,104,105"]); + }); + + it("always excludes the calling process and any caller-supplied pids", async () => { + const self = process.pid; + const { impl } = makeExecFileMock({ + pgrep: `${self}\n201\n202\n`, + ps: [` ${self} node`, " 201 node", " 202 node"].join("\n"), + }); + + const pids = await findVitestProcessIds({ execFileImpl: impl, excludePids: [202] }); + + expect(pids).toEqual([201]); + }); + + it("returns empty when pgrep finds nothing (non-zero exit)", async () => { + const { impl, calls } = makeExecFileMock({ pgrepError: true }); + + const pids = await findVitestProcessIds({ execFileImpl: impl }); + + expect(pids).toEqual([]); + // ps must not run with an empty pid list. + expect(calls.map((c) => c.cmd)).toEqual(["pgrep"]); + }); + + it("returns empty on win32 without spawning anything", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const { impl, calls } = makeExecFileMock({ pgrep: "999\n", ps: " 999 node" }); + const pids = await findVitestProcessIds({ execFileImpl: impl }); + expect(pids).toEqual([]); + expect(calls).toEqual([]); + } finally { + platformSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/ai-engine-loader.ts b/packages/core/src/ai-engine-loader.ts index 182947cc13..3ea21c614d 100644 --- a/packages/core/src/ai-engine-loader.ts +++ b/packages/core/src/ai-engine-loader.ts @@ -10,7 +10,7 @@ * returns `undefined` and callers degrade gracefully. */ -import type { CreateAiSessionFactory } from "./plugin-types.js"; +import type { CreateAiSessionFactory, CreateInteractiveAiSessionFactory } from "./plugin-types.js"; // Engine exports a function type we intentionally don't pull in here — importing // the type would reintroduce the cycle this module is designed to avoid. @@ -19,6 +19,7 @@ type CreateFnAgent = any; let createFnAgent: CreateFnAgent | undefined; let createAiSessionFactory: CreateAiSessionFactory | undefined; +let createInteractiveAiSessionFactory: CreateInteractiveAiSessionFactory | undefined; /** Shape of a message in an agent session's state. */ export interface AgentMessage { @@ -57,3 +58,23 @@ export function setCreateAiSessionFactory(fn: CreateAiSessionFactory | undefined export async function getCreateAiSessionFactory(): Promise { return createAiSessionFactory; } + +/** + * Wire engine's plugin-facing interactive AI session factory into core. + * Called by `@fusion/engine` at module load; tests may register stubs. + */ +export function setCreateInteractiveAiSessionFactory( + fn: CreateInteractiveAiSessionFactory | undefined, +): void { + createInteractiveAiSessionFactory = fn; +} + +/** + * Returns engine-registered plugin interactive AI session factory, or + * `undefined` when engine hasn't registered it (common in isolated core tests). + */ +export async function getCreateInteractiveAiSessionFactory(): Promise< + CreateInteractiveAiSessionFactory | undefined +> { + return createInteractiveAiSessionFactory; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d0aa646c9..de3a70a8a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,8 @@ export { getFnAgent, setCreateAiSessionFactory, getCreateAiSessionFactory, + setCreateInteractiveAiSessionFactory, + getCreateInteractiveAiSessionFactory, type AgentMessage, } from "./ai-engine-loader.js"; export { @@ -231,7 +233,12 @@ export { normalizeTitleForTaskId, } from "./task-title-id-drift.js"; export { getPrimaryPrInfo } from "./task-helpers.js"; -export { MANUAL_RETRY_RESET_COUNTER_KEYS, buildManualRetryResetPatch } from "./manual-retry-reset.js"; +export { + IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON, + MANUAL_RETRY_RESET_COUNTER_KEYS, + buildAutoPauseClearPatch, + buildManualRetryResetPatch, +} from "./manual-retry-reset.js"; export type { TaskIdIntegrityAnomaly, TaskIdIntegrityAnomalyKind, @@ -321,6 +328,7 @@ export { getTaskHardMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge, + allowsAutoMergeProcessing, isSharedBranchGroupMemberIntegration, resolveEffectiveAutoMerge, resolveEffectiveGroupAutoMerge, @@ -332,6 +340,10 @@ export { isBranchGroupMemberLanded, isBranchGroupComplete, } from "./branch-group-completion.js"; +export { + findVitestProcessIds, + type FindVitestProcessIdsOptions, +} from "./vitest-processes.js"; export { countRecentIdenticalStallEntries, getInReviewStallReason, @@ -531,6 +543,12 @@ export type { CreateAiSessionOptions, AiSessionResult, CreateAiSessionFactory, + CreateInteractiveAiSessionOptions, + InteractiveAiSessionProgressEvent, + InteractiveAiSessionEvent, + InteractiveAiSession, + CreateInteractiveAiSessionResult, + CreateInteractiveAiSessionFactory, PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, diff --git a/packages/core/src/manual-retry-reset.ts b/packages/core/src/manual-retry-reset.ts index 042f25afde..938177ca26 100644 --- a/packages/core/src/manual-retry-reset.ts +++ b/packages/core/src/manual-retry-reset.ts @@ -1,5 +1,7 @@ import type { Task } from "./types.js"; +export const IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON = "in-review-stall-deadlock"; + export const MANUAL_RETRY_RESET_COUNTER_KEYS = [ "stuckKillCount", "resumeLimboCount", @@ -17,6 +19,23 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [ "mergeAuditBounceCount", ] as const satisfies ReadonlyArray; +export function buildAutoPauseClearPatch( + task: Pick, +): Partial { + if ( + task.paused === true + && task.userPaused !== true + && task.pausedReason === IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON + ) { + return { + paused: false, + pausedReason: null as unknown as Task["pausedReason"], + }; + } + + return {}; +} + export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boolean }): Partial { const patch: Partial = { nextRecoveryAt: null as unknown as Task["nextRecoveryAt"], diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 67393291c7..fcc42c34c3 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -487,6 +487,15 @@ export class MissionStore extends EventEmitter { }; } + private listGoalsByIds(goalIds: string[]): Goal[] { + return goalIds + .map((goalId) => this.db + .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") + .get(goalId) as GoalRow | undefined) + .filter((row): row is GoalRow => Boolean(row)) + .map((row) => this.rowToGoal(row)); + } + /** * Convert a database row to a MissionContractAssertion object. */ @@ -703,12 +712,7 @@ export class MissionStore extends EventEmitter { const mission = this.getMission(id); if (!mission) return undefined; - const linkedGoals = this.listGoalIdsForMission(id) - .map((goalId) => this.db - .prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?") - .get(goalId) as GoalRow | undefined) - .filter((row): row is GoalRow => Boolean(row)) - .map((row) => this.rowToGoal(row)); + const linkedGoals = this.listGoalsByIds(this.listGoalIdsForMission(id)); const milestones = this.listMilestones(id); const milestonesWithSlices = milestones.map((milestone) => { @@ -1416,6 +1420,45 @@ export class MissionStore extends EventEmitter { return rows.map((row) => row.missionId); } + /** + * Resolve task → goal provenance by deriving the owning mission from mission linkage. + * Goal IDs are never duplicated onto the task row; provenance is always recovered from mission links. + */ + listGoalIdsForTask(taskId: string): string[] { + const feature = this.getFeatureByTaskId(taskId); + const missionIdFromFeature = feature + ? (() => { + const slice = this.getSlice(feature.sliceId); + if (!slice) { + return undefined; + } + const milestone = this.getMilestone(slice.milestoneId); + return milestone?.missionId; + })() + : undefined; + + const missionId = missionIdFromFeature ?? (() => { + const row = this.db + .prepare('SELECT missionId FROM tasks WHERE id = ? AND "deletedAt" IS NULL') + .get(taskId) as { missionId?: string | null } | undefined; + return row?.missionId ?? undefined; + })(); + + if (!missionId) { + return []; + } + + return this.listGoalIdsForMission(missionId); + } + + /** + * Resolve task → goal provenance to full Goal records derived from the owning mission. + * Goal rows are read on demand so archived goals remain visible without storing duplicate task-level goal data. + */ + listGoalsForTask(taskId: string): Goal[] { + return this.listGoalsByIds(this.listGoalIdsForTask(taskId)); + } + // ── Milestone Operations ─────────────────────────────────────────── /** diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c2c3b1436d..4d06bb6532 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -40,7 +40,7 @@ import type { } from "./plugin-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { createLogger } from "./logger.js"; -import { getCreateAiSessionFactory } from "./ai-engine-loader.js"; +import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; import { scanPluginSecurity } from "./plugin-security-scan.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) @@ -120,9 +120,10 @@ export class PluginLoader extends EventEmitter<{ async createRouteContext( pluginId: string, - overrides?: Partial>, + overrides?: Partial>, ): Promise { const createAiSession = await getCreateAiSessionFactory(); + const createInteractiveAiSession = await getCreateInteractiveAiSessionFactory(); if (process.env.DEBUG?.includes("plugins")) { this.log.log( createAiSession @@ -137,11 +138,15 @@ export class PluginLoader extends EventEmitter<{ settings: overrides?.settings ?? await this.getPluginSettings(pluginId), logger: this.createLogger(pluginId), createAiSession, + createInteractiveAiSession, resolveProjectTaskStore: overrides?.resolveProjectTaskStore, - emitEvent: (event: string, data: unknown) => { - this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) }); + // The host (dashboard) may supply a real publisher that forwards custom + // plugin events to connected SSE clients. Absent an override, fall back to + // logging (the historical no-op behavior) so non-dashboard hosts and tests + // keep working. + emitEvent: overrides?.emitEvent ?? ((event: string, data: unknown) => { this.log.log(`[plugin:${pluginId}] Custom event: ${event}`, data); - }, + }), }; } diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index b9665f27d4..2af8d98957 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -13,7 +13,7 @@ import type { Database } from "./db.js"; import type { TaskStore } from "./store.js"; -import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; +import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const; @@ -121,6 +121,134 @@ export interface AiSessionResult { */ export type CreateAiSessionFactory = (options: CreateAiSessionOptions) => Promise; +// ── Interactive AI Sessions ─────────────────────────────────────────── +// +// A generic interactive (multi-turn, await-input) AI session capability. +// Unlike the one-shot `createAiSession` above, an interactive session can +// pause mid-agent-turn on a structured question and resume when the caller +// supplies an answer. The host (engine) builds the prompt → parse → retry → +// pause → resume loop; the caller drives it by pulling events. +// +// The protocol is deliberately generic: the caller supplies a `systemPrompt` +// that instructs the agent to emit the JSON question/complete contract +// (the same shape used by `PlanningResponse`). The seam hardcodes no +// application-specific (e.g. compound-engineering) prompts or concepts. + +/** + * Options for creating an interactive AI session. + * Mirrors {@link CreateAiSessionOptions}; the caller-supplied `systemPrompt` + * is responsible for instructing the agent to emit the question/complete + * JSON protocol that the seam parses. + */ +export interface CreateInteractiveAiSessionOptions { + /** Working directory for the agent session */ + cwd: string; + /** System prompt for the agent (must instruct it to emit the JSON protocol) */ + systemPrompt: string; + /** Tool mode: "coding" for full tools, "readonly" for read-only */ + tools?: "coding" | "readonly"; + /** Default model provider (e.g., "anthropic") */ + defaultProvider?: string; + /** Default model ID within the provider */ + defaultModelId?: string; + /** + * Skill names the session should load (matched against discovered skills). + * Lets a plugin point a session at a specific bundled skill rather than + * relying on cwd-only discovery. Forwarded to the engine's skill selection. + */ + requestedSkillNames?: string[]; + /** + * Extra directories to scan for skills (each holding `/SKILL.md`), in + * addition to the default cwd/agent-dir roots. A plugin that installs its + * skills to a plugin-local directory passes that directory here so its + * `requestedSkillNames` are actually discoverable in the live session. + */ + additionalSkillPaths?: string[]; + /** + * Live progress callback, invoked WHILE a turn runs (the pull-based + * `nextEvent()` only resolves once the turn settles). Receives streaming + * thinking/text deltas and tool start/end markers so a caller can surface + * the agent's work in real time. Optional; ignored by factories that cannot + * stream. Must not throw — implementations should swallow callback errors. + */ + onProgress?: (event: InteractiveAiSessionProgressEvent) => void; +} + +/** + * A live progress event emitted mid-turn via + * {@link CreateInteractiveAiSessionOptions.onProgress}. + * + * - `thinking` / `text`: an incremental output DELTA (not a snapshot) — the + * consumer accumulates. + * - `tool`: a discrete tool execution start/end marker. + */ +export type InteractiveAiSessionProgressEvent = + | { type: "thinking"; delta: string } + | { type: "text"; delta: string } + | { type: "tool"; name: string; phase: "start" | "end"; isError?: boolean }; + +/** + * A single event pulled from an interactive AI session. + * + * Discriminated union on `type`: + * - `thinking` / `text`: incremental agent output (data is a string). + * - `question`: the agent paused awaiting structured input; the session is + * now in awaiting-input until {@link InteractiveAiSession.answer} is called. + * `data` is a {@link PlanningQuestion} (reused for protocol parity). + * - `complete`: the agent finished; `data` is the final payload (shape is + * defined by the caller's protocol — opaque to the seam). + * - `error`: an agent/session/parse error; `data` carries a human-readable + * message and optional error detail. The caller is never left hanging. + */ +export type InteractiveAiSessionEvent = + | { type: "thinking"; data: string } + | { type: "text"; data: string } + | { type: "question"; data: PlanningQuestion } + | { type: "complete"; data: unknown } + | { type: "error"; data: { message: string; cause?: unknown } }; + +/** + * An interactive, multi-turn AI session. + * + * Event delivery is **pull-based**: the caller awaits {@link nextEvent} to get + * the next event. `nextEvent()` resolves once the session has produced an + * event for the most recent `prompt`/`answer`. A `question` event leaves the + * session in awaiting-input; the caller must call {@link answer} (not + * {@link prompt}) to resume. After a `complete` or `error` event the session + * is terminal and `nextEvent()` will keep returning that terminal event. + * + * (Pull-based `nextEvent()` is chosen over an async iterator because it is the + * simpler shape to drive deterministically from a route/test: each turn is one + * `prompt`/`answer` followed by one awaited `nextEvent`.) + */ +export interface InteractiveAiSession { + /** Send a free-text turn to the agent (the opening turn, or follow-up text). */ + prompt(text: string): Promise; + /** Pull the next event produced by the most recent prompt/answer. */ + nextEvent(): Promise; + /** Answer the currently-awaiting question, resuming the agent. */ + answer(questionId: string, response: unknown): Promise; + /** Release the underlying agent/session handles. Safe to call repeatedly. */ + dispose(): void; +} + +/** + * Result returned from creating an interactive AI session. + */ +export interface CreateInteractiveAiSessionResult { + /** The interactive session handle. */ + session: InteractiveAiSession; + /** Path to persisted session file, if any. */ + sessionFile?: string; +} + +/** + * Engine-injected factory for plugin interactive AI sessions. + */ +export type CreateInteractiveAiSessionFactory = ( + options: CreateInteractiveAiSessionOptions, +) => Promise; + /** * Context object passed to plugins at runtime. * Contains task store access, settings, logging, and event emission. @@ -137,6 +265,12 @@ export interface PluginContext { emitEvent: (event: string, data: unknown) => void; /** Engine-injected AI session factory (undefined when engine is not loaded) */ createAiSession?: CreateAiSessionFactory; + /** + * Engine-injected interactive (multi-turn, await-input) AI session factory. + * Undefined when the engine is not loaded or on non-route contexts (parity + * with `createAiSession`). + */ + createInteractiveAiSession?: CreateInteractiveAiSessionFactory; /** Optional host capability to resolve a project-scoped TaskStore by projectId. */ resolveProjectTaskStore?: (projectId: string) => Promise; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 3f7cbc63fd..b0bb8931d7 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10,6 +10,7 @@ import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js"; import { normalizeTaskPriority } from "./task-priority.js"; import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; +import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; @@ -88,6 +89,7 @@ interface TaskRow { blockedBy: string | null; overlapBlockedBy: string | null; paused: number | null; + pausedReason: string | null; userPaused: number | null; baseBranch: string | null; executionStartBranch: string | null; @@ -1231,6 +1233,34 @@ export class TaskStore extends EventEmitter { this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); } + private emitTaskLifecycleEventSafely( + event: "task:created" | "task:updated", + args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"], + ): boolean { + const listeners = super.listeners(event) as Array<(...listenerArgs: typeof args) => unknown>; + if (listeners.length === 0) { + return false; + } + + const [task] = args; + const taskId = task && typeof task === "object" && "id" in task ? String(task.id) : "unknown"; + + for (const listener of listeners) { + try { + const result = listener(...args); + if (result && typeof (result as PromiseLike).then === "function") { + void Promise.resolve(result).catch((error) => { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + }); + } + } catch (error) { + storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`); + } + } + + return true; + } + /** * Get the SQLite database, initializing it on first access. * Also performs auto-migration from legacy file-based storage if needed. @@ -1392,6 +1422,7 @@ export class TaskStore extends EventEmitter { } await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateAgentLogEntriesToFilesOnce(); + await this.cleanupNoOpTaskMovedActivityRowsOnce(); if (this.db.getSchemaVersion() < SCHEMA_VERSION) { this.db.init(); } @@ -1454,6 +1485,7 @@ export class TaskStore extends EventEmitter { blockedBy: row.blockedBy || undefined, overlapBlockedBy: row.overlapBlockedBy || undefined, paused: row.paused ? true : undefined, + pausedReason: row.pausedReason || undefined, userPaused: row.userPaused ? true : undefined, baseBranch: row.baseBranch || undefined, executionStartBranch: row.executionStartBranch || undefined, @@ -2084,6 +2116,7 @@ export class TaskStore extends EventEmitter { task.blockedBy ?? null, task.overlapBlockedBy ?? null, task.paused ? 1 : 0, + task.pausedReason ?? null, task.userPaused ? 1 : 0, task.baseBranch ?? null, task.branch ?? null, @@ -2200,7 +2233,7 @@ export class TaskStore extends EventEmitter { this.db.prepare(` INSERT INTO tasks ( id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep, - worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider, + worktree, blockedBy, overlapBlockedBy, paused, pausedReason, userPaused, baseBranch, branch, autoMerge, 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, error, summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, @@ -2227,7 +2260,7 @@ export class TaskStore extends EventEmitter { this.db.prepare(` INSERT INTO tasks ( id, lineageId, title, description, priority, "column", status, size, reviewLevel, currentStep, - worktree, blockedBy, overlapBlockedBy, paused, userPaused, baseBranch, branch, autoMerge, executionStartBranch, baseCommitSha, modelPresetId, modelProvider, + worktree, blockedBy, overlapBlockedBy, paused, pausedReason, userPaused, baseBranch, branch, autoMerge, 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, error, summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, @@ -2252,6 +2285,7 @@ export class TaskStore extends EventEmitter { blockedBy = excluded.blockedBy, overlapBlockedBy = excluded.overlapBlockedBy, paused = excluded.paused, + pausedReason = excluded.pausedReason, userPaused = excluded.userPaused, baseBranch = excluded.baseBranch, branch = excluded.branch, @@ -2586,6 +2620,7 @@ export class TaskStore extends EventEmitter { // Task moved this.on("task:moved", (data) => { if (this.suppressActivityLogForPollingEmit) return; + if (data.from === data.to) return; this.recordActivityFromListener( { type: "task:moved", @@ -4008,7 +4043,7 @@ export class TaskStore extends EventEmitter { await this._maybeAutoArchiveSameAgentDuplicate(task, input); - this.emit("task:created", task); + this.emitTaskLifecycleEventSafely("task:created", [task]); if (options?.invokeTaskCreatedHook !== false) { await this.invokeTaskCreatedHook(task); } @@ -4396,6 +4431,17 @@ export class TaskStore extends EventEmitter { return existing; } + // `branch_groups.branchName` is globally UNIQUE — a branch is represented by + // exactly one open group. If another source already owns an open group for + // this branch, reuse it rather than calling createBranchGroup and violating + // the UNIQUE constraint. Without this, two missions whose shared base resolves + // to the same branch (e.g. "main") collide: the throw escapes triageFeature + // and is swallowed by its callers, silently stranding "defined" features. + const existingByBranch = this.getBranchGroupByBranchName(init.branchName); + if (existingByBranch) { + return existingByBranch; + } + return this.createBranchGroup({ sourceType, sourceId, @@ -4626,7 +4672,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4639,7 +4685,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4882,7 +4928,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -4895,7 +4941,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5045,7 +5091,7 @@ export class TaskStore extends EventEmitter { const task = this.rowToTask(row); task.inReviewStall = getInReviewStallReason(task, { now, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5058,7 +5104,7 @@ export class TaskStore extends EventEmitter { task.inReviewStalled = getInReviewStalledSignal(task, { now, thresholdMs: settings.inReviewStalledThresholdMs, - autoMerge: settings.autoMerge, + autoMerge: allowsAutoMergeProcessing(task, settings), engineActiveSinceMs: settings.engineActiveSinceMs, engineActivationGraceMs: settings.engineActivationGraceMs, }); @@ -5686,7 +5732,9 @@ export class TaskStore extends EventEmitter { if (this.isWatching) this.taskCache.set(id, { ...task }); - this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); + if (fromColumn !== toColumn) { + this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); + } return task; } @@ -5878,7 +5926,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6549,7 +6597,7 @@ export class TaskStore extends EventEmitter { if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); } - this.emit("task:updated", task); + this.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); } @@ -6800,12 +6848,12 @@ export class TaskStore extends EventEmitter { if (this.isWatching) { this.taskCache.set(id, { ...current }); } - this.emit("task:updated", current); + this.emitTaskLifecycleEventSafely("task:updated", [current]); return current; } const emittedTask = ({ id, log, updatedAt } as unknown) as Task; - this.emit("task:updated", emittedTask); + this.emitTaskLifecycleEventSafely("task:updated", [emittedTask]); return emittedTask; }); } @@ -8634,9 +8682,12 @@ export class TaskStore extends EventEmitter { if (archivedSet.has(id)) { // Task moved to archive — emit task:moved (matching what // archiveTask emits in-process) so other subscribers can react. - // Activity-log listeners skip this emit; the originating + // Skip already-archived cache entries to avoid no-op emits. + // Activity-log listeners skip polling emits; the originating // TaskStore instance wrote the row in-process. - this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" }); + if (cached.column !== "archived") { + this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column, source: "engine" }); + } } else { // Polling replicas only mirror the originating delete signal. // Do not record run-audit here; the writer already owns that row. @@ -10290,6 +10341,44 @@ export class TaskStore extends EventEmitter { this.db.bumpLastModified(); } + private async cleanupNoOpTaskMovedActivityRowsOnce(): Promise { + const migrationKey = "noOpTaskMovedActivityCleanupVersion"; + const migrationVersion = "1"; + const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as + | { value: string } + | undefined; + + if (row?.value === migrationVersion) { + return; + } + + const hasTable = + this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activityLog' LIMIT 1").get() !== + undefined; + const markDone = () => { + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(migrationKey, migrationVersion); + }; + + if (!hasTable) { + markDone(); + this.db.bumpLastModified(); + return; + } + + this.db.transactionImmediate(() => { + this.db.prepare(` + DELETE FROM activityLog + WHERE type = 'task:moved' + AND json_extract(metadata, '$.from') = json_extract(metadata, '$.to') + `).run(); + markDone(); + this.db.bumpLastModified(); + }); + } + // ── Archive Cleanup Methods ───────────────────────────────────────── /** diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 479a965a98..0caaac894d 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -47,6 +47,23 @@ export function resolveEffectiveAutoMerge( return task.autoMerge ?? settings.autoMerge; } +/** + * Gate for auto-merge *processing* (engine enqueue + self-healing sweeps). + * 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 + * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would + * (incorrectly for processing gates) starve the manual-required parking path. + */ +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} + // Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated. export function resolveEffectiveGroupAutoMerge( group: Pick, diff --git a/packages/core/src/vitest-processes.ts b/packages/core/src/vitest-processes.ts new file mode 100644 index 0000000000..a2f5e01802 --- /dev/null +++ b/packages/core/src/vitest-processes.ts @@ -0,0 +1,87 @@ +import { execFile as nodeExecFile } from "node:child_process"; + +/** + * Locate running vitest processes safely. + * + * `pgrep -f vitest` matches FULL command lines, so a bare pattern also matches + * innocent bystanders whose argv merely mentions vitest: + * - wrapper shells (`zsh -c '... npx vitest run ...'`) — killing these + * strands the `$?` handler so failures look like silent truncation, + * - monitoring/grep one-liners that mention vitest, + * - editors or tools opened on `vitest.config.ts`. + * Root cause of the 2026-06-03 incident where the memory-pressure auto-kill + * SIGKILLed unrelated process trees every 30s. + * + * This helper filters pgrep candidates to processes whose executable (`comm`) + * is actually node, so only the vitest runner and its workers are reported. + */ + +export interface FindVitestProcessIdsOptions { + /** PIDs to exclude in addition to the calling process. */ + excludePids?: number[]; + /** Test seam — injected execFile. */ + execFileImpl?: typeof nodeExecFile; +} + +function execToStdout( + execFileImpl: typeof nodeExecFile, + cmd: string, + args: string[], +): Promise { + return new Promise((resolve) => { + execFileImpl(cmd, args, { encoding: "utf8" }, (err, out) => { + // pgrep/ps exit non-zero when nothing matches — treat as empty result. + resolve(err ? "" : (typeof out === "string" ? out : "")); + }); + }); +} + +function parsePids(stdout: string): number[] { + return stdout + .split(/\r?\n/) + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0); +} + +/** Keep only pids whose executable is node (vitest runner + pool workers). */ +async function filterToNodeProcesses( + execFileImpl: typeof nodeExecFile, + pids: number[], +): Promise { + if (pids.length === 0) return []; + const stdout = await execToStdout(execFileImpl, "ps", [ + "-o", + "pid=,comm=", + "-p", + pids.join(","), + ]); + const nodePids: number[] = []; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx <= 0) continue; + const pid = Number.parseInt(trimmed.slice(0, spaceIdx), 10); + if (!Number.isFinite(pid) || pid <= 0) continue; + const comm = trimmed.slice(spaceIdx + 1).trim(); + const executable = comm.split("/").pop() ?? comm; + if (executable === "node" || executable === "node.exe" || executable === "nodejs") { + nodePids.push(pid); + } + } + return nodePids; +} + +export async function findVitestProcessIds( + options: FindVitestProcessIdsOptions = {}, +): Promise { + // pgrep/ps are POSIX-only; Windows callers treat this as a no-op. + if (process.platform === "win32") return []; + + const execFileImpl = options.execFileImpl ?? nodeExecFile; + const excluded = new Set([process.pid, ...(options.excludePids ?? [])]); + + const candidates = parsePids(await execToStdout(execFileImpl, "pgrep", ["-f", "vitest"])); + const nodePids = await filterToNodeProcesses(execFileImpl, candidates); + return nodePids.filter((pid) => !excluded.has(pid)); +} diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index 912b24277c..bb6b7e5431 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -734,6 +734,7 @@ The dashboard server exposes a REST API at `/api`: - `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`) - `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below) - `POST /api/tasks/:id/pr/create` - Create PR +- `POST /api/tasks/:id/pr/resolve-conflicts` - Resolve Create-PR merge conflicts with AI and push the task branch - `GET /api/tasks/:id/pr/status` - Get PR status (5-min staleness, auto background refresh) - `POST /api/tasks/:id/pr/refresh` - Force refresh PR status - `GET /api/tasks/:id/issue/status` - Get cached issue status (5-min staleness, auto background refresh) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 715fe9994c..3bcf1d8d9f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -311,7 +311,34 @@ function AppInner() { setBaseBranchFilter(value); setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id); }, [currentProject?.id]); - + + // Host capability handed to plugin dashboard views: subscribe to a plugin's + // custom SSE events (forwarded by the server as `plugin:custom`, scoped to the + // current project) over the shared bus — so plugins push live updates without + // deep-importing the dashboard's sse-bus or opening their own EventSource. + const subscribePluginEvents = useCallback( + (pluginId: string, onEvent: (e: { event: string; payload: unknown }) => void) => { + const params = new URLSearchParams(); + if (currentProject?.id) params.set("projectId", currentProject.id); + const query = params.size > 0 ? `?${params.toString()}` : ""; + return subscribeSse(`/api/events${query}`, { + events: { + "plugin:custom": (event: MessageEvent) => { + try { + const d = JSON.parse(event.data) as { pluginId?: string; event?: string; payload?: unknown }; + if (d.pluginId === pluginId && typeof d.event === "string") { + onEvent({ event: d.event, payload: d.payload }); + } + } catch { + // Ignore malformed plugin:custom payloads. + } + }, + }, + }); + }, + [currentProject?.id], + ); + // Remote node data and events when in remote mode (pass searchQuery for server-side filtering) const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined }); useRemoteNodeEvents(currentNodeId); @@ -1385,6 +1412,7 @@ function AppInner() { projectId: currentProject?.id, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, + subscribePluginEvents, openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), renderTaskCard: (task: Task | TaskDetail) => ( { + it("documents the evidence categories, written-rationale activation rule, and no-auto-refinement constraint", () => { + const doc = readFileSync( + resolve(__dirname, "../../../../docs/goals-refinement-gate.md"), + "utf-8", + ); + + expect(doc).toContain("# Goals Refinement Gate"); + expect(doc).toContain("[← Docs index](./README.md)"); + + for (const snippet of REQUIRED_TRIGGER_EVIDENCE) { + expect(doc).toContain(snippet); + } + + for (const snippet of REQUIRED_ACTIVATION_RULE_SNIPPETS) { + expect(doc).toContain(snippet); + } + + for (const snippet of REQUIRED_NO_AUTOMATIC_REFINEMENT_SNIPPETS) { + expect(doc).toContain(snippet); + } + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 3b702ee8c3..a5d6253f54 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2423,6 +2423,18 @@ export interface PrPreflightResponse { changedFiles: PrPreflightChangedFile[]; } +export interface ResolvePrConflictsResult { + resolved: boolean; + pushed: boolean; + conflictedFiles: string[]; + message: string; +} + +export interface ResolvePrConflictsResponse { + result: ResolvePrConflictsResult; + preflight: PrPreflightResponse; +} + export interface PrOptionsUser { login: string; name?: string; @@ -2463,6 +2475,14 @@ export function fetchPrPreflight(id: string, projectId?: string, base?: string): return api(withProjectId(`/tasks/${id}/pr/preflight${baseParam}`, projectId)); } +/** Ask Fusion to resolve Create-PR merge conflicts for a task branch */ +export function resolvePrConflicts(id: string, base?: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/pr/resolve-conflicts`, projectId), { + method: "POST", + ...(base ? { body: JSON.stringify({ base }) } : {}), + }); +} + /** Fetch PR creation options (branches/reviewers/assignees/labels) for a task */ export function fetchPrOptions(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/pr/options`, projectId)); diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 936931d017..423c39df46 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -236,25 +236,20 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask } }; + const visualViewport = window.visualViewport; + const handleViewportResize = () => { + scheduleStabilization(); + }; + scheduleStabilization(); window.addEventListener("pageshow", handlePageShow); - - const visualViewport = window.visualViewport; - let handleViewportResize: (() => void) | null = null; - if (visualViewport) { - handleViewportResize = () => { - scheduleStabilization(); - if (typeof visualViewport.removeEventListener === "function") { - visualViewport.removeEventListener("resize", handleViewportResize!); - } - handleViewportResize = null; - }; + if (typeof visualViewport?.addEventListener === "function") { visualViewport.addEventListener("resize", handleViewportResize); } return () => { window.removeEventListener("pageshow", handlePageShow); - if (handleViewportResize && typeof visualViewport?.removeEventListener === "function") { + if (typeof visualViewport?.removeEventListener === "function") { visualViewport.removeEventListener("resize", handleViewportResize); } if (rafId !== null) { diff --git a/packages/dashboard/app/components/PrCreateModal.css b/packages/dashboard/app/components/PrCreateModal.css index 17ff18c877..081b2232e2 100644 --- a/packages/dashboard/app/components/PrCreateModal.css +++ b/packages/dashboard/app/components/PrCreateModal.css @@ -80,6 +80,35 @@ gap: var(--space-sm); } +.pr-create-modal__conflict-resolution { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); +} + +.pr-create-modal__conflict-copy { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.pr-create-modal__conflict-title, +.pr-create-modal__conflict-message { + margin: 0; +} + +.pr-create-modal__conflict-title { + font-weight: 600; +} + +.pr-create-modal__conflict-message { + color: var(--text-muted); +} + .pr-create-modal__label { font-size: 0.75rem; text-transform: uppercase; @@ -246,7 +275,8 @@ .pr-create-modal__title-row, .pr-create-modal__grid-two, .pr-create-modal__commit-row, - .pr-create-modal__file-row { + .pr-create-modal__file-row, + .pr-create-modal__conflict-resolution { display: flex; flex-direction: column; align-items: flex-start; diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 4c066434cc..8ab1ec7679 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react"; +import { createPortal } from "react-dom"; import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react"; import { getErrorMessage, type PrInfo, type StructuredGhError } from "@fusion/core"; import { @@ -6,6 +7,7 @@ import { fetchPrOptions, fetchPrPreflight, generatePrMetadata, + resolvePrConflicts, type PrOptionsLabel, type PrOptionsResponse, type PrOptionsUser, @@ -134,6 +136,7 @@ export function PrCreateModal({ const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [resolveConflictError, setResolveConflictError] = useState(null); const [lastGhError, setLastGhError] = useState(null); const [aiTitle, setAiTitle] = useState(""); const [aiBody, setAiBody] = useState(""); @@ -146,6 +149,7 @@ export function PrCreateModal({ const [preflight, setPreflight] = useState(null); const [baseBranch, setBaseBranch] = useState(""); const [draft, setDraft] = useState(false); + const [resolvingConflicts, setResolvingConflicts] = useState(false); const [reviewers, setReviewers] = useState([]); const [assignees, setAssignees] = useState([]); const [labels, setLabels] = useState([]); @@ -156,6 +160,7 @@ export function PrCreateModal({ const requestId = ++requestSeqRef.current; setLoading(true); setError(null); + setResolveConflictError(null); try { const [metadata, preflightData, optionsData] = await Promise.all([ generatePrMetadata(taskId, projectId), @@ -281,6 +286,7 @@ export function PrCreateModal({ const handleBaseChange = useCallback(async (nextBase: string) => { setBaseBranch(nextBase); + setResolveConflictError(null); try { const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase); setPreflight(nextPreflight); @@ -289,6 +295,21 @@ export function PrCreateModal({ } }, [projectId, taskId]); + const handleResolveConflicts = useCallback(async () => { + if (!baseBranch || resolvingConflicts) return; + setResolvingConflicts(true); + setResolveConflictError(null); + try { + const response = await resolvePrConflicts(taskId, baseBranch, projectId); + setPreflight(response.preflight); + addToast("Resolved PR conflicts and pushed branch", "success"); + } catch (resolveError) { + setResolveConflictError(getErrorMessage(resolveError)); + } finally { + setResolvingConflicts(false); + } + }, [addToast, baseBranch, projectId, resolvingConflicts, taskId]); + const payload = useMemo(() => ({ title: title.trim(), body: body.trim(), @@ -323,7 +344,7 @@ export function PrCreateModal({ if (!open) return null; - return ( + return createPortal(
event.target === event.currentTarget && onClose()}>
void handleBaseChange(baseBranch)}> Re-run preflight + {preflight?.conflictsWithBase ? ( +
+
+

Resolve conflicts with AI

+

Fusion will use AI to resolve conflicts on this branch and push it.

+
+ +
+ ) : null}
@@ -445,6 +483,15 @@ export function PrCreateModal({
+ {resolveConflictError ? ( +
+

{resolveConflictError}

+
+ +
+
+ ) : null} + {error && (

{error}

@@ -469,6 +516,7 @@ export function PrCreateModal({
- + , + document.body, ); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index fcdd2df02b..3e891f7af7 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2451,6 +2451,28 @@ export function SettingsModal({ Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. +
+ + + + Lowering this window means Reliability metrics/charts and the Activity feed will not show history older + than the selected range. Per-task task detail history is unaffected. Default: 30 days. + +

Chat Rooms

@@ -6108,31 +6130,6 @@ export function SettingsModal({ )}
-

Database Maintenance

-
- - - - Prune append-only operational logs (activity log, agent logs, run audit, heartbeats) older than this - many days during periodic maintenance. Keeps the database from growing without bound — large databases - are slower to checkpoint and more prone to corruption. Default: 30 days. - -
-

Memory Backups