diff --git a/.changeset/fn-8845-spec-lock-drift-report.md b/.changeset/fn-8845-spec-lock-drift-report.md new file mode 100644 index 0000000000..fce19e8ad7 --- /dev/null +++ b/.changeset/fn-8845-spec-lock-drift-report.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Preserve approved plans and surface deterministic execution drift. +category: feature +dev: Adds immutable spec-lock, current-plan evidence, and retained drift-report storage. diff --git a/.changeset/fn-8943-spec-lock-divergence.md b/.changeset/fn-8943-spec-lock-divergence.md new file mode 100644 index 0000000000..4152c4bbfc --- /dev/null +++ b/.changeset/fn-8943-spec-lock-divergence.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep re-locked plans marked as previously diverged instead of resetting to on plan. +category: fix +dev: Engine spec-drift snapshot derives priorDivergence from the retained report history via the shared hasPriorLockDivergence helper. diff --git a/docs/architecture.md b/docs/architecture.md index b644b5dfb8..62be8c05cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ Action gates emit a best-effort, idempotent system-mail item for each pending ap Actionable terminal task updates are classified into bounded reasons such as a named merge gate, retry exhaustion, or a completion blocker. The PostgreSQL-backed task row persists an active/resolved episode with an opaque identity, so `NotificationService` sends one `task-wedged` provider event and one dashboard system-mailbox message per active reason even across restarts. Repeated observations remain quiet until an authoritative non-wedge task update resolves the episode; changed and resolved-then-reentered reasons notify again. -A failed snapshot is not actionable while persisted automatic-recovery ownership remains: a scheduled recovery has both its retry counter and deadline, while transient merge recovery has an in-budget persisted retry counter. A self-healing decline is actionable only when its proof shows no live session, no recent activity, and no intentional pause or auto-merge-off hold. Pause-derived wedge reasons additionally require real pause state (`paused: true` or `status: "paused"`); an actively progressing task, including `reviewing`, is never wedged. `NotificationService` re-reads and reclassifies the live task immediately before a wedge claim and again when a generic failure grace timer fires: it suppresses progressing, paused, auto-merge-off, deleted, and archived/complete-lane rows, and a not-found live read is quiet rather than rejecting the per-task wedge chain. Recovery that begins after a failed event, including a resume that deliberately leaves a stale pause reason behind, therefore cannot create a mailbox row or `task-wedged` provider event. Explicit operator-action parks and cleared/exhausted recovery markers remain terminal and claim exactly one episode. +A failed snapshot is not actionable while persisted automatic-recovery ownership remains: a scheduled recovery has both its retry counter and deadline, while transient merge recovery has an in-budget persisted retry counter. Pause-derived wedge reasons additionally require real pause state (`paused: true` or `status: "paused"`); an actively progressing task is never wedged. `NotificationService` re-reads and reclassifies the live task immediately before a wedge claim and again when a generic failure grace timer fires, so recovery that begins after a failed event, including a resume that deliberately leaves a stale pause reason behind, cannot create a mailbox row or `task-wedged` provider event. Explicit operator-action parks and cleared/exhausted recovery markers remain terminal and claim exactly one episode. Each task also stores `lastNotifiedAtByReason`, an independent timestamp map keyed by bounded reason. `WEDGE_RENOTIFY_COOLDOWN_MS` defaults to six hours: resolving an episode does not clear its reason's live stamp, so a scheduler/self-healing resolve→re-wedge flap sends neither a provider push nor a mailbox message until the window expires. A different reason notifies immediately, including X→Y→X while X remains within its own cooldown; expired or invalid entries are pruned during the atomic claim, and legacy rows without the map notify normally before initializing it. The no-durable-store fallback applies the same per-reason window in memory. Provider and mailbox delivery are independently best-effort after sharing this single claim decision, while run-audit metadata remains ids/counts/outcomes-only. @@ -22,7 +22,7 @@ Each task also stores `lastNotifiedAtByReason`, an independent timestamp map key Dependency changes and planning finalization share one outer lifecycle lock keyed by the canonical project ID and task ID. In PostgreSQL mode this is a dedicated, single-connection session advisory lock: it is acquired before the normal task lock and released before the mutation/finalization Promise settles. The operational runtime pool is never borrowed for this purpose. -`ResolvedBackend.directSessionUrl` carries the executable lock endpoint. Embedded lifecycle startup supplies its lifecycle-created local URL with `embedded-lifecycle` provenance. For external deployments, a non-pooler `DATABASE_URL` is itself the direct-session endpoint with `runtime-direct` provenance. `DATABASE_MIGRATION_URL` is required only when the runtime URL is pooler-like or a distinct schema-work endpoint is wanted; a direct override has `migration-override` provenance. Pooled, mismatched, or unavailable endpoints fail closed. +`ResolvedBackend.directSessionUrl` carries the executable lock endpoint. Embedded lifecycle startup supplies its lifecycle-created local URL for runtime, migration, and direct-session use with `embedded-lifecycle` provenance. External deployments must supply an explicit `DATABASE_MIGRATION_URL`; it is the only external direct-session endpoint and has `migration-override` provenance. Missing, mismatched, unavailable, or pooler-like endpoints fail closed rather than falling back to `DATABASE_URL`. A newly added dependency atomically records `needs-replan` and clears superseded approval fingerprint/approval-park evidence before a new graph continuation can be eligible. Pending pre-execution continuation work is cancelled with that superseded handoff; running and historical graph work remain durable evidence. Finalizers take the same lock and therefore cannot restore stale handoff state after a re-seed. Manual approval is restored before graph continuation; Plan Review verdicts and results remain graph-owned. @@ -624,7 +624,6 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. ### Agent roles - **Planning**: the planning processor generates task plans (`PROMPT.md`) and selects eligible planning tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. Each attempt captures the authoritative artifact baseline and owns its fallback callback provenance. Only a settled, fallback-free attempt that changed that exact baseline and passes deterministic validation may hand off to workflow Plan Review. Empty, unchanged, or fallback-engaged attempts use the shared bounded `recoveryRetryCount`/`nextRecoveryAt` backoff; exhaustion persists an actionable planning error and never signals successful handoff. After a prompt settles, triage awaits the originating runtime's finite `settleFallbackDispatch` lifecycle signal, then awaits every observer callback admitted by that signal before deciding. A configured runtime that cannot supply this signal fails closed through the same bounded planning recovery rather than handing a potentially fallback-authored plan to review. This deliberately never inspects arbitrary Node timers: clean planner housekeeping can schedule unrelated one-shot or recurring timers without delaying admission. A callback from an obsolete attempt remains scoped to that attempt. Explicit duplicate-marker closure runs only after this same clean-attempt admission. If the stuck-task detector kills a not-yet-approved planning session after a non-empty `PROMPT.md` draft exists, the retry is requeued as `needs-replan` and seeds the next prompt in revision mode from that draft instead of cold-starting. A newly added dependency in a hold lane follows the same durable `needs-replan` path; it never clears status, so a planner interrupted after prompt persistence remains claimable and cannot silently bypass the approval/release handoff. When `PROMPT.md` is absent, a non-empty `plan` task document written through `fn_task_document_write` is the fallback seed; missing or whitespace-only drafts still cold-start. - **Executor**: `TaskExecutor` (`executor.ts`) implements tasks in worktrees - - **Task-pinned orphan recovery:** task-ID-pinned acquisition holds one path reservation across classification, preservation, quarantine reconciliation, and recreation. Inactive incomplete or unregistered directories are atomically moved to `/.fusion/recovery/worktrees`, or to `/.fusion-recovery/worktrees` after an `EXDEV` cross-filesystem refusal. Each actual recovery root retains the newest 10 recognized Fusion-generated entries; pruning is fail-soft and preserves unknown, symlinked, unreadable, or active paths. Worktree pool and self-healing scans exclude both `.ai-merge` and `.fusion-recovery` as internal container boundaries. - **Execution-only reused-base refresh (FN-8693):** planning creates isolated worktrees but does not refresh them; immediately before a graph `code` node, normal executor dispatch, or durable-agent heartbeat session, refresh-enabled reuse resolves the current integration target C1 and compares it with durable `task.baseCommitSha`. A clean no-own-commit checkout resets to C1; a clean own-commit checkout rebases and retains its resulting C2 `HEAD`, while storing C1—not C2—as the baseline. A durable C0/C1 mismatch is rechecked from git and durable metadata on every acquisition, so restart reconciliation needs no in-memory marker. Dirty, unresolved, unsupported worktrunk, git, conflict, persistence, and unprovable-reconciliation cases are typed non-execution outcomes that park before session start. If baseline persistence fails after git moves `HEAD`, the engine compensates to the original clean checkout and emits `worktree:base-refresh-persistence-failed-compensated`; otherwise it requires later proof-based reconciliation. Audit events are `worktree:base-refreshed`, `worktree:base-refresh-blocked`, `worktree:base-refresh-conflict`, `worktree:base-refresh-persistence-failed-compensated`, and `worktree:base-refresh-reconciled`. Plan/review/gate acquisition and merger acquisition remain excluded; production merger behavior is the unified clean-room `runAiMerge` path. - **Reviewer**: `reviewStep()` (`reviewer.ts`) performs plan/code/spec reviews @@ -643,7 +642,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - Advisory and blocking paths are both logged to task logs for operator visibility. ### Scheduling and execution -- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling whose workflow hold/release sweep ranks auto-release candidates by priority first, then FIFO (`createdAt` ascending), then task ID. Pauses, unmet dependencies, active file-scope overlaps, and downstream capacity occupancy exclude non-runnable work before it can consume a reservation; `urgent` always stays ahead of lower priorities. This production hold/release order intentionally does not use dependency-unblock fanout as a ranking key. +- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then dependency-unblock fanout within the same priority class (FN-4969), then FIFO (`createdAt` ascending) with task-id fallback. `urgent` always stays ahead of lower priorities, and overlap/file-scope blockers are excluded from fanout weighting. - **Worktree-capacity admission (FN-8822):** scheduler execute, triage specify, project-engine merge, and direct workflow-planning continuation handoffs share one serialized project coordinator. Its ceiling is `min(maxConcurrent, maxWorktrees)` when worktree limiting is enabled and counts only canonical live task claims plus transient reservations—not retained directories, stale metadata, paused/terminal tasks, or orphans. A genuinely full cap persists a deduplicated queued reason with the `maxWorktrees` gate, used/limit, and holder IDs through ordinary task status/log APIs. Retained worktrees are deliberately non-destructive: cleanup and pooling preserve active, dirty, or uniquely committed work. - `blockedBy` invariant (FN-3924/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state. - Dependency-cycle invariant (FN-5256): task dependency graphs are acyclic at write time (`DependencyCycleError` in `TaskStore` for `createTask`, `createTaskWithReservedId`, `updateTask`, and `applyReplicatedTaskCreate`) with `task:dependency-cycle-rejected` audit evidence. Self-healing batch 2 adds `reconcileDependencyCycles`, which emits `task:dependency-cycle-detected`, auto-repairs only bounded umbrella-back-edge loops via `task:auto-reconciled-dependency-cycle`, and leaves ambiguous cycles untouched with `task:dependency-cycle-unrepaired` for operator inspection. @@ -2272,7 +2271,6 @@ 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`. For **complete**-role columns it also force-deletes branches that still have unique commits vs the integration base (squash/AI-merge tip SHAs are not ancestors of main) with reason `complete-column-unique-commits-force`, and does **not** emit the non-actionable `stale-active-branch-rescue-needed` warn for those lanes. Non-complete columns with unique commits still warn rescue-needed and leave the branch alone. Archived columns are skipped entirely. 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. Completion fan-out (`clearCompletionBranchIfSubsumed`) force-deletes the task branch after done even when unique commits remain, so squash leftovers do not accumulate. - **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. -- **Merge transient-status recovery (FN-8912)**: each merge generation fences status writes with its own abort signal, so a body superseded by abort (including one outliving the bounded settle latch) cannot re-stamp or clear a successor's transient status. An aborted merge clears its own active stamp before rejecting callers; the single-flight lane release deliberately remains in the drain loop's trailing `finally`, where it is synchronous before rejection and cannot tear down a successor. After claiming a generation, the serialized merge pump reconciles an orphaned active stamp before invoking the body. That clear is sound because `mergeRunning` serializes holders and the abort fence closes the orphan writer set, not because a claim alone establishes exclusivity. Clearing these owner-held stamps is never policy-gated; starting or re-enqueuing a merge remains policy-gated. The stale sweep validates and clears through `updateTaskAtomic` so a snapshot cannot erase a newly active merge, preserves confirmed finalization rows, and clear-then-enqueues only eligible non-workspace tasks. - **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. `recoverMissingWorktreeReviewFailures` also owns durable unusable-worktree session-start failures that reached `in-review` with a merge-active status (`merging`, `merging-pr`, or `merging-fix`): before interrupted/deadlocked merge sweeps can re-drive the same phantom path, it applies auto-merge eligibility, workspace-task exclusion, and triple-proof, clears stale `worktree`/`branch`/`sessionFile`, resets the exhausted worktree-session retry budget, increments `recoveryRetryCount` as the bounded merge-active stale-metadata clear counter, and requeues to `todo` preserving progress. Operator retry surfaces (`fn_task_retry`, CLI `task retry`, dashboard retry) have the same signature-only reset primitive so a missing-worktree failure does not require a valid `merging` transition. - **Durable agent heartbeat error recovery (FN-7835/FN-7859/FN-7878)**: `HeartbeatTriggerScheduler` keeps timers armed for durable heartbeat-managed agents in `state:"error"` when `lastError` is recoverable: generic/unknown errors and transient credential-rotation failures get the bounded retry budget, while operator-actionable credential/scope, quota/billing, and model-access failures do not restart. Stale worktree/module-resolution errors also skip naive retry recovery so the dedicated stale-host/worktree suppression path can handle them. `HeartbeatMonitor.executeHeartbeat()` clears recoverable errors at run entry (`error → active`, clears `lastError`), increments a consecutive `metadata.heartbeatErrorRecovery` counter, and emits `agent:auto-recover-error-state`; success resets the counter. Once the bounded budget (`MAX_HEARTBEAT_ERROR_RECOVERY_ATTEMPTS`, settings-overridable) is exhausted, the agent is parked `paused` with `pauseReason:"error-retry-exhausted"` and `agent:error-retry-exhausted` is emitted. Non-recoverable durable heartbeat errors are parked `paused` with `pauseReason:"error-unrecoverable"` and `agent:error-parked-unrecoverable` rather than remaining indefinitely in bare `error`. @@ -2291,7 +2289,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events. - **Bare branch-collision recovery (FN-8132)**: after stale lock/registration recovery, `NativeWorktreeBackend.create` classifies a `git worktree add -b` “branch already exists” error even when its requested target path is absent. It attaches an unregistered branch only when every unique commit is attributed to the requesting task, recreates merged/subsumed or no-unique-work branches from the caller-pinned start point, and refuses foreign, unattributed, or mixed unique history without moving its ref. A live foreign worktree remains a `BranchConflictError`; recovery dispositions emit `worktree:branch-collision-recovery`. - **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class). -- **Scheduler fanout comparator (FN-4969):** the core still exposes a priority-and-fanout comparator for callers that explicitly need dependency-unblock weighting, with `urgent` always ahead of lower priorities and overlap/file-scope blockers excluded from unblock weight. Workflow hold/release dispatch does not use it: its live fairness order is priority, then `createdAt`, then task ID after eligibility filters. +- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight. - **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat. - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. - **Verified no-op/duplicate executor completion (FN-6275/FN-7488)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`), the task already carries a no-commit contract, or the PROMPT declares a source-free gitignored task-artifact delivery. The source-free path is intentionally narrow: File Scope must be populated and limited to board/task artifacts such as `.fusion/tasks/...`, task documents/logs, or attachments; the prompt must forbid force-adding ignored `.fusion/` artifacts and fabricating empty commits or equivalently state that source-free/gitignored task artifacts are the only deliverables; and any tracked source/docs/config/test/changeset scope keeps the `no_commits` refusal active (even if `.fusion/` artifacts are also listed). These exemptions only relax the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Prompt-derived source-free completions log `prompt-derived source-free task-artifact contract` for operator audit. Ordinary zero-commit implementation completions without one of these contracts are still refused. @@ -2381,6 +2379,13 @@ Workflow session capacity is acquired separately from heartbeat capacity and rel A scheduler pass batches missing task workflow selections once and shares a strictly pass-scoped selection cache with hold-release and reservation resolution. The cache is never retained because selections are mutable. Only one sweep per project identity may run: concurrent calls are skipped, not joined, since their clocks, budgets, and slot reservations are caller-owned. Each sweep has a 10-second default budget. It does not claim to cancel database calls: after the deadline it starts no further sweep-owned await, while an in-flight read or started release can overrun and is reported as `budgetOverrunMs`. Dependency evaluation returns `{ satisfied, truncated }`; truncation is `sweep-budget-exhausted` only for an already-classified dependency hold, never `deps-unsatisfied`, and does not reset its held clock. Cards not reached are represented only by `unevaluatedCount`, never fabricated hold reasons. A truncated sweep never releases on partial evidence and never prunes an unreached card's held clock. +## Spec-lock and deterministic drift reports + +An accepted `PROMPT.md` is preserved as an immutable, project-scoped spec-lock. The canonical current-plan evidence parser covers `Mission`, `File Scope`, `Steps`, `Completion/Acceptance Criteria`, `Do NOT/Non-goals`, `Dependencies`, and lineage headings. `Mission` is a whitespace-normalized structural hash, never semantic interpretation: missing, empty, or duplicate Mission sections make comparison `unavailable` with a fixed reason. + +Each authoritative prompt write reaches `PROMPT.md` before its task-row approval invalidation, then serializes evidence capture and reconciliation under the planning lifecycle lock. Current-plan evidence structurally binds the live task dependency set plus mission, slice, and parent-task lineage IDs as well as the prompt sections, so a dependency or lineage mutation appends comparable evidence even when `PROMPT.md` bytes are unchanged. Manual approval and a successful workflow-graph Plan Review create/reuse the lock before publishing acceptance evidence consumed by scheduling. If evidence persistence is interrupted after the file/row write, the inactive task remains safely unreleased; startup or event reconciliation re-reads `PROMPT.md`, appends the missing comparable revision, and reports drift. Re-approval reuses identical content or appends a lock with a prior-version diff; a clean later lock after an earlier retained divergence reports `diverged-relocked-approved` rather than losing that history. Drift reports compare the latest lock with current evidence and execution file paths, producing only machine-observable `scope-creep`, `silent-expansion`, or `plan-deviation` findings. Added File Scope boundaries, dependencies, lineage, durable steps, and acceptance criteria are silent expansion; reordered steps and changed Mission hashes are plan deviation. Reports are retained independently of live task rows, so archive, cleanup, and unarchive retain operator history. + +The evaluator fences the latest lock version, current-plan evidence version/hash, approval fingerprint, and execution fingerprint before insertion. A stale candidate is re-evaluated from a fresh snapshot; project-engine task create, update, and move events enqueue a coalesced fresh comparison, while failed report persistence schedules a retry and shutdown cancels queued work and timers. Read APIs derive `activeLock` from the live fingerprint and current-plan hash, and return a current report only when its lock/current-plan/approval identities match the latest snapshots; older reports remain history rather than falsely showing `on-plan` after a dependency or lineage invalidation. Reports and run-audit events store hashes, IDs, and fixed outcomes only, never prompt or Mission prose. Drift is neither an admission nor quality/completion decision and does not move or fail work. ## Durable intake executor ownership diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a6e1a89a3f..f2daa2b5fb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2431,3 +2431,6 @@ In **Settings → Merge**, pull-request mode offers **Required pull-request chec ### Durable agent activity telemetry Activity includes durable and ephemeral agent sessions from heartbeat, executor, workflow-step, triage, reviewer, and merger lanes. **Sessions** is the sum of CLI session rows and `usage_events` session-start rows in the `agent-session` category; each session class has one writer. Human chat and mailbox turns supply `user_message` events. Active nodes remains zero on a single-node installation when no mesh routing node id exists. +## Plan alignment in Task Detail + +The shared Task Detail Definition view shows the persisted spec alignment, latest lock/current-plan versions, and deterministic finding categories. `activeLock` is derived from the live approval fingerprint and current-plan hash; an unavailable or inactive lock is not presented as on-plan. A historical report from a prior lock or plan revision stays in retained history and displays as unavailable until a matching current report exists. Findings are structural; `mission-statement` identifies a changed Mission narrative hash without displaying or judging its prose. The same shared content is used by modal and right-dock task detail hosts. diff --git a/docs/missions.md b/docs/missions.md index f30bf3ee63..4b9f415a1c 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -763,3 +763,7 @@ Autonomous no-task heartbeat agents may create or delegate implementation work o Automatic feature validation is content-addressed by landed SHA, resolved judge provider/model, and exact built prompts. Admission is atomic per project, feature, and fingerprint: a matching running run is not duplicated; the latest terminal history is selected deterministically; static-only passes are reused; and matching failures permit at most three dispatched runs before the feature is blocked. Behavioral or mixed assertions never reuse a pass, but failures are still budgeted. Every automatic suppression appends one visible `validation memoized` activity event (`running`, `reuse-pass`, or `budget-exhausted`) with fingerprint and referenced run ID where available. Initial exhaustion additionally appends one `validation-stuck` event; later unchanged sweeps append only their memoized event. No synthetic validator run or verdict is created for reuse or exhaustion. Missing landed SHA, fallback checkout, unknown judge identity, and preparation failures fail open to ordinary validation; `error`/`blocked` outcomes are transient. Manual validation bypasses memoization and the budget. Recovery revisits only a feature bearing FN-8694's budget-block provenance: unchanged inputs remain blocked, while a changed prepared fingerprint can be admitted; unrelated blocked/remediation/operator states stay closed. + +## Spec alignment + +A linked task may expose a separate spec alignment signal: `on-plan`, `diverged-needs-review`, `diverged-relocked-approved`, or `unavailable`. This signal is independent of feature delivery and validation status; it never marks a feature done, blocks a task, or substitutes for assertion validation. Archived tasks retain their task-visible lock history but follow the existing unlink behavior and do not recreate a feature projection. diff --git a/packages/core/src/__tests__/planner/spec-lock.test.ts b/packages/core/src/__tests__/planner/spec-lock.test.ts new file mode 100644 index 0000000000..7d1c010e53 --- /dev/null +++ b/packages/core/src/__tests__/planner/spec-lock.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSpecDrift, hasPriorLockDivergence, isCurrentSpecDriftReport } from "../../planner/drift-report.js"; +import { canonicalizePlan, createCurrentPlanEvidence, diffSpecLocks, isSpecLockActive } from "../../planner/spec-lock.js"; + +const prompt = `# Task\n\n## Mission\n\nBuild a safe widget.\n\n## File Scope\n\n- src/widget.ts\n\n## Steps\n\n1. Build widget\n\n## Completion Criteria\n\n- [ ] Widget works\n\n## Do NOT\n\n- Change API\n\n## Dependencies\n\n- FN-1\n`; +const evidence = (text = prompt, version = 1) => createCurrentPlanEvidence({ version, sourceRevision: version, capturedAt: "2026-08-09T07:06:00.000Z", prompt: text }); +const lock = (current = evidence()) => ({ version: 1, acceptedAt: "2026-08-09T07:06:00.000Z", approvalFingerprint: "approved", currentPlanVersion: current.version, currentPlanHash: current.plan.contentHash!, plan: current.plan }); + +describe("spec lock canonicalization", () => { + it("normalizes Mission whitespace but preserves a structural Mission rewrite", () => { + expect(canonicalizePlan(prompt).sections.mission.hash).toBe(canonicalizePlan(prompt.replace("Build a safe widget.", " Build a safe widget. ")).sections.mission.hash); + expect(canonicalizePlan(prompt).sections.mission.hash).not.toBe(canonicalizePlan(prompt.replace("safe", "different")).sections.mission.hash); + }); + + it.each(["", "\n## File Scope\n\n- a.ts", `${prompt}\n## Mission\n\nDuplicate`])("makes missing, empty, and duplicate Mission unavailable", (text) => { + expect(canonicalizePlan(text).status).toBe("unavailable"); + }); + + it("matches recursive glob boundaries without corrupting double-stars", () => { + const recursive = evidence(prompt.replace("src/widget.ts", "src/**/*.ts")); + expect(evaluateSpecDrift({ latestLock: lock(recursive), currentPlan: recursive, modifiedFiles: ["src/widget.ts", "src/deep/widget.ts"] }).findings).toEqual([]); + }); + + it("normalizes File Scope separators before comparing execution paths", () => { + const windowsScope = evidence(prompt.replace("src/widget.ts", "src\\widget.ts")); + expect(evaluateSpecDrift({ latestLock: lock(windowsScope), currentPlan: windowsScope, modifiedFiles: ["src/widget.ts"] }).findings).toEqual([]); + }); + + it("identifies deterministic Mission deviation and file scope creep", () => { + const current = evidence(prompt.replace("safe widget", "different widget"), 2); + const report = evaluateSpecDrift({ latestLock: lock(), currentPlan: current, modifiedFiles: ["src/other.ts"] }); + expect(report.alignment).toBe("diverged-needs-review"); + expect(report.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "plan-deviation", category: "mission-statement" }), + expect.objectContaining({ kind: "scope-creep", path: "src/other.ts" }), + ])); + }); + + it("flags additive boundaries as silent expansion and retains a stable diff", () => { + const current = evidence(prompt.replace("- src/widget.ts", "- src/widget.ts\n- src/extra.ts"), 2); + expect(evaluateSpecDrift({ latestLock: lock(), currentPlan: current }).findings).toContainEqual(expect.objectContaining({ kind: "silent-expansion", category: "file-scope" })); + expect(diffSpecLocks(lock().plan, current.plan).changedSections).toEqual(["file-scope"]); + }); + + it("makes live dependency and lineage mutations comparable current-plan revisions", () => { + const accepted = createCurrentPlanEvidence({ + version: 1, + sourceRevision: 1, + capturedAt: "2026-08-09T07:06:00.000Z", + prompt, + bindings: { dependencies: ["FN-1"], missionId: "M-1", sliceId: "S-1", sourceParentTaskId: "FN-PARENT" }, + }); + const changed = createCurrentPlanEvidence({ + version: 2, + sourceRevision: 2, + capturedAt: "2026-08-09T07:07:00.000Z", + prompt, + bindings: { dependencies: ["FN-1", "FN-2"], missionId: "M-2", sliceId: "S-1", sourceParentTaskId: "FN-PARENT" }, + }); + const permuted = createCurrentPlanEvidence({ + version: 3, + sourceRevision: 3, + capturedAt: "2026-08-09T07:08:00.000Z", + prompt, + bindings: { dependencies: ["FN-2", "FN-1", "FN-1"], missionId: "M-2", sliceId: "S-1", sourceParentTaskId: "FN-PARENT" }, + }); + const report = evaluateSpecDrift({ latestLock: lock(accepted), currentPlan: changed }); + expect(changed.sourceHash).not.toBe(accepted.sourceHash); + expect(permuted.sourceHash).toBe(changed.sourceHash); + expect(report.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "silent-expansion", category: "dependencies" }), + expect.objectContaining({ kind: "plan-deviation", category: "lineage" }), + ])); + }); + + it.each([ + ["Steps", "1. Build widget\n2. Add another widget step", "steps"], + ["Completion Criteria", "- [ ] Widget works\n- [ ] Another criterion", "acceptance-criteria"], + ])("classifies added %s items as silent expansion", (heading, replacement, category) => { + const current = evidence(prompt.replace(heading === "Steps" ? "1. Build widget" : "- [ ] Widget works", replacement), 2); + expect(evaluateSpecDrift({ latestLock: lock(), currentPlan: current }).findings).toContainEqual( + expect.objectContaining({ kind: "silent-expansion", category }), + ); + }); + + it("treats reordered durable steps as a plan deviation", () => { + const expandedPrompt = prompt.replace("1. Build widget", "1. Build widget\n2. Add setup"); + const current = evidence(expandedPrompt, 2); + const reordered = evidence(expandedPrompt.replace("1. Build widget\n2. Add setup", "1. Add setup\n2. Build widget"), 3); + expect(evaluateSpecDrift({ latestLock: lock(current), currentPlan: reordered }).findings).toContainEqual( + expect.objectContaining({ kind: "plan-deviation", category: "steps" }), + ); + }); + + it("reports malformed retained evidence as unavailable rather than clean", () => { + const current = evidence(); + delete (current.plan.sections as Partial).steps; + expect(evaluateSpecDrift({ latestLock: lock(), currentPlan: current, approvedPlanFingerprint: "approved" })).toMatchObject({ + status: "unavailable", + reason: "malformed-plan-evidence", + alignment: "unavailable", + }); + }); + + it("is on-plan only for matching active approval", () => { + const current = evidence(); + const active = evaluateSpecDrift({ latestLock: lock(), currentPlan: current, approvedPlanFingerprint: "approved" }); + expect(active.alignment).toBe("on-plan"); + expect(active.approvedPlanFingerprint).toBe("approved"); + expect(isSpecLockActive(lock(), current, "approved")).toBe(true); + expect(isSpecLockActive(lock(), current, undefined)).toBe(false); + expect(evaluateSpecDrift({ latestLock: lock(), currentPlan: evidence() }).alignment).toBe("unavailable"); + }); + + it("retains historical divergence after a clean newer lock is re-approved", () => { + const relockedPlan = evidence(prompt.replace("safe widget", "re-scoped widget"), 2); + const relock = { + ...lock(relockedPlan), + version: 2, + approvalFingerprint: "re-approved", + priorVersion: 1, + }; + expect(evaluateSpecDrift({ + latestLock: relock, + currentPlan: relockedPlan, + approvedPlanFingerprint: "re-approved", + priorDivergence: true, + }).alignment).toBe("diverged-relocked-approved"); + }); + + it("derives prior divergence from all retained lock versions", () => { + const current = evidence(); + const currentLock = lock(current); + const clean = evaluateSpecDrift({ latestLock: currentLock, currentPlan: current, approvedPlanFingerprint: "approved" }); + const unavailable = evaluateSpecDrift({}); + const divergence = evaluateSpecDrift({ + latestLock: currentLock, + currentPlan: evidence(prompt.replace("safe widget", "different widget"), 2), + approvedPlanFingerprint: "approved", + }); + + expect(hasPriorLockDivergence([], currentLock.version)).toBe(false); + expect(hasPriorLockDivergence([unavailable], undefined)).toBe(false); + expect(hasPriorLockDivergence([divergence], currentLock.version)).toBe(false); + expect(hasPriorLockDivergence([{ ...divergence, lockVersion: 0 }, { ...divergence, lockVersion: 0 }], currentLock.version)).toBe(true); + expect(hasPriorLockDivergence([unavailable, clean], currentLock.version)).toBe(false); + }); + + it("does not expose a report from an older current-plan revision as current", () => { + const first = evidence(prompt, 1); + const originalLock = lock(first); + const report = evaluateSpecDrift({ latestLock: originalLock, currentPlan: first, approvedPlanFingerprint: "approved" }); + const rewritten = evidence(prompt.replace("safe widget", "changed widget"), 2); + expect(isCurrentSpecDriftReport(report, originalLock, rewritten, "approved")).toBe(false); + expect(isCurrentSpecDriftReport(report, originalLock, first, "approved")).toBe(true); + expect(isCurrentSpecDriftReport(report, originalLock, first, undefined)).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index ebb67566e5..0405a7e2c6 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -92,6 +92,8 @@ import { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -117,7 +119,9 @@ describe("schema-applier: immutable migration identities", () => { expect(TASK_RECOMMENDATIONS_VERSION).toBe("0047"); expect(GITHUB_CHECK_STATES_VERSION).toBe("0048"); expect(AGENT_ACTIVITY_EVENTS_VERSION).toBe("0049"); - expect(SCHEMA_BASELINE_VERSION).toBe("0049"); + expect(SPEC_LOCK_DRIFT_REPORT_VERSION).toBe("0050"); + expect(SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION).toBe("0051"); + expect(SCHEMA_BASELINE_VERSION).toBe("0051"); }); it("keeps monitor and approval isolation assigned to version 0003", () => { @@ -732,7 +736,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = null; }); - it("creates all 109 project tables, 17 central tables, 1 archive table", async () => { + it("creates all 112 project tables, 17 central tables, 1 archive table", async () => { ctx = await setupFreshDb(); // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // applySchemaBaseline now runs the plugin schema-init hooks by default, @@ -748,13 +752,15 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", `)) as unknown as Array<{ table_schema: string; n: number }>; const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n])); /* - FNXC:AgentActivityStream 2026-08-09-21:32: + FNXC:PgSchemaApplier 2026-08-03-02:16: Project table count = historical core baseline plus later migrations. 0040 adds 2 lifecycle outbox tables; 0041 adds 4 lifecycle consumer tables; 0043 adds the durable unplanned-dispatch - refusal marker (100 → 105); 0048 adds GitHub check state (→ 107); 0049 adds the agent-activity outbox and counter (→ 109). - Plugin tables are added separately by the schema-init hook and are excluded here. + refusal marker (100 → 105); later baseline additions bring the count to 106; and 0048 adds + GitHub check state (106 → 107); 0049 adds the agent-activity outbox and counter (→ 109); + 0050 adds immutable lock, evidence, and report history (109 → 112). Plugin tables are added separately + by the schema-init hook and are excluded here. */ - expect(bySchema.project).toBe(109); + expect(bySchema.project).toBe(112); /* FNXC:CapacityModel 2026-07-29-08:10 (drop the cross-project cap — table half): 17, not 18: `central.global_concurrency` is dropped by migration 0037. A fresh @@ -1780,6 +1786,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1855,6 +1863,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, ]); }); @@ -2063,6 +2073,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, ]); }); @@ -2152,6 +2164,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, ]); }); @@ -2241,6 +2255,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_RECOMMENDATIONS_VERSION, GITHUB_CHECK_STATES_VERSION, AGENT_ACTIVITY_EVENTS_VERSION, + SPEC_LOCK_DRIFT_REPORT_VERSION, + SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION, ]); }); }); @@ -2265,7 +2281,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with seque WHERE n.nspname = 'project' AND a.attidentity = 'a' ORDER BY c.relname `)) as unknown as Array<{ table_name: string; column_name: string }>; - // The 8 AUTOINCREMENT columns from the SQLite schema. + // The 9 identity columns include immutable spec-drift report history. const identityTables = rows.map((r) => r.table_name); expect(identityTables).toEqual( expect.arrayContaining([ diff --git a/packages/core/src/__tests__/postgres/task-dependency-mutation.pg.test.ts b/packages/core/src/__tests__/postgres/task-dependency-mutation.pg.test.ts index f95ece8550..062ddab1e1 100644 --- a/packages/core/src/__tests__/postgres/task-dependency-mutation.pg.test.ts +++ b/packages/core/src/__tests__/postgres/task-dependency-mutation.pg.test.ts @@ -21,6 +21,8 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "../../workflows/builtin-coding-workf const pgTest = pgDescribe; +const SPEC_LOCK_PROMPT = `# Task\n\n## Mission\n\nKeep dependency scope observable.\n\n## File Scope\n\n- packages/core/src/store.ts\n\n## Steps\n\n1. Preserve evidence\n\n## Completion Criteria\n\n- [ ] Evidence is retained\n\n## Do NOT\n\n- Hide dependency changes\n\n## Dependencies\n\n- None\n`; + pgTest("TaskStore dependency mutations (PostgreSQL)", () => { const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_dep_mut", @@ -37,6 +39,47 @@ pgTest("TaskStore dependency mutations (PostgreSQL)", () => { afterEach(h.afterEach); + it("captures a comparable drift revision after a live dependency mutation", async () => { + const prerequisite = await store.createTask({ description: "locked prerequisite", column: "done" }); + const dependent = await store.createTask({ description: "locked dependent", column: "todo" }); + await store.updateTask(dependent.id, { prompt: SPEC_LOCK_PROMPT }); + await store.lockCurrentPlan(dependent.id, "approved-dependency-scope", SPEC_LOCK_PROMPT); + await store.updateTask(dependent.id, { approvedPlanFingerprint: "approved-dependency-scope" }); + + await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: prerequisite.id }); + + const [current, report] = await Promise.all([ + store.getLatestCurrentPlanEvidence(dependent.id), + store.getLatestSpecDriftReport(dependent.id), + ]); + expect(current?.version).toBe(2); + expect(current?.plan.sections.dependencies.canonical).toContain(`task-dependency:${prerequisite.id}`); + expect(report).toEqual(expect.objectContaining({ + alignment: "diverged-needs-review", + findings: expect.arrayContaining([expect.objectContaining({ kind: "silent-expansion", category: "dependencies" })]), + })); + }); + + it("captures a comparable drift revision after a live lineage mutation", async () => { + const task = await store.createTask({ description: "locked lineage", column: "todo" }); + await store.updateTask(task.id, { prompt: SPEC_LOCK_PROMPT }); + await store.lockCurrentPlan(task.id, "approved-lineage-scope", SPEC_LOCK_PROMPT); + await store.updateTask(task.id, { approvedPlanFingerprint: "approved-lineage-scope" }); + + await store.updateTask(task.id, { missionId: "M-re-scoped", sliceId: "S-re-scoped" }); + + const [current, report] = await Promise.all([ + store.getLatestCurrentPlanEvidence(task.id), + store.getLatestSpecDriftReport(task.id), + ]); + expect(current?.version).toBe(2); + expect(current?.plan.sections.lineage.canonical).toContain("mission:M-re-scoped"); + expect(report).toEqual(expect.objectContaining({ + alignment: "diverged-needs-review", + findings: expect.arrayContaining([expect.objectContaining({ kind: "silent-expansion", category: "lineage" })]), + })); + }); + it("replaces an obsolete dependency and clears stale blockers when the replacement is done", async () => { const obsolete = await store.createTask({ description: "obsolete prerequisite" }); const canonical = await store.createTask({ description: "canonical prerequisite", column: "done" }); @@ -127,6 +170,37 @@ pgTest("TaskStore dependency mutations (PostgreSQL)", () => { expect((await store.getWorkflowWorkItem(pending.id))?.state).toBe("cancelled"); }); + /* + FNXC:SpecLock 2026-08-09-20:34: + A mission/slice link is planning lineage. It must retire the same acceptance projection as a + dependency mutation even though no dependency is added and the task remains in its current lane. + */ + it("invalidates accepted plan evidence when mission lineage changes", async () => { + const task = await store.createTask({ description: "lineage mutation" }); + await store.updateTask(task.id, { + approvedPlanFingerprint: "sha256:accepted", + workflowStepResults: [{ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "passed", + completedAt: "2026-08-09T20:34:00.000Z", + }], + }); + + const updated = await store.updateTask(task.id, { missionId: "M-locked", sliceId: "S-locked" }); + + expect(updated.missionId).toBe("M-locked"); + expect(updated.sliceId).toBe("S-locked"); + expect(updated.status).toBe("needs-replan"); + expect(updated.approvedPlanFingerprint).toBeUndefined(); + expect(updated.workflowStepResults).toEqual([expect.objectContaining({ + workflowStepId: "plan-review", + status: "passed", + supersededAt: expect.any(String), + supersededReason: "dependency-change", + })]); + }); + it("keeps invalidation and continuation cancellation authoritative in a combined updateTask patch", async () => { const prerequisite = await store.createTask({ description: "combined prerequisite", column: "done" }); const dependent = await store.createTask({ description: "combined dependent", column: "todo" }); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 66dd930227..81bc4ff1f6 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -86,6 +86,10 @@ export { } from "./plugins/plugin-prompt-condition.js"; export type { PromptConditionEvaluationResult } from "./plugins/plugin-prompt-condition.js"; export { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js"; +export { canonicalizePlan, createCurrentPlanEvidence, diffSpecLocks, isSpecLockActive, SPEC_LOCK_PARSER_VERSION } from "./planner/spec-lock.js"; +export { evaluateSpecDrift, hasPriorLockDivergence, isCurrentSpecDriftReport } from "./planner/drift-report.js"; +export type { CanonicalPlan, CanonicalPlanSection, CurrentPlanEvidence, SpecLock, SpecLockDiff, SpecLockSection } from "./planner/spec-lock.js"; +export type { DriftAlignment, DriftFinding, DriftFindingCategory, DriftFindingKind, DriftReport } from "./planner/drift-report.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.js"; export { resolveNearDuplicateCanonicalFlags } from "./duplicates/near-duplicate-canonical-flags.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c0617709e..dd727f9f68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,10 @@ export { } from "./plugins/plugin-prompt-condition.js"; export type { PromptConditionEvaluationResult } from "./plugins/plugin-prompt-condition.js"; export { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js"; +export { canonicalizePlan, createCurrentPlanEvidence, diffSpecLocks, isSpecLockActive, SPEC_LOCK_PARSER_VERSION } from "./planner/spec-lock.js"; +export { evaluateSpecDrift, hasPriorLockDivergence, isCurrentSpecDriftReport } from "./planner/drift-report.js"; +export type { CanonicalPlan, CanonicalPlanSection, CurrentPlanEvidence, SpecLock, SpecLockDiff, SpecLockSection } from "./planner/spec-lock.js"; +export type { DriftAlignment, DriftFinding, DriftFindingCategory, DriftFindingKind, DriftReport } from "./planner/drift-report.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.js"; export { resolveNearDuplicateCanonicalFlags } from "./duplicates/near-duplicate-canonical-flags.js"; diff --git a/packages/core/src/planner/drift-report.ts b/packages/core/src/planner/drift-report.ts new file mode 100644 index 0000000000..4dda9532d0 --- /dev/null +++ b/packages/core/src/planner/drift-report.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import { isSpecLockActive, type CurrentPlanEvidence, type SpecLock, type SpecLockSection } from "./spec-lock.js"; + +export type DriftAlignment = "on-plan" | "diverged-needs-review" | "diverged-relocked-approved" | "unavailable"; +export type DriftFindingKind = "scope-creep" | "silent-expansion" | "plan-deviation"; +export type DriftFindingCategory = SpecLockSection | "mission-statement"; + +export interface DriftFinding { + kind: DriftFindingKind; + category: DriftFindingCategory; + priorHash?: string; + currentHash?: string; + path?: string; +} +export interface DriftReport { + lockVersion?: number; + currentPlanVersion?: number; + /** Canonical content hash fences a report to the precise plan revision evaluated. */ + currentPlanHash?: string; + /** + * FNXC:SpecDrift 2026-08-09-08:25: + * Approval state is an independent stale fence: a report cannot call a lock active after an + * approval invalidation races its evaluation. + */ + approvedPlanFingerprint?: string; + status: "available" | "unavailable"; + reason?: string; + findings: DriftFinding[]; + alignment: DriftAlignment; + executionHash: string; + reportHash: string; +} + +/** + * FNXC:SpecDrift 2026-08-10-09:28: + * Re-locking a plan must retain divergence from any earlier immutable lock. Derive this from the + * append-only report history, never only the newest report, so core storage and engine snapshots + * use exactly one predicate and cannot disagree about alignment. + */ +export function hasPriorLockDivergence(reports: readonly DriftReport[], latestLockVersion: number | undefined): boolean { + return reports.some((entry) => entry.alignment === "diverged-needs-review" && entry.lockVersion !== latestLockVersion); +} + +const hash = (value: unknown): string => createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +const normalizePath = (path: string): string => path.replace(/\\/g, "/").replace(/^\.\//, ""); +const matchesScope = (path: string, scope: string): boolean => { + // Preserve glob tokens while escaping literals: replacing `**` first and then `*` corrupts it. + const glob = normalizePath(scope); + let expression = ""; + for (let index = 0; index < glob.length; index += 1) { + if (glob[index] === "*" && glob[index + 1] === "*") { + // `**/` admits files directly below the preceding segment as well as nested paths. + if (glob[index + 2] === "/") { + expression += "(?:.*/)?"; + index += 2; + } else { + expression += ".*"; + index += 1; + } + } else if (glob[index] === "*") { + expression += "[^/]*"; + } else { + expression += glob[index]!.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${expression}$`).test(path); +}; + +/** + * FNXC:SpecDrift 2026-08-09-07:06: + * FN-8845 must make current-plan changes visible even after they invalidate approval. This pure + * evaluator never uses an LLM and reports unavailable rather than declaring malformed evidence clean. + */ +export function evaluateSpecDrift(input: { latestLock?: SpecLock; currentPlan?: CurrentPlanEvidence; approvedPlanFingerprint?: string; modifiedFiles?: string[]; priorDivergence?: boolean }): DriftReport { + const execution = [...new Set((input.modifiedFiles ?? []).map(normalizePath))].sort(); + const executionHash = hash(execution); + if (!input.latestLock || !input.currentPlan || input.latestLock.plan.status !== "available" || input.currentPlan.plan.status !== "available") { + const reason = input.currentPlan?.plan.reason ?? input.latestLock?.plan.reason ?? "lock-or-current-plan-missing"; + return report({ lockVersion: input.latestLock?.version, currentPlanVersion: input.currentPlan?.version, currentPlanHash: input.currentPlan?.plan.contentHash, approvedPlanFingerprint: input.approvedPlanFingerprint?.trim() || undefined, status: "unavailable", reason, findings: [], alignment: "unavailable", executionHash }); + } + /* + FNXC:SpecDrift 2026-08-09-19:01: + Retained snapshots are durable input, not trusted runtime objects. A partial/old row must produce + a fixed unavailable result rather than throw or accidentally omit a scope-bearing section. + */ + if (!hasComparableSections(input.latestLock) || !hasComparableSections(input.currentPlan)) { + return report({ lockVersion: input.latestLock.version, currentPlanVersion: input.currentPlan.version, currentPlanHash: input.currentPlan.plan.contentHash, approvedPlanFingerprint: input.approvedPlanFingerprint?.trim() || undefined, status: "unavailable", reason: "malformed-plan-evidence", findings: [], alignment: "unavailable", executionHash }); + } + const findings: DriftFinding[] = []; + for (const key of Object.keys(input.latestLock.plan.sections) as SpecLockSection[]) { + const prior = input.latestLock.plan.sections[key]; + const current = input.currentPlan.plan.sections[key]; + if (prior.hash === current.hash) continue; + const expanded = isStructuralExpansion(key, prior.canonical, current.canonical); + findings.push({ kind: expanded ? "silent-expansion" : "plan-deviation", category: key === "mission" ? "mission-statement" : key, priorHash: prior.hash, currentHash: current.hash }); + } + const scope = input.latestLock.plan.sections["file-scope"].canonical.split("\n").filter(Boolean); + for (const path of execution) if (scope.length > 0 && !scope.some((entry) => matchesScope(path, entry))) findings.push({ kind: "scope-creep", category: "file-scope", path }); + const active = isSpecLockActive(input.latestLock, input.currentPlan, input.approvedPlanFingerprint); + const alignment: DriftAlignment = findings.length > 0 ? "diverged-needs-review" : active ? (input.priorDivergence ? "diverged-relocked-approved" : "on-plan") : "unavailable"; + return report({ lockVersion: input.latestLock.version, currentPlanVersion: input.currentPlan.version, currentPlanHash: input.currentPlan.plan.contentHash, approvedPlanFingerprint: input.approvedPlanFingerprint?.trim() || undefined, status: "available", findings, alignment, executionHash }); +} + +/** + * FNXC:SpecDrift 2026-08-09-18:17: + * Added boundaries, goals, steps, and acceptance criteria are scope expansion even when the + * mutable prompt invalidated approval. Ordered steps stay strict: a reorder changes approach. + */ +function hasComparableSections(value: SpecLock | CurrentPlanEvidence): boolean { + const sections = value.plan.sections; + return ["mission", "file-scope", "steps", "acceptance-criteria", "non-goals", "dependencies", "lineage"].every((key) => { + const section = sections[key as SpecLockSection]; + /* FNXC:SpecDrift 2026-08-09-19:01: Optional absent sections canonicalize as an empty available + boundary and intentionally have no per-section hash; only non-empty sections need one. */ + return section?.status === "available" + && typeof section.canonical === "string" + && (section.canonical.length === 0 || typeof section.hash === "string"); + }); +} + +function isStructuralExpansion(key: SpecLockSection, prior: string, current: string): boolean { + if (!(key === "file-scope" || key === "dependencies" || key === "lineage" || key === "steps" || key === "acceptance-criteria")) return false; + const priorItems = prior.split("\n").filter(Boolean); + const currentItems = current.split("\n").filter(Boolean); + return currentItems.length > priorItems.length && priorItems.every((item, index) => key === "steps" ? currentItems[index] === item : currentItems.includes(item)); +} + +/** + * FNXC:SpecDrift 2026-08-09-19:19: + * Readers must not reuse an older clean report after a re-lock or prompt evidence revision. A + * report is current only when both retained identities still name the latest snapshots. + */ +export function isCurrentSpecDriftReport( + report: DriftReport | undefined, + latestLock: SpecLock | undefined, + currentPlan: CurrentPlanEvidence | undefined, + approvedPlanFingerprint?: string, +): report is DriftReport { + /* + FNXC:SpecDrift 2026-08-09-20:21: + Approval invalidation does not necessarily create a new current-plan revision (dependency and + lineage mutations retain the same prompt). Fence the approval fingerprint too, so readers never + present the prior active on-plan report after that acceptance evidence has been superseded. + */ + return report !== undefined + && report.lockVersion === latestLock?.version + && report.currentPlanVersion === currentPlan?.version + && report.currentPlanHash === currentPlan?.plan.contentHash + && report.approvedPlanFingerprint === (approvedPlanFingerprint?.trim() || undefined); +} + +function report(value: Omit): DriftReport { return { ...value, reportHash: hash(value) }; } diff --git a/packages/core/src/planner/spec-lock.ts b/packages/core/src/planner/spec-lock.ts new file mode 100644 index 0000000000..885bbc2040 --- /dev/null +++ b/packages/core/src/planner/spec-lock.ts @@ -0,0 +1,182 @@ +import { createHash } from "node:crypto"; + +export const SPEC_LOCK_PARSER_VERSION = 1; + +export type SpecLockSection = "mission" | "file-scope" | "steps" | "acceptance-criteria" | "non-goals" | "dependencies" | "lineage"; +export type SpecParseReason = "mission-missing" | "mission-empty" | "mission-duplicate" | "section-missing" | "section-duplicate"; +export type SpecParseStatus = "available" | "unavailable"; + +export interface CanonicalPlanSection { + status: SpecParseStatus; + reason?: SpecParseReason; + canonical: string; + hash?: string; +} + +export interface CanonicalPlan { + parserVersion: number; + sections: Record; + contentHash?: string; + status: SpecParseStatus; + reason?: SpecParseReason; +} + +export interface CurrentPlanEvidence { + version: number; + sourceRevision: number; + sourceHash: string; + capturedAt: string; + plan: CanonicalPlan; +} + +/** Live task relations are structural plan inputs alongside the persisted PROMPT.md source. */ +export interface PlanEvidenceBindings { + dependencies?: readonly string[]; + missionId?: string; + sliceId?: string; + sourceParentTaskId?: string; +} + +export interface SpecLock { + version: number; + acceptedAt: string; + approvalFingerprint: string; + currentPlanVersion: number; + currentPlanHash: string; + plan: CanonicalPlan; + priorVersion?: number; + diff?: SpecLockDiff; +} + +export interface SpecLockDiff { changedSections: SpecLockSection[]; } + +const sections: Array<{ key: SpecLockSection; headings: string[]; required: boolean }> = [ + { key: "mission", headings: ["mission"], required: true }, + { key: "file-scope", headings: ["file scope"], required: false }, + { key: "steps", headings: ["steps"], required: false }, + { key: "acceptance-criteria", headings: ["completion criteria", "acceptance criteria"], required: false }, + { key: "non-goals", headings: ["do not", "non-goals"], required: false }, + { key: "dependencies", headings: ["dependencies"], required: false }, + { key: "lineage", headings: ["mission lineage", "parent-child lineage"], required: false }, +]; + +const hash = (value: string): string => createHash("sha256").update(value, "utf8").digest("hex"); +const normalizeText = (value: string): string => value.replace(/\r\n?/g, "\n").replace(/[ \t]+/g, " ").trim(); +const normalizeListItems = (value: string): string[] => value.split("\n") + .map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").replace(/[ \t]+/g, " ").trim()) + .filter(Boolean); +const normalizeList = (value: string): string => [...new Set(normalizeListItems(value))].sort().join("\n"); +const normalizeOrderedList = (value: string): string => normalizeListItems(value).join("\n"); +const normalizedSection = (key: SpecLockSection, value: string): string => { + /* + FNXC:SpecLock 2026-08-09-19:01: + File Scope is a cross-platform boundary. Canonicalize path separators before set normalization so + equivalent Windows and POSIX declarations neither invalidate approval nor hide real scope creep. + */ + if (key === "file-scope") return normalizeList(value.replace(/\\/g, "/")); + if (key === "dependencies" || key === "lineage" || key === "acceptance-criteria" || key === "non-goals") return normalizeList(value); + if (key === "steps") return normalizeOrderedList(value); + return normalizeText(value); +}; + +/** + * FNXC:SpecLock 2026-08-09-07:06: + * FN-8845 compares only a fixed structural contract. Mission prose is normalized as text and + * hashed, never interpreted, so whitespace is cosmetic while a narrative rewrite is observable. + */ +export function canonicalizePlan(prompt: string, bindings?: PlanEvidenceBindings): CanonicalPlan { + const normalized = prompt.replace(/\r\n?/g, "\n"); + const headings = [...normalized.matchAll(/^##\s+(.+?)\s*$/gmi)].map((match) => ({ name: match[1].trim().toLowerCase(), start: match.index!, end: match.index! + match[0].length })); + const result = {} as Record; + for (const definition of sections) { + const matches = headings.filter((heading) => definition.headings.includes(heading.name)); + if (matches.length > 1) { + result[definition.key] = { status: "unavailable", reason: definition.key === "mission" ? "mission-duplicate" : "section-duplicate", canonical: "" }; + continue; + } + if (matches.length === 0) { + result[definition.key] = definition.required + ? { status: "unavailable", reason: definition.key === "mission" ? "mission-missing" : "section-missing", canonical: "" } + : { status: "available", canonical: "" }; + continue; + } + const heading = matches[0]; + const next = headings.find((candidate) => candidate.start > heading.start); + const canonical = normalizedSection(definition.key, normalized.slice(heading.end, next?.start).replace(/^\n+|\n+$/g, "")); + if (definition.required && !canonical) { + result[definition.key] = { status: "unavailable", reason: definition.key === "mission" ? "mission-empty" : "section-missing", canonical: "" }; + } else { + result[definition.key] = { status: "available", canonical, hash: hash(canonical) }; + } + } + /* + FNXC:SpecLock 2026-08-09-21:01: + Dependency and lineage writers can change the approved scope without rewriting PROMPT.md. Bind + their durable task-row values into the same canonical sections, so invalidation has a comparable + current-plan revision and cannot degrade into an inactive-but-clean lock. + */ + applyLivePlanBindings(result, bindings); + const unavailable = Object.values(result).find((section) => section.status === "unavailable"); + if (unavailable) return { parserVersion: SPEC_LOCK_PARSER_VERSION, sections: result, status: "unavailable", reason: unavailable.reason }; + const content = JSON.stringify(Object.fromEntries(Object.entries(result).map(([key, section]) => [key, section.hash]))); + return { parserVersion: SPEC_LOCK_PARSER_VERSION, sections: result, contentHash: hash(content), status: "available" }; +} + +export function diffSpecLocks(previous: CanonicalPlan, next: CanonicalPlan): SpecLockDiff { + return { changedSections: sections.map(({ key }) => key).filter((key) => previous.sections[key].hash !== next.sections[key].hash || previous.sections[key].status !== next.sections[key].status) }; +} + +/** + * FNXC:SpecLock 2026-08-09-19:19: + * A retained lock is only active when the task still carries the exact approval fingerprint and + * canonical current-plan hash it accepted. Keep this predicate shared by API and evaluator so an + * inactive lock cannot be rendered as on-plan after a prompt rewrite or approval invalidation. + */ +export function isSpecLockActive( + lock: SpecLock | undefined, + currentPlan: CurrentPlanEvidence | undefined, + approvedPlanFingerprint: string | undefined, +): lock is SpecLock { + return lock !== undefined + && currentPlan?.plan.contentHash === lock.currentPlanHash + && approvedPlanFingerprint?.trim() === lock.approvalFingerprint; +} + +function normalizedPlanEvidenceBindings(bindings: PlanEvidenceBindings | undefined): PlanEvidenceBindings { + const dependencies = [...new Set((bindings?.dependencies ?? []).map((value) => value.trim()).filter(Boolean))].sort(); + return { + ...(dependencies.length > 0 ? { dependencies } : {}), + ...(bindings?.missionId?.trim() ? { missionId: bindings.missionId.trim() } : {}), + ...(bindings?.sliceId?.trim() ? { sliceId: bindings.sliceId.trim() } : {}), + ...(bindings?.sourceParentTaskId?.trim() ? { sourceParentTaskId: bindings.sourceParentTaskId.trim() } : {}), + }; +} + +function applyLivePlanBindings( + sections: Record, + bindings: PlanEvidenceBindings | undefined, +): void { + const normalized = normalizedPlanEvidenceBindings(bindings); + const dependencies = (normalized.dependencies ?? []).map((value) => `task-dependency:${value}`); + const lineage = [ + normalized.missionId ? `mission:${normalized.missionId}` : undefined, + normalized.sliceId ? `slice:${normalized.sliceId}` : undefined, + normalized.sourceParentTaskId ? `parent-task:${normalized.sourceParentTaskId}` : undefined, + ].filter((value): value is string => Boolean(value)); + for (const [key, values] of [["dependencies", dependencies], ["lineage", lineage]] as const) { + if (values.length === 0 || sections[key].status !== "available") continue; + const canonical = normalizeList([sections[key].canonical, ...values].filter(Boolean).join("\n")); + sections[key] = { status: "available", canonical, hash: hash(canonical) }; + } +} + +export function createCurrentPlanEvidence(input: Omit & { prompt: string; bindings?: PlanEvidenceBindings }): CurrentPlanEvidence { + const { prompt, bindings, ...evidence } = input; + const normalizedBindings = normalizedPlanEvidenceBindings(bindings); + return { + ...evidence, + /* Bindings are part of the authoritative source identity: same prompt plus a dependency edit is a new revision. */ + sourceHash: hash(JSON.stringify({ prompt, bindings: normalizedBindings })), + plan: canonicalizePlan(prompt, normalizedBindings), + }; +} diff --git a/packages/core/src/postgres/migrations/0050_spec_lock_drift_report.sql b/packages/core/src/postgres/migrations/0050_spec_lock_drift_report.sql new file mode 100644 index 0000000000..28fd9d5874 --- /dev/null +++ b/packages/core/src/postgres/migrations/0050_spec_lock_drift_report.sql @@ -0,0 +1,29 @@ +-- FNXC:SpecLock 2026-08-09-07:06: immutable task-plan history is project-scoped and deliberately has no task FK, so archive/tombstone cleanup cannot erase approval evidence. +CREATE TABLE IF NOT EXISTS project.spec_locks ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), task_id text NOT NULL, version integer NOT NULL, + accepted_at text NOT NULL, approval_fingerprint text NOT NULL, current_plan_version integer NOT NULL, current_plan_hash text NOT NULL, + snapshot jsonb NOT NULL, prior_version integer, diff jsonb, PRIMARY KEY (project_id, task_id, version) +); +CREATE TABLE IF NOT EXISTS project.current_plan_evidence ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), task_id text NOT NULL, version integer NOT NULL, + source_revision bigint NOT NULL, source_hash text NOT NULL, captured_at text NOT NULL, snapshot jsonb NOT NULL, + PRIMARY KEY (project_id, task_id, version), UNIQUE (project_id, task_id, source_hash) +); +CREATE TABLE IF NOT EXISTS project.spec_drift_reports ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), task_id text NOT NULL, report_hash text NOT NULL, + lock_version integer, current_plan_version integer, current_plan_hash text, execution_hash text NOT NULL, report jsonb NOT NULL, created_at text NOT NULL, + PRIMARY KEY (project_id, task_id, report_hash) +); +CREATE INDEX IF NOT EXISTS idx_spec_locks_latest ON project.spec_locks(project_id, task_id, version DESC); +CREATE INDEX IF NOT EXISTS idx_current_plan_evidence_latest ON project.current_plan_evidence(project_id, task_id, version DESC); +CREATE INDEX IF NOT EXISTS idx_spec_drift_reports_latest ON project.spec_drift_reports(project_id, task_id, created_at DESC); +DO $$ DECLARE relation_name text; BEGIN + FOREACH relation_name IN ARRAY ARRAY['spec_locks', 'current_plan_evidence', 'spec_drift_reports'] LOOP + EXECUTE format('ALTER TABLE project.%I ENABLE ROW LEVEL SECURITY', relation_name); + EXECUTE format('ALTER TABLE project.%I FORCE ROW LEVEL SECURITY', relation_name); + EXECUTE format('DROP POLICY IF EXISTS fusion_project_isolation ON project.%I', relation_name); + EXECUTE format('CREATE POLICY fusion_project_isolation ON project.%I USING (current_setting(''fusion.project_bypass'', true) = ''on'' OR project_id = current_setting(''fusion.project_id'', true)) WITH CHECK (current_setting(''fusion.project_bypass'', true) = ''on'' OR project_id = current_setting(''fusion.project_id'', true))', relation_name); + EXECUTE format('DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.%I', relation_name); + EXECUTE format('CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.%I FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id()', relation_name); + END LOOP; +END $$; diff --git a/packages/core/src/postgres/migrations/0051_spec_lock_source_revision_bigint.sql b/packages/core/src/postgres/migrations/0051_spec_lock_source_revision_bigint.sql new file mode 100644 index 0000000000..70feba8ef7 --- /dev/null +++ b/packages/core/src/postgres/migrations/0051_spec_lock_source_revision_bigint.sql @@ -0,0 +1,3 @@ +-- FNXC:SpecLock 2026-08-09-17:37: current-plan source revisions use Date.now(), which exceeds PostgreSQL integer range; preserve append-only evidence by widening existing 0048 deployments. +ALTER TABLE project.current_plan_evidence + ALTER COLUMN source_revision TYPE bigint; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 2457dc1bf4..8b28749d50 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -58,7 +58,8 @@ capacity-model table drop that landed while this PR was open. /* FNXC:MissionValidation 2026-08-01-16:21: advance the schema ceiling before validator admission reads durable content fingerprints. */ /* FNXC:PrMergeEventDrivenChecks 2026-08-09-14:35: 0048 registers project-scoped GitHub CI check state. */ /** FNXC:AgentActivityStream 2026-08-09-21:32: 0049 follows the landed 0048 GitHub check-state migration so upgraded projects receive the durable activity outbox. */ -export const SCHEMA_BASELINE_VERSION = "0049"; +/* FNXC:SpecLock 2026-08-09-18:17: 0050 stores immutable plan history and 0051 widens source revisions before Date.now()-based writes. */ +export const SCHEMA_BASELINE_VERSION = "0051"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -199,6 +200,10 @@ export const TASK_RECOMMENDATIONS_VERSION = "0047"; export const GITHUB_CHECK_STATES_VERSION = "0048"; /** FNXC:AgentActivityStream 2026-08-09-21:32: Migration 0049 avoids the already-landed 0048 bookkeeping identity. */ export const AGENT_ACTIVITY_EVENTS_VERSION = "0049"; +/** FNXC:SpecLock 2026-08-09-18:17: immutable lock/report storage follows the already-landed activity migration. */ +export const SPEC_LOCK_DRIFT_REPORT_VERSION = "0050"; +/** FNXC:SpecLock 2026-08-09-18:17: widen source revisions before Date.now()-based current-plan writes overflow integer storage. */ +export const SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION = "0051"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -422,6 +427,8 @@ const WORKFLOW_PRINCIPAL_FENCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0046_fn_87 const TASK_RECOMMENDATIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0047_fn_8829_task_recommendations.sql"); const GITHUB_CHECK_STATES_MIGRATION_PATH = join(MIGRATIONS_DIR, "0048_fn_8903_github_check_states.sql"); const AGENT_ACTIVITY_EVENTS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0049_fn_8864_agent_activity_events.sql"); +const SPEC_LOCK_DRIFT_REPORT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0050_spec_lock_drift_report.sql"); +const SPEC_LOCK_SOURCE_REVISION_BIGINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0051_spec_lock_source_revision_bigint.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -541,6 +548,8 @@ export async function applySchemaBaseline( const taskRecommendationsAlreadyApplied = applied.includes(TASK_RECOMMENDATIONS_VERSION); const githubCheckStatesAlreadyApplied = applied.includes(GITHUB_CHECK_STATES_VERSION); const agentActivityEventsAlreadyApplied = applied.includes(AGENT_ACTIVITY_EVENTS_VERSION); + const specLockDriftReportAlreadyApplied = applied.includes(SPEC_LOCK_DRIFT_REPORT_VERSION); + const specLockSourceRevisionBigintAlreadyApplied = applied.includes(SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1185,6 +1194,20 @@ export async function applySchemaBaseline( await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AGENT_ACTIVITY_EVENTS_VERSION}) ON CONFLICT (version) DO NOTHING`); schemaChanged = true; } + + if (!specLockDriftReportAlreadyApplied) { + const migrationSql = await readFile(SPEC_LOCK_DRIFT_REPORT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SPEC_LOCK_DRIFT_REPORT_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + if (!specLockSourceRevisionBigintAlreadyApplied) { + const migrationSql = await readFile(SPEC_LOCK_SOURCE_REVISION_BIGINT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 32bacd192e..0c79deaa8e 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -351,6 +351,19 @@ export const tasks = projectSchema.table("tasks", { index("idxTasksSearchVector").using("gin", t.searchVector), ]); +/* FNXC:SpecLock 2026-08-09-07:06: plan locks, evidence, and reports omit task FKs so immutable history survives archive cleanup and task tombstones. */ +export const specLocks = projectSchema.table("spec_locks", { + projectId: text("project_id").notNull(), taskId: text("task_id").notNull(), version: integer("version").notNull(), + acceptedAt: text("accepted_at").notNull(), approvalFingerprint: text("approval_fingerprint").notNull(), currentPlanVersion: integer("current_plan_version").notNull(), currentPlanHash: text("current_plan_hash").notNull(), + snapshot: jsonb("snapshot").notNull(), priorVersion: integer("prior_version"), diff: jsonb("diff"), +}, (t) => [primaryKey({ columns: [t.projectId, t.taskId, t.version] })]); +export const currentPlanEvidence = projectSchema.table("current_plan_evidence", { + projectId: text("project_id").notNull(), taskId: text("task_id").notNull(), version: integer("version").notNull(), sourceRevision: bigint("source_revision", { mode: "number" }).notNull(), sourceHash: text("source_hash").notNull(), capturedAt: text("captured_at").notNull(), snapshot: jsonb("snapshot").notNull(), +}, (t) => [primaryKey({ columns: [t.projectId, t.taskId, t.version] }), unique("current_plan_evidence_source").on(t.projectId, t.taskId, t.sourceHash)]); +export const specDriftReports = projectSchema.table("spec_drift_reports", { + projectId: text("project_id").notNull(), taskId: text("task_id").notNull(), reportHash: text("report_hash").notNull(), lockVersion: integer("lock_version"), currentPlanVersion: integer("current_plan_version"), currentPlanHash: text("current_plan_hash"), executionHash: text("execution_hash").notNull(), report: jsonb("report").notNull(), createdAt: text("created_at").notNull(), +}, (t) => [primaryKey({ columns: [t.projectId, t.taskId, t.reportHash] })]); + // ── Config ─────────────────────────────────────────────────────────── export const config = projectSchema.table("config", { // FNXC:MultiProjectIsolation 2026-07-11: diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9e46391f7e..e2699cb813 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -4,11 +4,29 @@ import { TaskLaneCache } from "./task-lane-cache.js"; import { randomUUID } from "node:crypto"; import { WEDGE_RENOTIFY_COOLDOWN_MS } from "./types/task/task-core.js"; import { join } from "node:path"; -import { and, eq, isNull, ne, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, ne, sql } from "drizzle-orm"; +import { createCurrentPlanEvidence, diffSpecLocks, isSpecLockActive, type CurrentPlanEvidence, type PlanEvidenceBindings, type SpecLock } from "./planner/spec-lock.js"; +import { evaluateSpecDrift, hasPriorLockDivergence, type DriftReport } from "./planner/drift-report.js"; import * as schema from "./postgres/schema/index.js"; import { type FSWatcher } from "node:fs"; +import { readFile } from "node:fs/promises"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, TaskDeleteClosureContext, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js"; +/* +FNXC:SpecLock 2026-08-09-21:01: +Current-plan evidence includes durable scope relations that can mutate independently of PROMPT.md. +Keep this projection small and structural; it captures IDs only and never copies prompt prose into +telemetry or a mutable task field. +*/ +function specLockBindings(task: Pick): PlanEvidenceBindings { + return { + dependencies: task.dependencies ?? [], + missionId: task.missionId, + sliceId: task.sliceId, + sourceParentTaskId: task.sourceParentTaskId, + }; +} + export type OverlapBlockerRepairReason = | "task-not-found" | "no-overlap-blocker" @@ -59,7 +77,7 @@ export const WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX = "workflow:"; import { GlobalSettingsStore } from "./config/global-settings.js"; import { Database } from "./db/db.js"; import { ArchiveDatabase } from "./db/archive-db.js"; -import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "./postgres/data-layer.js"; import { withPlanningLifecycleAdvisoryLock } from "./postgres/advisory-locks.js"; import { MissionStore } from "./missions/mission-store.js"; import { AsyncMissionStore } from "./async-stores/async-mission-store.js"; @@ -959,8 +977,8 @@ export class TaskStore extends EventEmitter { public async atomicWriteTaskJson(dir: string, task: Task): Promise { return atomicWriteTaskJsonImpl2(this, dir, task); } - public async atomicWriteTaskJsonWithAudit( dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation, ): Promise { - return atomicWriteTaskJsonWithAuditImpl(this, dir, task, auditInput, planningInvalidation); + public async atomicWriteTaskJsonWithAudit( dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation, specPlanPrompt?: string, ): Promise { + return atomicWriteTaskJsonWithAuditImpl(this, dir, task, auditInput, planningInvalidation, specPlanPrompt); } /* FNXC:TaskTiming 2026-07-15-00:00: @@ -1147,6 +1165,216 @@ export class TaskStore extends EventEmitter { async refineTask(id: string, feedback: string): Promise { return refineTaskImpl(this, id, feedback); } + /** + * FNXC:SpecLock 2026-08-09-07:06: + * FN-8845 retains plan snapshots outside the mutable task row. Backend-only storage is deliberate: + * SQLite is no longer a runtime path and silently falling back would lose audit history on restart. + */ + async appendCurrentPlanEvidence(taskId: string, evidence: import("./planner/spec-lock.js").CurrentPlanEvidence): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const projectId = this.asyncLayer.projectId ?? ""; + await this.asyncLayer.db.insert(schema.project.currentPlanEvidence).values({ projectId, taskId, version: evidence.version, sourceRevision: evidence.sourceRevision, sourceHash: evidence.sourceHash, capturedAt: evidence.capturedAt, snapshot: evidence }).onConflictDoNothing(); + const rows = await this.asyncLayer.db.select().from(schema.project.currentPlanEvidence).where(and(projectScopeFor(schema.project.currentPlanEvidence.projectId, this.asyncLayer.projectId), eq(schema.project.currentPlanEvidence.taskId, taskId), eq(schema.project.currentPlanEvidence.sourceHash, evidence.sourceHash))).limit(1); + return rows[0]!.snapshot as import("./planner/spec-lock.js").CurrentPlanEvidence; + } + async getLatestCurrentPlanEvidence(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.currentPlanEvidence).where(and(projectScopeFor(schema.project.currentPlanEvidence.projectId, this.asyncLayer.projectId), eq(schema.project.currentPlanEvidence.taskId, taskId))).orderBy(desc(schema.project.currentPlanEvidence.version)).limit(1); + return rows[0]?.snapshot as CurrentPlanEvidence | undefined; + } + /** + * FNXC:SpecLock 2026-08-09-12:34: + * FN-8845 records every authoritative full PROMPT write before it can be released. Content-hash + * dedupe makes retries idempotent while monotonically assigning versions to material rewrites. + */ + async captureCurrentPlanEvidence(taskId: string, prompt: string, sourceRevision = Date.now()): Promise { + return this.withPlanningLifecycleLock(taskId, () => this.captureCurrentPlanEvidenceWhilePlanningLocked(taskId, prompt, sourceRevision)); + } + /** + * FNXC:SpecLock 2026-08-09-19:01: + * An authoritative prompt update already owns the planning lifecycle lock before taking the + * task lock. Reuse that lock here so its current-plan revision cannot race acceptance or be + * published after a later re-lock. + */ + async captureCurrentPlanEvidenceWhilePlanningLocked( + taskId: string, + prompt: string, + sourceRevision = Date.now(), + liveTask?: Pick, + ): Promise { + return this.captureCurrentPlanEvidenceLocked(taskId, prompt, sourceRevision, liveTask); + } + private async captureCurrentPlanEvidenceLocked( + taskId: string, + prompt: string, + sourceRevision: number, + liveTask?: Pick, + ): Promise { + const [prior, task] = await Promise.all([this.getLatestCurrentPlanEvidence(taskId), liveTask ?? this.getTask(taskId)]); + const candidate = createCurrentPlanEvidence({ + version: (prior?.version ?? 0) + 1, + sourceRevision, + capturedAt: new Date().toISOString(), + prompt, + bindings: specLockBindings(task), + }); + if (prior?.sourceHash === candidate.sourceHash) return prior; + return this.appendCurrentPlanEvidence(taskId, candidate); + } + async appendSpecLock(taskId: string, lock: import("./planner/spec-lock.js").SpecLock): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const projectId = this.asyncLayer.projectId ?? ""; + await this.asyncLayer.db.insert(schema.project.specLocks).values({ projectId, taskId, version: lock.version, acceptedAt: lock.acceptedAt, approvalFingerprint: lock.approvalFingerprint, currentPlanVersion: lock.currentPlanVersion, currentPlanHash: lock.currentPlanHash, snapshot: lock.plan, priorVersion: lock.priorVersion, diff: lock.diff }).onConflictDoNothing(); + const rows = await this.asyncLayer.db.select().from(schema.project.specLocks).where(and(projectScopeFor(schema.project.specLocks.projectId, this.asyncLayer.projectId), eq(schema.project.specLocks.taskId, taskId), eq(schema.project.specLocks.version, lock.version))).limit(1); + const row = rows[0]!; + return { version: row.version, acceptedAt: row.acceptedAt, approvalFingerprint: row.approvalFingerprint, currentPlanVersion: row.currentPlanVersion, currentPlanHash: row.currentPlanHash, plan: row.snapshot as import("./planner/spec-lock.js").CanonicalPlan, priorVersion: row.priorVersion ?? undefined, diff: row.diff as import("./planner/spec-lock.js").SpecLockDiff | undefined }; + } + async getLatestSpecLock(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.specLocks).where(and(projectScopeFor(schema.project.specLocks.projectId, this.asyncLayer.projectId), eq(schema.project.specLocks.taskId, taskId))).orderBy(desc(schema.project.specLocks.version)).limit(1); + const row = rows[0]; + return row ? { version: row.version, acceptedAt: row.acceptedAt, approvalFingerprint: row.approvalFingerprint, currentPlanVersion: row.currentPlanVersion, currentPlanHash: row.currentPlanHash, plan: row.snapshot as import("./planner/spec-lock.js").CanonicalPlan, priorVersion: row.priorVersion ?? undefined, diff: row.diff as import("./planner/spec-lock.js").SpecLockDiff | undefined } : undefined; + } + /** + * FNXC:SpecLock 2026-08-09-19:19: + * The active lock is a derived live view, not a mutable column. Retained history stays append-only + * while API readers receive no active lock after a stale approval or current-plan rewrite. + */ + async getActiveSpecLock(taskId: string): Promise { + const [task, latestLock, currentPlan] = await Promise.all([ + this.getTask(taskId), + this.getLatestSpecLock(taskId), + this.getLatestCurrentPlanEvidence(taskId), + ]); + return isSpecLockActive(latestLock, currentPlan, task.approvedPlanFingerprint) ? latestLock : undefined; + } + /** Create or reuse the immutable lock for an already-persisted current-plan revision. */ + async lockCurrentPlan(taskId: string, approvalFingerprint: string, prompt: string): Promise { + return this.withPlanningLifecycleLock(taskId, () => this.lockCurrentPlanWhilePlanningLocked(taskId, approvalFingerprint, prompt)); + } + /** + * FNXC:SpecLockLifecycleLock 2026-08-09-17:37: + * Approval finalization already owns the non-reentrant PostgreSQL planning advisory lock. This + * companion avoids reacquiring it while preserving one serialized lock/current-plan append. + */ + async lockCurrentPlanWhilePlanningLocked(taskId: string, approvalFingerprint: string, prompt: string): Promise { + const currentPlan = await this.captureCurrentPlanEvidenceLocked(taskId, prompt, Date.now()); + if (currentPlan.plan.status !== "available" || !currentPlan.plan.contentHash) { + throw new Error(`Cannot lock an unavailable plan: ${currentPlan.plan.reason ?? "unknown"}`); + } + const prior = await this.getLatestSpecLock(taskId); + if (prior?.approvalFingerprint === approvalFingerprint && prior.currentPlanHash === currentPlan.plan.contentHash) return prior; + const lock: SpecLock = { version: (prior?.version ?? 0) + 1, acceptedAt: new Date().toISOString(), approvalFingerprint, currentPlanVersion: currentPlan.version, currentPlanHash: currentPlan.plan.contentHash, plan: currentPlan.plan, ...(prior ? { priorVersion: prior.version, diff: diffSpecLocks(prior.plan, currentPlan.plan) } : {}) }; + return this.appendSpecLock(taskId, lock); + } + async appendSpecDriftReport(taskId: string, report: DriftReport): Promise { + return this.withPlanningLifecycleLock(taskId, () => this.appendSpecDriftReportWhilePlanningLocked(taskId, report)); + } + /** + * FNXC:SpecDriftFence 2026-08-09-18:45: + * Report insertion shares the planning advisory lock with prompt writes, re-locks, and dependency + * invalidation. Re-check all snapshot identities while holding that lock; otherwise a stale worker + * can insert an on-plan result after the newer plan has already become authoritative. + */ + async appendSpecDriftReportWhilePlanningLocked(taskId: string, report: DriftReport): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const [task, latestLock, currentPlan] = await Promise.all([ + this.getTask(taskId), + this.getLatestSpecLock(taskId), + this.getLatestCurrentPlanEvidence(taskId), + ]); + const executionFence = evaluateSpecDrift({ latestLock, currentPlan, modifiedFiles: task.modifiedFiles }).executionHash; + if ( + report.lockVersion !== latestLock?.version + || report.currentPlanVersion !== currentPlan?.version + || report.currentPlanHash !== currentPlan?.plan.contentHash + || report.approvedPlanFingerprint !== (task.approvedPlanFingerprint?.trim() || undefined) + || report.executionHash !== executionFence + ) { + return this.reconcileSpecDriftWhilePlanningLocked(task); + } + return this.persistSpecDriftReport(taskId, report); + } + async listSpecLocks(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.specLocks).where(and(projectScopeFor(schema.project.specLocks.projectId, this.asyncLayer.projectId), eq(schema.project.specLocks.taskId, taskId))).orderBy(schema.project.specLocks.version); + return rows.map((row) => ({ version: row.version, acceptedAt: row.acceptedAt, approvalFingerprint: row.approvalFingerprint, currentPlanVersion: row.currentPlanVersion, currentPlanHash: row.currentPlanHash, plan: row.snapshot as import("./planner/spec-lock.js").CanonicalPlan, priorVersion: row.priorVersion ?? undefined, diff: row.diff as import("./planner/spec-lock.js").SpecLockDiff | undefined })); + } + async listCurrentPlanEvidence(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.currentPlanEvidence).where(and(projectScopeFor(schema.project.currentPlanEvidence.projectId, this.asyncLayer.projectId), eq(schema.project.currentPlanEvidence.taskId, taskId))).orderBy(schema.project.currentPlanEvidence.version); + return rows.map((row) => row.snapshot as CurrentPlanEvidence); + } + async listSpecDriftReports(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.specDriftReports).where(and(projectScopeFor(schema.project.specDriftReports.projectId, this.asyncLayer.projectId), eq(schema.project.specDriftReports.taskId, taskId))).orderBy(schema.project.specDriftReports.createdAt); + return rows.map((row) => row.report as DriftReport); + } + async getLatestSpecDriftReport(taskId: string): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const rows = await this.asyncLayer.db.select().from(schema.project.specDriftReports).where(and(projectScopeFor(schema.project.specDriftReports.projectId, this.asyncLayer.projectId), eq(schema.project.specDriftReports.taskId, taskId))).orderBy(desc(schema.project.specDriftReports.createdAt)).limit(1); + return rows[0]?.report as DriftReport | undefined; + } + /** Evaluate retained evidence from one store snapshot; reporting never changes task lifecycle. */ + async reconcileSpecDrift(task: Pick): Promise { + return this.withPlanningLifecycleLock(task.id, () => this.reconcileSpecDriftWhilePlanningLocked(task)); + } + /** + * FNXC:SpecLockLifecycleLock 2026-08-09-17:37: + * Reconciliation is part of approval's serialized handoff. Do not call the public wrapper from + * that handoff: PostgreSQL advisory locks are intentionally non-reentrant and would deadlock. + */ + async reconcileSpecDriftWhilePlanningLocked(task: Pick): Promise { + const live = await this.getTask(task.id); + let currentPlan = await this.getLatestCurrentPlanEvidence(task.id); + /* + FNXC:SpecLock 2026-08-09-19:01: + A prompt write can reach disk and invalidate approval before a transient database failure + appends its evidence. During later event/startup reconciliation, repair only that inactive + source mismatch from PROMPT.md; active plans and cosmetic title synchronization never create a + surprise revision. An unreadable file remains an honest unavailable report. + */ + if (!live.approvedPlanFingerprint) { + try { + const prompt = await readFile(join(this.taskDir(task.id), "PROMPT.md"), "utf8"); + const candidate = createCurrentPlanEvidence({ + version: (currentPlan?.version ?? 0) + 1, + sourceRevision: Date.now(), + capturedAt: new Date().toISOString(), + prompt, + bindings: specLockBindings(live), + }); + if (currentPlan?.sourceHash !== candidate.sourceHash) { + currentPlan = await this.captureCurrentPlanEvidenceLocked(task.id, prompt, candidate.sourceRevision, live); + } + } catch { + /* FNXC:SpecLock 2026-08-09-19:01: Retain history and let the evaluator report unavailable + rather than inventing evidence when PROMPT.md is unavailable (including archive cleanup). */ + } + } + const [latestLock, reports] = await Promise.all([this.getLatestSpecLock(task.id), this.listSpecDriftReports(task.id)]); + /* + FNXC:SpecDrift 2026-08-09-21:01: + Re-lock approval retains the fact that an earlier immutable version diverged. Looking only at + the latest report loses that fact after the first clean v2 reconciliation and falsely returns + on-plan; use the shared history predicate so store and engine cannot drift apart. + */ + const priorDivergence = hasPriorLockDivergence(reports, latestLock?.version); + const report = evaluateSpecDrift({ latestLock, currentPlan, approvedPlanFingerprint: live.approvedPlanFingerprint, modifiedFiles: live.modifiedFiles, priorDivergence }); + const [fencedTask, fencedLock, fencedPlan] = await Promise.all([this.getTask(task.id), this.getLatestSpecLock(task.id), this.getLatestCurrentPlanEvidence(task.id)]); + const fenced = fencedLock?.version !== latestLock?.version || fencedPlan?.version !== currentPlan?.version || fencedPlan?.plan.contentHash !== currentPlan?.plan.contentHash || fencedTask.approvedPlanFingerprint !== live.approvedPlanFingerprint || JSON.stringify(fencedTask.modifiedFiles ?? []) !== JSON.stringify(live.modifiedFiles ?? []); + const persisted = fenced + ? evaluateSpecDrift({ latestLock: fencedLock, currentPlan: fencedPlan, approvedPlanFingerprint: fencedTask.approvedPlanFingerprint, modifiedFiles: fencedTask.modifiedFiles, priorDivergence: hasPriorLockDivergence(reports, fencedLock?.version) }) + : report; + return this.persistSpecDriftReport(task.id, persisted); + } + private async persistSpecDriftReport(taskId: string, report: DriftReport): Promise { + if (!this.asyncLayer) throw new Error("Spec-lock history requires PostgreSQL backend storage"); + const projectId = this.asyncLayer.projectId ?? ""; + await this.asyncLayer.db.insert(schema.project.specDriftReports).values({ projectId, taskId, reportHash: report.reportHash, lockVersion: report.lockVersion, currentPlanVersion: report.currentPlanVersion, currentPlanHash: report.currentPlanHash, executionHash: report.executionHash, report, createdAt: new Date().toISOString() }).onConflictDoNothing(); + const rows = await this.asyncLayer.db.select().from(schema.project.specDriftReports).where(and(projectScopeFor(schema.project.specDriftReports.projectId, this.asyncLayer.projectId), eq(schema.project.specDriftReports.taskId, taskId), eq(schema.project.specDriftReports.reportHash, report.reportHash))).limit(1); + return rows[0]!.report as DriftReport; + } async getTask(id: string, options?: { activityLogLimit?: number; includeDeleted?: boolean }): Promise { return getTaskImpl(this, id, options); } @@ -1450,6 +1678,12 @@ export class TaskStore extends EventEmitter { id: string, updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; recommendations?: import("./types.js").TaskRecommendation[]; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { + /* + FNXC:SpecLock 2026-08-09-20:34: + updateTaskImpl owns the planning lifecycle fence for every authoritative plan mutation. Keep + this public entry unwrapped so its post-task-lock reconciliation can use the lock-held variant + without attempting a non-reentrant nested advisory lock. + */ return updateTaskImpl(this, id, updates, runContext); } /** diff --git a/packages/core/src/task-store/branch-and-pr-entities.ts b/packages/core/src/task-store/branch-and-pr-entities.ts index 8b1ba1801f..8dc8881f1e 100644 --- a/packages/core/src/task-store/branch-and-pr-entities.ts +++ b/packages/core/src/task-store/branch-and-pr-entities.ts @@ -620,10 +620,36 @@ export async function updateTaskImpl(store: TaskStore, updates: Parameters[1], runContext?: RunMutationContext, ): Promise { - if (updates.dependencies !== undefined) { - return store.withPlanningLifecycleLock(id, () => updateTaskWithTaskLockImpl(store, id, updates, runContext)); + const hasAuthoritativePlanMutation = updates.prompt !== undefined + || updates.dependencies !== undefined + || updates.missionId !== undefined + || updates.sliceId !== undefined; + const hasDriftEvidenceMutation = hasAuthoritativePlanMutation || updates.modifiedFiles !== undefined; + const write = () => updateTaskWithTaskLockImpl(store, id, updates, runContext); + if (hasAuthoritativePlanMutation) { + return store.withPlanningLifecycleLock(id, async () => { + const updated = await write(); + /* + FNXC:SpecLock 2026-08-09-20:34: + Reconcile only after updateTaskWithTaskLockImpl releases its non-reentrant task lock. + Prompt, dependency, and lineage writes share this planning fence so their inactive/active + evidence reaches one comparable report before a subsequent approval or execution handoff. + */ + if (store.isBackendMode()) { + await store.reconcileSpecDriftWhilePlanningLocked(updated).catch((error: unknown) => { + storeLog.warn(`[spec-lock] deferred drift reconciliation for ${updated.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + return updated; + }); } - return updateTaskWithTaskLockImpl(store, id, updates, runContext); + const updated = await write(); + if (hasDriftEvidenceMutation && store.isBackendMode()) { + await store.reconcileSpecDrift(updated).catch((error: unknown) => { + storeLog.warn(`[spec-lock] deferred drift reconciliation for ${updated.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + return updated; } async function updateTaskWithTaskLockImpl(store: TaskStore, diff --git a/packages/core/src/task-store/project-store-ops.ts b/packages/core/src/task-store/project-store-ops.ts index e98eac0ddf..668d0215d0 100644 --- a/packages/core/src/task-store/project-store-ops.ts +++ b/packages/core/src/task-store/project-store-ops.ts @@ -13,7 +13,7 @@ import { resolveCapacityPoolId } from "../workflows/workflow-capacity.js"; import {resolveWorkflowIntakeFacts} from "./task-creation.js"; import {TransitionRejectionError} from "./errors.js"; import * as schema from "../postgres/schema/index.js"; -import {and, eq, inArray, isNull, ne, or, sql} from "drizzle-orm"; +import {and, desc, eq, inArray, isNull, ne, or, sql} from "drizzle-orm"; import {mkdir, writeFile} from "node:fs/promises"; import {join} from "node:path"; import type {Task, ColumnId, CheckoutClaimPrecondition, ActivityLogEntry, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, GoalCitation, GoalCitationFilter} from "../types.js"; @@ -42,6 +42,7 @@ import {withTaskWorkflowSerialization} from "./async/async-workflow-workitems.js import {recordActivityLogEntry as recordActivityLogEntryAsync} from "./async/async-audit.js"; import {applyOriginalDescription} from "../tasks/original-description-policy.js"; import {isPlanReviewSatisfied} from "../planner/plan-approval.js"; +import {createCurrentPlanEvidence} from "../planner/spec-lock.js"; import {recordRunAuditEvent as recordRunAuditEventAsync} from "../postgres/data-layer.js"; import {listGoalCitations as listGoalCitationsAsync} from "./async/async-events.js"; import type {RunAuditEventRow} from "../task-store/row-types.js"; @@ -114,7 +115,7 @@ function sameDependencySet(actual: readonly string[], expected: readonly string[ && actual.every((dependency, index) => dependency === expected[index]); } -export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation,): Promise { +export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation, specPlanPrompt?: string,): Promise { const id = store.getTaskIdFromDir(dir); // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:10: // Backend mode: upsert the task row + audit event in one async Drizzle @@ -135,6 +136,35 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st const existingRow = await layer.transactionImmediate(async (tx) => { const persist = async () => { const row = await readTaskRowInTransaction(tx, id, { includeDeleted: true }, layer.projectId); + /* + FNXC:SpecLock 2026-08-09-18:17: + A full PROMPT.md rewrite publishes its evidence and clears approval in this one task-row + transaction. A database rollback therefore cannot leave either half visible by itself. + */ + if (specPlanPrompt !== undefined) { + const projectId = layer.projectId ?? ""; + const priorRows = await tx.select().from(schema.project.currentPlanEvidence) + .where(and(eq(schema.project.currentPlanEvidence.projectId, projectId), eq(schema.project.currentPlanEvidence.taskId, id))) + .orderBy(desc(schema.project.currentPlanEvidence.version)).limit(1); + const prior = priorRows[0]?.snapshot as import("../planner/spec-lock.js").CurrentPlanEvidence | undefined; + const candidate = createCurrentPlanEvidence({ + version: (prior?.version ?? 0) + 1, + sourceRevision: Date.now(), + capturedAt: new Date().toISOString(), + prompt: specPlanPrompt, + }); + if (prior?.sourceHash !== candidate.sourceHash) { + await tx.insert(schema.project.currentPlanEvidence).values({ + projectId, + taskId: id, + version: candidate.version, + sourceRevision: candidate.sourceRevision, + sourceHash: candidate.sourceHash, + capturedAt: candidate.capturedAt, + snapshot: candidate, + }); + } + } if (row && row.deletedAt != null) { return { deletedAt: row.deletedAt as string }; } diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 6d9f29314a..0ae1f7e7fc 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -28,7 +28,7 @@ import {isTaskTerminalNodeIdAsync} from "../workflows/workflow-ir-resolver.js"; import {extractTaskIdTokens, normalizeTitleForTaskId} from "../tasks/task-title-id-drift.js"; import {buildBootstrapPrompt} from "../mesh/mesh-task-replication.js"; import {validateFileScopeInPromptContent} from "../task-store/file-scope.js"; -import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHeadingLine, rewriteMissionSection} from "../task-store/comments.js"; +import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHeadingLine} from "../task-store/comments.js"; import {applyOriginalDescription} from "../tasks/original-description-policy.js"; import {normalizeTaskReviewState} from "../task-store/review-state.js"; import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt, resolveTaskSymbolsForTask} from "../tasks/task-symbol-resolution.js"; @@ -229,13 +229,38 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat let respecifyFromColumn: string | undefined; let respecifyMoveLanes: TaskMoveLanes | undefined; let previousDependencies: string[] | undefined; + let dependenciesChanged = false; let planningInvalidatedAt: string | undefined; + const priorMissionId = task.missionId; + const priorSliceId = task.sliceId; + /* + FNXC:SpecLock 2026-08-09-20:34: + Mission and slice links are locked lineage, not delivery-only labels. Detect a real link + change before the shared invalidation block retires approval and Plan Review evidence in the + same task-row write; retained locks stay immutable for the ensuing drift report and re-lock. + */ + if ((updates.missionId !== undefined || updates.sliceId !== undefined) + && ((updates.missionId !== undefined && (updates.missionId ?? undefined) !== priorMissionId) + || (updates.sliceId !== undefined && (updates.sliceId ?? undefined) !== priorSliceId))) { + planningInvalidatedAt = new Date().toISOString(); + } if (updates.dependencies !== undefined) { previousDependencies = (task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean); const oldDeps = new Set(previousDependencies); const normalizedDependencies = updates.dependencies.map((dependency) => dependency.trim()).filter(Boolean); const hasNewDeps = normalizedDependencies.some((d) => !oldDeps.has(d)); + dependenciesChanged = normalizedDependencies.length !== previousDependencies.length + || normalizedDependencies.some((dependency) => !oldDeps.has(dependency)); task.dependencies = normalizedDependencies; + /* + FNXC:SpecLock 2026-08-09-20:34: + Every dependency-set mutation changes the approved plan contract, including removals and + same-length replacements that do not enter the hold-lane re-specification branch below. + Invalidate the durable approval projection before writing the row; history remains intact. + */ + if (dependenciesChanged) { + planningInvalidatedAt = new Date().toISOString(); + } /* FNXC:WorkflowLifecycleColumns 2026-07-31-02:40 (batch-core feed): @@ -297,7 +322,7 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat graph-owned durable re-entry signal: it preserves prompt authority and makes the interrupted planner's stale finalizer harmless. */ - planningInvalidatedAt = new Date().toISOString(); + planningInvalidatedAt ??= new Date().toISOString(); const depLogEntry: TaskLogEntry = { timestamp: new Date().toISOString(), action: relocating @@ -1072,10 +1097,17 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } await mkdir(dir, { recursive: true }); await writeFile(join(dir, "PROMPT.md"), updates.prompt); + /* + FNXC:SpecLock 2026-08-09-12:34: + An explicit full-spec write is an authoritative plan revision, not cosmetic title sync. + Capture comparable evidence and retire approval before publishing the task update so a + rewritten plan cannot inherit release authorization from its predecessor. + */ + task.approvedPlanFingerprint = undefined; } // When runContext is provided, record audit event atomically with task mutation - const planningInvalidation = movedToTriage + const planningInvalidation = dependenciesChanged ? {expectedCurrentDependencies: previousDependencies ?? []} : undefined; if (runContext) { @@ -1090,9 +1122,21 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat updatedFields: Object.keys(updates).filter((k) => (updates as Record)[k] !== undefined), ...(titleNormalized ? { titleNormalized: true } : {}), }, - }, planningInvalidation); + }, planningInvalidation, updates.prompt); } else { - await store.atomicWriteTaskJsonWithAudit(dir, task, undefined, planningInvalidation); + await store.atomicWriteTaskJsonWithAudit(dir, task, undefined, planningInvalidation, updates.prompt); + } + + if (store.isBackendMode() && updates.prompt !== undefined) { + /* + FNXC:SpecLock 2026-08-09-19:01: + The task row clears approval only after PROMPT.md reaches disk. Append the same persisted + prompt as current-plan evidence while the caller's planning lifecycle lock is still held; + reconciliation then has a comparable revision instead of reporting an inactive lock with + missing evidence. This runs after the authoritative row write so a failed file write never + fabricates a plan revision. + */ + await store.captureCurrentPlanEvidenceWhilePlanningLocked(task.id, updates.prompt, Date.now(), task); } /* @@ -1164,10 +1208,14 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat next = rewriteHeadingLine(next, heading); } if (updates.description !== undefined) { - // FNXC:OriginalDescriptionInPrompt 2026-07-14-23:35: - // Keep ## Mission and ## Original Description in sync with task.description - // on real specs so operator edits stay visible at the top of PROMPT.md. - next = rewriteMissionSection(next, task.description); + /* + FNXC:SpecLockMissionAuthority 2026-08-09-20:04: + `## Mission` is locked structural evidence, not a task-description mirror. A + description-only edit is deliberately cosmetic; rewriting Mission here would mutate + an active accepted plan outside the authoritative prompt-write transaction and leave + approval briefly claiming a plan that no longer exists. Original Description remains + non-contract context and can still mirror the task description. + */ next = applyOriginalDescription(next, task.description ?? ""); } if (next !== existingPrompt) { diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index fc4699d15d..b5f7ab931b 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -223,7 +223,7 @@ export async function updateTaskDependenciesImpl(store: TaskStore, id: string, m } async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: string, mutation: TaskDependencyMutation, runContext?: RunMutationContext,): Promise { - return store.withTaskLock(id, async () => { + const updated = await store.withTaskLock(id, async () => { const dir = store.taskDir(id); const task = await store.readTaskJson(dir); const previousDependencies = [...(task.dependencies ?? [])]; @@ -345,6 +345,8 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri const previousDependencySet = new Set(normalizedCurrent); const hasNewDependencies = nextDependencies.some((dependencyId) => !previousDependencySet.has(dependencyId)); + const dependenciesChanged = normalizedCurrent.length !== nextDependencies.length + || normalizedCurrent.some((dependency, index) => dependency !== nextDependencies[index]); task.dependencies = nextDependencies; /* @@ -465,8 +467,14 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri when merged intake/hold lanes make this a same-column transition. Leaving the old fingerprint would let an unchanged prompt bypass manual approval. */ - if (shouldRespecify) { - task.status = "needs-replan"; + /* + FNXC:SpecLockDependencyInvalidation 2026-08-09-18:45: + Dependencies are part of the frozen contract. Adding was previously the only mutation that + cleared admission, leaving a removed or replaced prerequisite able to run under approval for + a different plan. Every material dependency edit retires approval evidence, while only the + existing new-dependency path changes lifecycle placement to request a re-plan. + */ + if (dependenciesChanged) { task.approvedPlanFingerprint = undefined; task.awaitingApprovalReason = undefined; task.workflowStepResults = supersedePlanReviewResults( @@ -474,6 +482,9 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri task.updatedAt, ); } + if (shouldRespecify) { + task.status = "needs-replan"; + } if (shouldRespecify && intakeColumn !== undefined) { task.column = intakeColumn; movedToTriage = true; @@ -528,7 +539,7 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri }, }; await store.atomicWriteTaskJsonWithAudit(dir, task, auditEvent, - hasNewDependencies && task.status === "needs-replan" + dependenciesChanged ? {expectedCurrentDependencies: normalizedCurrent} : undefined, ); @@ -560,4 +571,16 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri store.emitTaskLifecycleEventSafely("task:updated", [task]); return task; }); + /* + FNXC:SpecLockDependencyInvalidation 2026-08-09-18:45: + Publish the inactive-lock report before the planning lifecycle lock is released, but after the + task lock is released: `getTask()` intentionally acquires the latter and is non-reentrant. + A report outage remains retryable telemetry and cannot undo a valid dependency mutation. + */ + if (store.isBackendMode()) { + await store.reconcileSpecDriftWhilePlanningLocked(updated).catch((error: unknown) => { + storeLog.warn(`[spec-lock] deferred dependency drift reconciliation for ${updated.id}: ${error instanceof Error ? error.message : String(error)}`); + }); + } + return updated; } diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 5d6df32ef9..e1606e3e38 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -69,3 +69,6 @@ export async function getAgentActivity( */ return api(`/agent-activity${query.size ? `?${query}` : ""}`, options); } + +export { fetchSpecLock } from "./api/tasks/tasks"; +export type { SpecLockResponse } from "./api/tasks/tasks"; diff --git a/packages/dashboard/app/api/tasks/tasks.ts b/packages/dashboard/app/api/tasks/tasks.ts index 66d75e5a74..4a7259c0e8 100644 --- a/packages/dashboard/app/api/tasks/tasks.ts +++ b/packages/dashboard/app/api/tasks/tasks.ts @@ -12,6 +12,9 @@ import type { TaskGitLabTracking, TaskGitLabTrackedItem, GithubIssueAction, + CurrentPlanEvidence, + DriftReport, + SpecLock, } from "@fusion/core"; import { withTokenHeader } from "../../auth"; import { api, ApiRequestError, buildApiUrl, proxyApi } from "../client/client.js"; @@ -71,6 +74,25 @@ export interface TaskPromptResponse { prompt?: string; } +/** Persisted structural plan evidence; the browser renders it but never recomputes drift. */ +export interface SpecLockResponse { + latestLock: SpecLock | null; + activeLock: SpecLock | null; + currentPlan: CurrentPlanEvidence | null; + /** Current-only report; stale immutable reports remain in history. */ + report: DriftReport | null; + latestReport: DriftReport | null; + history: { + locks: SpecLock[]; + currentPlans: CurrentPlanEvidence[]; + reports: DriftReport[]; + }; +} + +export function fetchSpecLock(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${encodeURIComponent(id)}/spec-lock`, projectId)); +} + /* FNXC:TaskDetailPlan 2026-08-05-04:05: Definition polling reads only PROMPT.md. It must not request a TaskDetail because board/SSE/mutation diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 53a36bbe93..fbf17c1691 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -2078,6 +2078,27 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th flex-wrap: wrap; } +/* +FNXC:SpecLockMissionAlignment 2026-08-09-19:34: +FN-8845 renders delivery state and retained spec alignment independently. Semantic token colors make +needs-review and unavailable visible without suggesting that a re-lock is a quality or completion result. +*/ +.mission-spec-alignment--on-plan, +.mission-spec-alignment--diverged-relocked-approved { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); +} + +.mission-spec-alignment--diverged-needs-review { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 12%, transparent); +} + +.mission-spec-alignment--unavailable { + color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 12%, transparent); +} + .mission-feature__icon { flex-shrink: 0; color: var(--text-muted); diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 94ac74633e..c96f211da4 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, type MouseEvent, typ import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { getErrorMessage, type Goal } from "@fusion/core"; +import { getErrorMessage, type DriftAlignment, type Goal } from "@fusion/core"; import { X, Plus, @@ -103,6 +103,7 @@ import { fetchMissionInterviewDrafts, discardMissionInterviewDraft, fetchTaskDetail, + fetchSpecLock, apiGetBranchGroup, api, type AiSessionSummary, @@ -1271,6 +1272,35 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr an unavailable candidate can be skipped but an old response never leaks its branch, member count, or PR state into the current mission. */ + /* + FNXC:SpecLockMissionAlignment 2026-08-09-08:25: + FN-8845 keeps delivery status and spec alignment independent. Mission cards obtain the + persisted task report instead of inferring alignment from a task column, so an archived or + unlinked feature never gains a fabricated roadmap projection. + */ + const [featureSpecAlignments, setFeatureSpecAlignments] = useState>({}); + + useEffect(() => { + let cancelled = false; + const linkedFeatures = selectedMission?.milestones.flatMap((milestone) => + milestone.slices.flatMap((slice) => slice.features.flatMap((feature) => feature.taskId ? [feature] : [])), + ) ?? []; + setFeatureSpecAlignments({}); + if (!isActive || linkedFeatures.length === 0) return () => { cancelled = true; }; + + void Promise.all(linkedFeatures.map(async (feature) => { + try { + const evidence = await fetchSpecLock(feature.taskId!, projectId); + return [feature.id, evidence.report?.alignment ?? "unavailable"] as const; + } catch { + return [feature.id, "unavailable"] as const; + } + })).then((entries) => { + if (!cancelled) setFeatureSpecAlignments(Object.fromEntries(entries)); + }); + return () => { cancelled = true; }; + }, [isActive, projectId, selectedMission]); + useEffect(() => { let cancelled = false; setSelectedMissionBranchGroup(null); @@ -3726,6 +3756,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr > {feature.status} + {feature.taskId && featureSpecAlignments[feature.id] && ( + + {featureSpecAlignments[feature.id]} + + )} {/* Loop state indicator */} {(feature.loopState && feature.loopState !== "idle") && ( (null); + const [specLock, setSpecLock] = useState(null); const detailRequestGenerationRef = useRef(0); const detailRequestRef = useRef<{ key: string; promise: Promise } | null>(null); /* @@ -897,6 +898,20 @@ export function TaskDetailContent({ return () => { cancelled = true; window.clearInterval(timer); }; }, [task.id, projectId, active]); + /* + FNXC:SpecLockTaskDetail 2026-08-09-07:36: + Both modal and right-dock hosts render this shared content, so the Definition tab requests the + persisted report once per visible task. Rendering must not re-evaluate prompt prose in-browser. + */ + useEffect(() => { + if (!active || activeTab !== "definition") return; + let cancelled = false; + void fetchSpecLock(task.id, projectId) + .then((value) => { if (!cancelled) setSpecLock(value); }) + .catch(() => { if (!cancelled) setSpecLock(null); }); + return () => { cancelled = true; }; + }, [active, activeTab, projectId, task.id]); + useEffect(() => { // FNXC:TaskDetailPlan 2026-08-03-02:06: hidden kept-alive hosts defer their initial detail request until reveal. if (!active) return; @@ -6151,6 +6166,49 @@ export function TaskDetailContent({ ) : ( <> {/* FNXC:TaskDetailSummaryTab 2026-07-29-00:00: FN-8197 keeps Definition focused on plan, retry, and source metadata; completed merge metadata renders exclusively in the done-only Summary tab. */} + {specLock && ( +
+
+
+ Spec alignment + {specLock.report?.alignment ?? "unavailable"} +
+
+
+
Latest lock
v{specLock.latestLock?.version ?? "—"}
+
Current plan
v{specLock.currentPlan?.version ?? "—"}
+
Lock state
{specLock.activeLock ? "active" : "inactive"}
+
Findings
{specLock.report?.findings.length ?? 0}
+
+ {specLock.latestLock && ( +

+ Accepted {specLock.latestLock.acceptedAt} · plan hash {specLock.latestLock.currentPlanHash} · approval {specLock.latestLock.approvalFingerprint} +

+ )} + {specLock.currentPlan && ( +

+ Captured {specLock.currentPlan.capturedAt} · source revision {specLock.currentPlan.sourceRevision} · source hash {specLock.currentPlan.sourceHash} +

+ )} + {specLock.latestLock?.diff?.changedSections.length ? ( +

Re-lock changed: {specLock.latestLock.diff.changedSections.join(", ")}

+ ) : null} + {(specLock.history?.locks.length ?? 0) > 1 || (specLock.history?.currentPlans.length ?? 0) > 1 || (specLock.history?.reports.length ?? 0) > 1 ? ( +

+ Retained history: {specLock.history.locks.map((lock) => `lock v${lock.version}`).join(", ") || "no locks"}; {specLock.history.currentPlans.map((plan) => `plan v${plan.version}`).join(", ") || "no plan evidence"}; {specLock.history.reports.length} reports +

+ ) : null} + {specLock.report?.findings.length ? ( +
    + {specLock.report.findings.map((finding, index) => ( +
  • + {finding.kind}: {finding.category}{finding.path ? ` (${finding.path})` : ""} +
  • + ))} +
+ ) : null} +
+ )} {(retrySummary?.total ?? 0) > 0 && (
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.spec-lock.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.spec-lock.test.tsx new file mode 100644 index 0000000000..6b2d4132d2 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.spec-lock.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { + makeTask, + noop, + noopDelete, + noopMerge, + noopMove, + noopOpenDetail, + setupTaskDetailModalHooks, +} from "./TaskDetailModal.test-helpers"; +import { TaskDetailContent } from "../TaskDetailModal"; + +setupTaskDetailModalHooks(); + +afterEach(async () => { + const { fetchSpecLock } = await import("../../api"); + vi.mocked(fetchSpecLock).mockReset(); + vi.mocked(fetchSpecLock).mockResolvedValue({ latestLock: null, activeLock: null, currentPlan: null, report: null, latestReport: null, history: { locks: [], currentPlans: [], reports: [] } }); +}); + +describe("TaskDetailModal spec-lock report", () => { + it("renders persisted Mission-statement divergence and immutable provenance in the shared Definition surface", async () => { + const { fetchSpecLock } = await import("../../api"); + vi.mocked(fetchSpecLock).mockResolvedValue({ + latestLock: { + version: 1, + acceptedAt: "2026-08-09T19:34:00.000Z", + approvalFingerprint: "approved-hash", + currentPlanVersion: 1, + currentPlanHash: "plan-hash", + plan: {} as never, + }, + activeLock: null, + currentPlan: { + version: 2, + sourceRevision: 42, + sourceHash: "source-hash", + capturedAt: "2026-08-09T19:35:00.000Z", + plan: {} as never, + }, + report: { + lockVersion: 1, + currentPlanVersion: 2, + currentPlanHash: "changed-plan-hash", + status: "available", + findings: [{ kind: "plan-deviation", category: "mission-statement", priorHash: "prior", currentHash: "current" }], + alignment: "diverged-needs-review", + executionHash: "execution", + reportHash: "report", + }, + latestReport: null, + history: { locks: [{ version: 1 }], currentPlans: [{ version: 1 }, { version: 2 }], reports: [{}, {}] }, + } as never); + + render( + , + ); + + const report = await screen.findByTestId("spec-lock-report"); + expect(report).toHaveAccessibleName("Spec lock alignment"); + expect(report).toHaveTextContent("diverged-needs-review"); + expect(report).toHaveTextContent("plan-deviation: mission-statement"); + expect(report).toHaveTextContent("source revision 42"); + expect(report).toHaveTextContent("source hash source-hash"); + expect(report).toHaveTextContent("Retained history: lock v1; plan v1, plan v2; 2 reports"); + await waitFor(() => expect(fetchSpecLock).toHaveBeenCalledWith("FN-099", undefined)); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 11ca300a16..859083378d 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -54,6 +54,8 @@ vi.mock("../../api", async (importOriginal) => { summarizeTitle: vi.fn().mockResolvedValue("Generated Title"), fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()), fetchTaskPrompt: vi.fn().mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" }), + // FNXC:SpecLockTaskDetail 2026-08-09-19:34: every shared detail fixture provides a stable empty retained-evidence response. + fetchSpecLock: vi.fn().mockResolvedValue({ latestLock: null, activeLock: null, currentPlan: null, report: null, latestReport: null, history: { locks: [], currentPlans: [], reports: [] } }), // FNXC:DashboardTests 2026-07-19-01:20: FN-8296 TaskDetail polls verification request status. fetchTaskVerificationRequest: vi.fn().mockResolvedValue(null), fetchAgentLogs: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts index c2adaded38..8b4d5f8623 100644 --- a/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts +++ b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import express from "express"; import { afterEach, beforeEach, expect, it } from "vitest"; @@ -37,6 +37,13 @@ pgDescribe("plan approval status persistence", () => { return { promise, release }; } + /** FNXC:SpecLock 2026-08-09-17:37: approval-path fixtures need a structurally available plan because malformed Mission evidence now fails closed. */ + async function writeLockablePrompt(taskId: string): Promise { + const taskDir = join(harness.rootDir, ".fusion", "tasks", taskId); + await mkdir(taskDir, { recursive: true }); + await writeFile(join(taskDir, "PROMPT.md"), "## Mission\n\nImplement the approved plan.\n\n## File Scope\n\n- src/**\n\n## Steps\n\n- Implement it\n", "utf8"); + } + it("clears the approval hold and persists the approved plan fingerprint", async () => { const task = await store.createTask({ description: "Approve this plan" }); await store.updateTask(task.id, { @@ -45,7 +52,7 @@ pgDescribe("plan approval status persistence", () => { }); expect((await store.getTask(task.id)).approvedPlanFingerprint).toBe("stale-fingerprint"); - const prompt = "# Approved plan\n\nImplement the requested behavior.\n"; + const prompt = "# Approved plan\n\n## Mission\n\nImplement the requested behavior.\n\n## File Scope\n\n- src/**\n\n## Steps\n\n- Implement it\n"; const taskDir = join(harness.rootDir, ".fusion", "tasks", task.id); await mkdir(taskDir, { recursive: true }); await writeFile(join(taskDir, "PROMPT.md"), prompt, "utf8"); @@ -58,6 +65,34 @@ pgDescribe("plan approval status persistence", () => { expect(isTaskBlockedOnApproval(persisted)).toBe(false); expect(persisted.approvedPlanFingerprint).toBe(computePlanApprovalFingerprint(prompt)); expect(response.body.approvedPlanFingerprint).toBe(persisted.approvedPlanFingerprint); + + // FNXC:SpecLockApproval 2026-08-09-19:51: the manual release route must append the + // immutable lock and a current deterministic report before graph execution can resume. + await expect(store.getActiveSpecLock(task.id)).resolves.toMatchObject({ + version: 1, + approvalFingerprint: persisted.approvedPlanFingerprint, + }); + await expect(store.getLatestSpecDriftReport(task.id)).resolves.toMatchObject({ + alignment: "on-plan", + lockVersion: 1, + currentPlanVersion: 1, + }); + }); + + it("does not rewrite locked Mission evidence during a description-only synchronization", async () => { + const task = await store.createTask({ description: "Initial description" }); + const prompt = "# Approved plan\n\n## Mission\n\nKeep this locked Mission statement.\n\n## File Scope\n\n- src/**\n\n## Steps\n\n- Implement it\n"; + const taskDir = join(harness.rootDir, ".fusion", "tasks", task.id); + await mkdir(taskDir, { recursive: true }); + await writeFile(join(taskDir, "PROMPT.md"), prompt, "utf8"); + await store.updateTask(task.id, { status: "awaiting-approval" }); + expect((await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`)).status).toBe(200); + + await store.updateTask(task.id, { description: "Cosmetic task description update" }); + + await expect(store.getActiveSpecLock(task.id)).resolves.toMatchObject({ version: 1 }); + await expect(store.getLatestSpecDriftReport(task.id)).resolves.toMatchObject({ alignment: "on-plan" }); + expect(await readFile(join(taskDir, "PROMPT.md"), "utf8")).toContain("## Mission\n\nKeep this locked Mission statement."); }); it.each(["failed", "advisory_failure"] as const)( @@ -87,7 +122,7 @@ pgDescribe("plan approval status persistence", () => { const taskDir = join(harness.rootDir, ".fusion", "tasks", task.id); await mkdir(taskDir, { recursive: true }); - await writeFile(join(taskDir, "PROMPT.md"), "# Human-approved plan\n", "utf8"); + await writeFile(join(taskDir, "PROMPT.md"), "# Human-approved plan\n\n## Mission\n\nImplement the approved plan.\n\n## File Scope\n\n- src/**\n\n## Steps\n\n- Implement it\n", "utf8"); const response = await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`); @@ -121,9 +156,11 @@ pgDescribe("plan approval status persistence", () => { verdict: "REVISE", }], } as never); + await writeLockablePrompt(task.id); const response = await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`); + expect(response.status).toBe(200); const persisted = await store.getTask(task.id); expect(persisted.column).toBe("todo"); @@ -164,6 +201,7 @@ pgDescribe("plan approval status persistence", () => { verdict: "REVISE", }], } as never); + await writeLockablePrompt(task.id); const originalUpdate = store.updateTask.bind(store); let approvalUpdates = 0; @@ -312,6 +350,7 @@ pgDescribe("plan approval status persistence", () => { const task = await store.createTask({ description: "Approval precedes dependency" }); const dependency = await store.createTask({ description: "Later prerequisite", column: "done" }); await store.updateTask(task.id, { status: "awaiting-approval" }); + await writeLockablePrompt(task.id); const mutationStore = new TaskStore(harness.rootDir, undefined, { asyncLayer: harness.layer }); await mutationStore.init(); @@ -372,7 +411,7 @@ pgDescribe("plan approval status persistence", () => { expect(persisted.awaitingApprovalReason).toBe("plan-review-replan-cap"); }); - it("clears a prior fingerprint when the approved plan cannot be read", async () => { + it("keeps the approval hold when the plan cannot be read for a spec lock", async () => { const task = await store.createTask({ description: "Approve without a readable plan" }); await store.updateTask(task.id, { status: "awaiting-approval", @@ -383,12 +422,36 @@ pgDescribe("plan approval status persistence", () => { const response = await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`); - expect(response.status).toBe(200); + expect(response.status).toBe(409); const persisted = await store.getTask(task.id); - expect(persisted.status).toBeUndefined(); - expect(isTaskBlockedOnApproval(persisted)).toBe(false); - expect(persisted.approvedPlanFingerprint).toBeUndefined(); - expect(response.body.approvedPlanFingerprint).toBeUndefined(); + expect(persisted.status).toBe("awaiting-approval"); + expect(isTaskBlockedOnApproval(persisted)).toBe(true); + expect(persisted.approvedPlanFingerprint).toBe("stale-fingerprint"); + await expect(store.getLatestSpecLock(task.id)).resolves.toBeUndefined(); + }); + + it("invalidates an accepted lock for removed and replaced dependencies", async () => { + const task = await store.createTask({ description: "Dependency changes invalidate the lock" }); + const first = await store.createTask({ description: "First prerequisite", column: "done" }); + const replacement = await store.createTask({ description: "Replacement prerequisite", column: "done" }); + await writeLockablePrompt(task.id); + await store.updateTask(task.id, { status: "awaiting-approval" }); + expect((await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`)).status).toBe(200); + expect((await store.getTask(task.id)).approvedPlanFingerprint).toBeTruthy(); + + await store.updateTaskDependencies(task.id, { operation: "add", dependency: first.id }); + await store.updateTask(task.id, { status: "awaiting-approval" }); + expect((await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`)).status).toBe(200); + await store.updateTaskDependencies(task.id, { operation: "replace", from: first.id, to: replacement.id }); + + const afterReplace = await store.getTask(task.id); + expect(afterReplace.approvedPlanFingerprint).toBeUndefined(); + expect((await store.getLatestSpecDriftReport(task.id))?.alignment).toBe("unavailable"); + + await store.updateTask(task.id, { status: "awaiting-approval" }); + expect((await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`)).status).toBe(200); + await store.updateTaskDependencies(task.id, { operation: "remove", dependency: replacement.id }); + expect((await store.getTask(task.id)).approvedPlanFingerprint).toBeUndefined(); }); it("clears the approval hold and stale fingerprint when rejecting a plan", async () => { diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index f966060242..fa1b30b3f0 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1,4 +1,4 @@ -import { createIngestedCheckResolver, createLogger, resolveRequiredCheckNames } from "@fusion/core"; +import { createIngestedCheckResolver, createLogger, isCurrentSpecDriftReport, resolveRequiredCheckNames } from "@fusion/core"; import type { Request, Response } from "express"; const severityAuditLog = createLogger("dashboard-register-task-workflow-routes"); @@ -4312,6 +4312,38 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /* + FNXC:SpecLock 2026-08-09-12:34: + Task Detail reads retained structural evidence from the store rather than recomputing it in the + browser, keeping displayed alignment identical to the execution-time evaluator. + */ + router.get("/tasks/:id/spec-lock", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const task = await scopedStore.getTask(req.params.id); + const [latestLock, activeLock, currentPlan, latestReport, locks, currentPlans, reports] = await Promise.all([ + scopedStore.getLatestSpecLock(req.params.id), + scopedStore.getActiveSpecLock(req.params.id), + scopedStore.getLatestCurrentPlanEvidence(req.params.id), + scopedStore.getLatestSpecDriftReport(req.params.id), + scopedStore.listSpecLocks(req.params.id), + scopedStore.listCurrentPlanEvidence(req.params.id), + scopedStore.listSpecDriftReports(req.params.id), + ]); + /* + FNXC:SpecDrift 2026-08-09-19:19: + Route readers expose the active lock separately and never promote a historical clean report + after a prompt rewrite or re-lock. The stale row remains in immutable history for audit. + */ + const report = isCurrentSpecDriftReport(latestReport, latestLock, currentPlan, task.approvedPlanFingerprint) ? latestReport : undefined; + res.json({ latestLock: latestLock ?? null, activeLock: activeLock ?? null, currentPlan: currentPlan ?? null, report: report ?? null, latestReport: latestReport ?? null, history: { locks, currentPlans, reports } }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + if (isTaskLookupMiss(err)) throw notFound(`Task ${req.params.id} not found`); + rethrowAsApiError(err, "Internal server error"); + } + }); + // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { @@ -4589,14 +4621,22 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork * manual gate falls back to today's always-re-park behavior for this task. */ let approvedPlanFingerprint: string | undefined; + let approvedPrompt: string | undefined; try { const { readFile } = await import("node:fs/promises"); const { join } = await import("node:path"); const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"); const promptText = await readFile(promptPath, "utf8"); + approvedPrompt = promptText; approvedPlanFingerprint = computePlanApprovalFingerprint(promptText); } catch { - // No PROMPT.md to fingerprint (unusual for an awaiting-approval task) — leave unset. + /* + FNXC:SpecLockApproval 2026-08-09-20:04: + Manual approval is a release boundary, so an unreadable PROMPT.md cannot fall back to + clearing a stale fingerprint and releasing un-lockable work. Keep the existing hold until + the operator restores a readable, structurally comparable plan that can be locked. + */ + throw conflict("Cannot approve plan: PROMPT.md must be readable to create the immutable spec lock"); } /* @@ -4647,6 +4687,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork approvedWorkflowStepResults = results; } + /* + FNXC:SpecLock 2026-08-09-17:37: + Validate an exhausted Plan Review before attempting persistence. A malformed cap state must + retain its established conflict response, while every valid release still locks under this fence. + */ + if (approvedPlanFingerprint && approvedPrompt) { + await scopedStore.lockCurrentPlanWhilePlanningLocked(task.id, approvedPlanFingerprint, approvedPrompt); + } + const approvalPatch = { status: null, approvedPlanFingerprint: approvedPlanFingerprint ?? null, @@ -4681,6 +4730,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork * so a stale plan can never bypass a later manual approval gate. */ const approved = await scopedStore.updateTask(task.id, approvalPatch); + /* + FNXC:SpecDrift 2026-08-09-07:36: + Publish the deterministic report before the approval handoff seeds graph execution. A + missing/unreadable PROMPT.md yields an unavailable report rather than an unexamined release. + */ + await scopedStore.reconcileSpecDriftWhilePlanningLocked(approved); /* * FNXC:PlanApprovalDispatch 2026-08-05-01:57: * Clearing awaiting-approval is only the first half of the operator decision. Resume the diff --git a/packages/engine/src/__tests__/mission-feature-sync.test.ts b/packages/engine/src/__tests__/mission-feature-sync.test.ts index 9551e11a0a..cceea316fd 100644 --- a/packages/engine/src/__tests__/mission-feature-sync.test.ts +++ b/packages/engine/src/__tests__/mission-feature-sync.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { reconcileMissionFeatureState } from "../missions/mission-feature-sync.js"; +import { projectMissionFeatureAlignment, reconcileMissionFeatureState, resolveMissionFeatureAlignment } from "../missions/mission-feature-sync.js"; describe("reconcileMissionFeatureState", () => { + it("projects persisted drift separately from delivery status", async () => { + expect(projectMissionFeatureAlignment({ alignment: "diverged-needs-review" })).toBe("diverged-needs-review"); + expect(projectMissionFeatureAlignment(undefined)).toBe("unavailable"); + await expect(resolveMissionFeatureAlignment({ getLatestSpecDriftReport: async () => ({ alignment: "diverged-relocked-approved" }) } as never, "FN-1")) + .resolves.toBe("diverged-relocked-approved"); + await expect(resolveMissionFeatureAlignment({ getLatestSpecDriftReport: async () => { throw new Error("read failed"); } } as never, "FN-1")) + .resolves.toBe("unavailable"); + }); + it("keeps assertion validation as the completion gate for research-derived features", async () => { const decision = await reconcileMissionFeatureState( { getTask: async () => undefined } as never, @@ -9,7 +18,7 @@ describe("reconcileMissionFeatureState", () => { { id: "F-1", status: "in-progress", lastValidatorStatus: "failed" } as never, { hasLinkedAssertions: true }, ); - expect(decision).toEqual(expect.objectContaining({ kind: "noop" })); + expect(decision).toEqual(expect.objectContaining({ kind: "noop", alignment: "unavailable" })); }); it("reconciles return and active board states without fabricating completion", async () => { @@ -22,7 +31,7 @@ describe("reconcileMissionFeatureState", () => { it("keeps archived and failed task outcomes as idempotent non-completion", async () => { const taskStore = { getTask: async () => undefined } as never; - await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "archived" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toEqual({ kind: "noop" }); + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "archived" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toEqual({ kind: "noop", alignment: "unavailable" }); await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "todo", status: "failed", error: "BLOCKED" } as never, { id: "F-1", status: "triaged" } as never)).resolves.toMatchObject({ kind: "failure" }); }); }); diff --git a/packages/engine/src/__tests__/spec-drift-reconciler.test.ts b/packages/engine/src/__tests__/spec-drift-reconciler.test.ts new file mode 100644 index 0000000000..33cddca955 --- /dev/null +++ b/packages/engine/src/__tests__/spec-drift-reconciler.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { canonicalizePlan, createCurrentPlanEvidence, evaluateSpecDrift, type DriftReport, type SpecLock, type Task } from "@fusion/core"; +import { createStoreSpecDriftRepository, SpecDriftReconciler } from "../spec-drift-reconciler.js"; + +const prompt = "## Mission\n\nBuild widget\n\n## File Scope\n\n- src/widget.ts\n"; +const evidence = createCurrentPlanEvidence({ version: 1, sourceRevision: 1, capturedAt: "2026-08-09T07:06:00.000Z", prompt }); +const lock = { version: 1, acceptedAt: "2026-08-09T07:06:00.000Z", approvalFingerprint: "approved", currentPlanVersion: 1, currentPlanHash: evidence.plan.contentHash!, plan: evidence.plan }; + +describe("SpecDriftReconciler", () => { + it("persists a deterministic out-of-scope finding without moving the task", async () => { + const persisted: unknown[] = []; + const reconciler = new SpecDriftReconciler({ snapshot: async () => ({ latestLock: lock, currentPlan: evidence, approvedPlanFingerprint: "approved", modifiedFiles: ["src/outside.ts"] }), persist: async (_taskId, report) => { persisted.push(report); } }); + const report = await reconciler.reconcile("FN-1"); + expect(report?.findings).toContainEqual(expect.objectContaining({ kind: "scope-creep", path: "src/outside.ts" })); + expect(persisted).toHaveLength(1); + }); + it("retries a failed persistence write without waiting for restart", async () => { + vi.useFakeTimers(); + let attempts = 0; + const reconciler = new SpecDriftReconciler({ + snapshot: async () => ({ latestLock: lock, currentPlan: evidence, approvedPlanFingerprint: "approved" }), + persist: async () => { attempts += 1; if (attempts === 1) throw new Error("temporary database outage"); }, + }); + await expect(reconciler.reconcile("FN-RETRY")).rejects.toThrow("temporary database outage"); + await vi.advanceTimersByTimeAsync(1_000); + expect(attempts).toBe(2); + reconciler.stop(); + vi.useRealTimers(); + }); + + it("coalesces live mutation events into one fresh comparison", async () => { + let persisted = 0; + const reconciler = new SpecDriftReconciler({ + snapshot: async () => ({ latestLock: lock, currentPlan: evidence, approvedPlanFingerprint: "approved" }), + persist: async () => { persisted += 1; }, + }); + reconciler.enqueue("FN-LIVE"); + reconciler.enqueue("FN-LIVE"); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(persisted).toBe(1); + reconciler.stop(); + }); + + it("retains v1 divergence through a clean v2 re-lock for event and startup reconciliation", async () => { + const taskId = "FN-RELOCK"; + const v1 = createCurrentPlanEvidence({ version: 1, sourceRevision: 1, capturedAt: "2026-08-10T09:28:00.000Z", prompt }); + const v1Lock: SpecLock = { version: 1, acceptedAt: "2026-08-10T09:28:00.000Z", approvalFingerprint: "v1-approved", currentPlanVersion: v1.version, currentPlanHash: v1.plan.contentHash!, plan: canonicalizePlan(prompt) }; + const v1Divergence = evaluateSpecDrift({ + latestLock: v1Lock, + currentPlan: createCurrentPlanEvidence({ version: 2, sourceRevision: 2, capturedAt: "2026-08-10T09:28:00.000Z", prompt: prompt.replace("Build widget", "Build changed widget") }), + approvedPlanFingerprint: "v1-approved", + }); + expect(v1Divergence.alignment).toBe("diverged-needs-review"); + const v2 = createCurrentPlanEvidence({ version: 3, sourceRevision: 3, capturedAt: "2026-08-10T09:28:00.000Z", prompt: prompt.replace("Build widget", "Build changed widget") }); + const v2Lock: SpecLock = { version: 2, acceptedAt: "2026-08-10T09:28:00.000Z", approvalFingerprint: "v2-approved", currentPlanVersion: v2.version, currentPlanHash: v2.plan.contentHash!, plan: v2.plan, priorVersion: 1 }; + const reports: DriftReport[] = [v1Divergence]; + const store = { + getTask: async () => ({ id: taskId, approvedPlanFingerprint: "v2-approved", modifiedFiles: [] } as Task), + getLatestSpecLock: async () => v2Lock, + getLatestCurrentPlanEvidence: async () => v2, + listSpecDriftReports: async () => reports, + appendSpecDriftReport: async (_taskId: string, report: DriftReport) => { + if (!reports.some((entry) => entry.reportHash === report.reportHash)) reports.push(report); + return report; + }, + }; + const reconciler = new SpecDriftReconciler(createStoreSpecDriftRepository(store)); + + reconciler.enqueue(taskId); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); + const eventReport = reports.at(-1)!; + expect(eventReport).toMatchObject({ alignment: "diverged-relocked-approved", lockVersion: 2, findings: [] }); + expect(eventReport.alignment).not.toBe("on-plan"); + + const startupReport = await reconciler.reconcile(taskId); + expect(startupReport).toMatchObject({ alignment: "diverged-relocked-approved", lockVersion: 2, findings: [] }); + expect(startupReport?.alignment).not.toBe("on-plan"); + reconciler.stop(); + }); + + it("preserves empty, same-lock, and unavailable repository states", async () => { + const task = { id: "FN-EDGE", approvedPlanFingerprint: "approved", modifiedFiles: [] } as Task; + const cleanStore = { + getTask: async () => task, + getLatestSpecLock: async () => lock, + getLatestCurrentPlanEvidence: async () => evidence, + listSpecDriftReports: async (): Promise => [], + appendSpecDriftReport: async (_taskId: string, report: DriftReport) => report, + }; + const divergence = evaluateSpecDrift({ latestLock: lock, currentPlan: createCurrentPlanEvidence({ version: 2, sourceRevision: 2, capturedAt: "2026-08-10T09:28:00.000Z", prompt: prompt.replace("Build widget", "Build changed widget") }), approvedPlanFingerprint: "approved" }); + const sameLockStore = { ...cleanStore, listSpecDriftReports: async (): Promise => [{ ...divergence, lockVersion: lock.version }] }; + const unavailableStore = { ...cleanStore, getTask: async () => ({ id: "FN-UNAVAILABLE" } as Task), getLatestSpecLock: async () => undefined, getLatestCurrentPlanEvidence: async () => undefined }; + + await expect(new SpecDriftReconciler(createStoreSpecDriftRepository(cleanStore)).reconcile(task.id)).resolves.toMatchObject({ alignment: "on-plan" }); + await expect(new SpecDriftReconciler(createStoreSpecDriftRepository(sameLockStore)).reconcile(task.id)).resolves.toMatchObject({ alignment: "on-plan" }); + await expect(new SpecDriftReconciler(createStoreSpecDriftRepository(unavailableStore)).reconcile("FN-UNAVAILABLE")).resolves.toMatchObject({ alignment: "unavailable" }); + }); + + it("does not leak queued writes after stop", async () => { + const reconciler = new SpecDriftReconciler({ snapshot: async () => ({ latestLock: lock, currentPlan: evidence }), persist: async () => { throw new Error("must not write"); } }); + reconciler.enqueue("FN-QUEUED"); + reconciler.stop(); + await new Promise((resolve) => queueMicrotask(resolve)); + await expect(reconciler.reconcile("FN-1")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/engine/src/executor/execute-workflow-graph.ts b/packages/engine/src/executor/execute-workflow-graph.ts index 3a879912d2..ef75544ed7 100644 --- a/packages/engine/src/executor/execute-workflow-graph.ts +++ b/packages/engine/src/executor/execute-workflow-graph.ts @@ -19,6 +19,9 @@ import type { } from "@fusion/core"; import { ACTIVE_WORKFLOW_WORK_ITEM_STATES, + computePlanApprovalFingerprint, + isPlanReviewSatisfied, + PLAN_REVIEW_GROUP_ID, getBuiltinWorkflow, resolveColumnAgentBinding, resolveMaxConsecutiveToolFailureRetries, @@ -38,6 +41,7 @@ import { executorLog } from "../logger.js"; import type { EngineRunContext } from "../util/run-audit.js"; import { takePreHeldExecutorSlot } from "../concurrency/concurrency.js"; import { resolveCompleteColumnFor } from "./lifecycle-columns.js"; +import { nextPlanReviewAttemptCount, PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT } from "../plan-review-feedback-history.js"; import type { AgentSemaphore } from "../concurrency/concurrency.js"; import type { WorkflowAgentCapacity } from "../agents/workflow-agent-capacity.js"; import { @@ -472,8 +476,49 @@ export async function executeWorkflowGraph( fix) must preserve the prior `status:"failed"` entry's history in `priorAttempts` rather than silently overwriting it. */ - const existing = upsertWorkflowStepResult(live?.workflowStepResults, result); - await deps.store.updateTask(taskId, { workflowStepResults: existing }, deps.getRunContextFor(taskId)); + const isPlanReviewResult = result.workflowStepId === PLAN_REVIEW_GROUP_ID + || result.workflowStepName === "Plan Review"; + const resultToPersist = isPlanReviewResult + ? { + ...result, + planReviewAttemptCount: nextPlanReviewAttemptCount( + live?.workflowStepResults?.find((existing) => existing.workflowStepId === result.workflowStepId), + result, + ), + } + : result; + const existing = upsertWorkflowStepResult( + live?.workflowStepResults, + resultToPersist, + isPlanReviewResult ? { maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT } : undefined, + ); + if (isPlanReviewResult && isPlanReviewSatisfied(resultToPersist) && deps.store.isBackendMode()) { + /* + FNXC:SpecLock 2026-08-09-20:21: + A graph Plan Review pass is an acceptance producer, not merely progress telemetry. + Create its immutable lock before publishing the satisfied result that scheduler and + hold-release consume; a lock failure leaves the old unsatisfied result in place. + */ + const prompt = await deps.readTaskArtifact(taskId, "PROMPT.md"); + if (!prompt?.trim()) throw new Error("Plan Review cannot accept an unreadable PROMPT.md without a spec lock"); + const fingerprint = computePlanApprovalFingerprint(prompt); + await deps.store.withPlanningLifecycleLock(taskId, async () => { + const fresh = await deps.store.getTask(taskId); + const acceptedResult = upsertWorkflowStepResult( + fresh.workflowStepResults, + resultToPersist, + { maxPriorAttempts: PLAN_REVIEW_FEEDBACK_HISTORY_LIMIT }, + ); + await deps.store.lockCurrentPlanWhilePlanningLocked(taskId, fingerprint, prompt); + const accepted = await deps.store.updateTask(taskId, { + workflowStepResults: acceptedResult, + approvedPlanFingerprint: fingerprint, + }, deps.getRunContextFor(taskId)); + await deps.store.reconcileSpecDriftWhilePlanningLocked(accepted); + }); + } else { + await deps.store.updateTask(taskId, { workflowStepResults: existing }, deps.getRunContextFor(taskId)); + } } catch { // Result recording is additive visibility — never affect the run. } diff --git a/packages/engine/src/missions/mission-feature-sync.ts b/packages/engine/src/missions/mission-feature-sync.ts index 607e98ceb9..4435f7b5e4 100644 --- a/packages/engine/src/missions/mission-feature-sync.ts +++ b/packages/engine/src/missions/mission-feature-sync.ts @@ -1,9 +1,35 @@ -import type { MissionFeature, Task, TaskStore } from "@fusion/core"; +import type { DriftAlignment, MissionFeature, Task, TaskStore } from "@fusion/core"; import { getTaskCompletionBlockerForStore } from "../execution/task-completion.js"; import { resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core"; export type MissionFeatureSyncTargetStatus = "done" | "in-progress" | "triaged"; +/** + * FNXC:SpecLockMissionAlignment 2026-08-09-07:36: + * Drift alignment is an orthogonal mission projection. It intentionally does not participate in + * delivery-status transitions: a diverged task may still be in progress, complete, or failed. + */ +export function projectMissionFeatureAlignment(report: { alignment: DriftAlignment } | undefined): DriftAlignment { + return report?.alignment ?? "unavailable"; +} + +/** + * FNXC:SpecLockMissionAlignment 2026-08-09-19:51: + * Mission delivery reconciliation consumes the retained report instead of deriving scope state + * from a task column. An absent report is unavailable, never an implicit on-plan projection. + */ +export async function resolveMissionFeatureAlignment( + taskStore: Pick, + taskId: string | undefined, +): Promise { + if (!taskId) return "unavailable"; + try { + return projectMissionFeatureAlignment(await taskStore.getLatestSpecDriftReport(taskId)); + } catch { + return "unavailable"; + } +} + export interface MissionFeatureSyncContext { hasLinkedAssertions?: boolean; /* @@ -35,17 +61,19 @@ export const LEGACY_PLANNER_COLUMNS: readonly string[] = ["triage", "todo"]; export type MissionFeatureSyncDecision = - | { kind: "failure"; reason: string } - | { kind: "blocked"; reason: string } - | { kind: "update"; status: MissionFeatureSyncTargetStatus; reason: string } - | { kind: "noop" }; + | { kind: "failure"; reason: string; alignment: DriftAlignment } + | { kind: "blocked"; reason: string; alignment: DriftAlignment } + | { kind: "update"; status: MissionFeatureSyncTargetStatus; reason: string; alignment: DriftAlignment } + | { kind: "noop"; alignment: DriftAlignment }; export async function reconcileMissionFeatureState( - taskStore: Pick & Parameters[0], + taskStore: Pick & Parameters[0], task: Task, feature: Pick, context: MissionFeatureSyncContext = {}, ): Promise { + const alignment = await resolveMissionFeatureAlignment(taskStore, task.id); + /* FNXC:MissionReconciliation 2026-07-30-00:00: FN-8307 makes failure a provenance-preserving withheld outcome regardless of @@ -56,6 +84,7 @@ export async function reconcileMissionFeatureState( return { kind: "failure", reason: `task ${task.id} failed; feature ${feature.id} remains ${feature.status}`, + alignment, }; } @@ -139,7 +168,7 @@ export async function reconcileMissionFeatureState( if ((lane.complete !== undefined && task.column === lane.complete)) { const blocker = await getTaskCompletionBlockerForStore(taskStore, task); if (blocker) { - return { kind: "blocked", reason: blocker }; + return { kind: "blocked", reason: blocker, alignment }; } if (hasUnvalidatedAssertions) { @@ -148,9 +177,10 @@ export async function reconcileMissionFeatureState( kind: "update", status: "in-progress", reason: `task ${task.id} completed; awaiting assertion validation`, + alignment, }; } - return { kind: "noop" }; + return { kind: "noop", alignment }; } if (feature.status !== "done") { @@ -158,10 +188,11 @@ export async function reconcileMissionFeatureState( kind: "update", status: "done", reason: `task ${task.id} completed`, + alignment, }; } - return { kind: "noop" }; + return { kind: "noop", alignment }; } /* @@ -170,7 +201,7 @@ export async function reconcileMissionFeatureState( status untouched so a terminal/duplicate archive cannot fabricate roadmap progress; callers may still recompute hierarchy idempotently. */ - if ((lane.archived !== undefined && task.column === lane.archived)) return { kind: "noop" }; + if ((lane.archived !== undefined && task.column === lane.archived)) return { kind: "noop", alignment }; if ( ((lane.wip !== undefined && task.column === lane.wip) || (lane.review !== undefined && task.column === lane.review)) @@ -182,6 +213,7 @@ export async function reconcileMissionFeatureState( reason: (lane.review !== undefined && task.column === lane.review) ? `task ${task.id} is in review` : `task ${task.id} started`, + alignment, }; } @@ -226,8 +258,9 @@ export async function reconcileMissionFeatureState( kind: "update", status: "triaged", reason: `task ${task.id} returned to triage`, + alignment, }; } - return { kind: "noop" }; + return { kind: "noop", alignment }; } diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 7dd58ab79b..017383ddf4 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -53,6 +53,7 @@ import { resolveIntegrationBranch } from "./merge/integration-branch.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; +import { createStoreSpecDriftRepository, SpecDriftReconciler } from "./spec-drift-reconciler.js"; import type { WorktreePool } from "./worktree/worktree-pool.js"; import type { ProjectRuntimeConfig } from "./project/project-runtime.js"; import { PrMonitor } from "./merge/pr-monitor.js"; @@ -399,6 +400,7 @@ type MergeResolver = { resolve: (result: MergeResult) => void; reject: (err: Err export class ProjectEngine { private runtime: InProcessRuntime; private started = false; + private specDriftReconciler?: SpecDriftReconciler; private prMonitor?: PrMonitor; /** * FNXC:PlannerOversight 2026-07-04-00:00: @@ -682,6 +684,7 @@ export class ProjectEngine { private taskMovedHandler?: (...args: any[]) => void; private taskUpdatedHandler?: (...args: any[]) => void; private taskDeletedHandler?: (...args: any[]) => void; + private specDriftTaskMutationHandler?: (...args: any[]) => void; private autostashOrphansHandler?: (...args: any[]) => void; private legacyAutoMergeStampAdvisoryEmitted = false; @@ -946,6 +949,29 @@ export class ProjectEngine { }); const store = this.runtime.getTaskStore(); + /* + FNXC:SpecDrift 2026-08-10-09:36: + The startup and live-event reconciler must receive the shared store repository rather than an + inline latest-report snapshot. Full append-only history preserves re-locked divergence, while + the report identity fence intentionally cannot detect an incorrect alignment value. + */ + this.specDriftReconciler = new SpecDriftReconciler(createStoreSpecDriftRepository(store)); + /* + FNXC:SpecDrift 2026-08-09-18:32: + Startup repair alone leaves a long-running engine blind to direct task mutations and workflow + moves. Subscribe once at the runtime boundary; the reconciler coalesces bursts and its report + insert fence prevents this listener from turning task:updated into a feedback loop. + */ + this.specDriftTaskMutationHandler = (event: Task | { task?: Task }) => { + const task = "id" in event ? event : event.task; + if (task?.id) this.specDriftReconciler?.enqueue(task.id); + }; + store.on("task:created", this.specDriftTaskMutationHandler); + store.on("task:updated", this.specDriftTaskMutationHandler); + store.on("task:moved", this.specDriftTaskMutationHandler); + for (const task of await store.listTasks({ includeArchived: true, slim: true })) { + this.specDriftReconciler.enqueue(task.id); + } const cwd = this.config.workingDirectory; const settings = await store.getSettings(); const migrationNotice = settings.sqliteMigrationNotice; @@ -1410,6 +1436,8 @@ export class ProjectEngine { */ this.shuttingDown = true; this.startupGeneration += 1; + this.specDriftReconciler?.stop(); + this.specDriftReconciler = undefined; // FNXC:VerificationConcurrency 2026-07-15-09:05: Drop this project's cap so it no longer pins process min. unregisterProjectVerificationLimit(this.config.projectId); @@ -1510,6 +1538,11 @@ export class ProjectEngine { if (this.taskDeletedHandler) { store.off("task:deleted", this.taskDeletedHandler); } + if (this.specDriftTaskMutationHandler) { + store.off("task:created", this.specDriftTaskMutationHandler); + store.off("task:updated", this.specDriftTaskMutationHandler); + store.off("task:moved", this.specDriftTaskMutationHandler); + } if (this.autostashOrphansHandler) { store.off("merger:autostashOrphans", this.autostashOrphansHandler as any); } diff --git a/packages/engine/src/spec-drift-reconciler.ts b/packages/engine/src/spec-drift-reconciler.ts new file mode 100644 index 0000000000..ccae80f668 --- /dev/null +++ b/packages/engine/src/spec-drift-reconciler.ts @@ -0,0 +1,105 @@ +import { evaluateSpecDrift, hasPriorLockDivergence, type CurrentPlanEvidence, type DriftReport, type SpecLock, type TaskStore } from "@fusion/core"; + +export interface SpecDriftSnapshot { + latestLock?: SpecLock; + currentPlan?: CurrentPlanEvidence; + approvedPlanFingerprint?: string; + modifiedFiles?: string[]; + priorDivergence?: boolean; +} +export interface SpecDriftRepository { + snapshot(taskId: string): Promise; + persist(taskId: string, report: DriftReport): Promise; +} + +const RETRY_DELAY_MS = 1_000; + +/** + * FNXC:SpecDrift 2026-08-10-09:28: + * Startup replay and live task mutations share this repository so both reconcile retained evidence + * identically without treating drift as a lifecycle or quality verdict. A latest-report-only read + * erases v1 divergence after a clean v2 re-lock; report identity fencing cannot prevent that + * incorrect alignment because alignment is deliberately not part of the identity. + */ +export function createStoreSpecDriftRepository( + store: Pick, +): SpecDriftRepository { + return { + snapshot: async (taskId) => { + const [task, latestLock, currentPlan, reports] = await Promise.all([ + store.getTask(taskId), + store.getLatestSpecLock(taskId), + store.getLatestCurrentPlanEvidence(taskId), + store.listSpecDriftReports(taskId), + ]); + return { + latestLock, + currentPlan, + approvedPlanFingerprint: task.approvedPlanFingerprint, + modifiedFiles: task.modifiedFiles, + priorDivergence: hasPriorLockDivergence(reports, latestLock?.version), + }; + }, + persist: async (taskId, report) => { await store.appendSpecDriftReport(taskId, report); }, + }; +} + +/** + * FNXC:SpecDrift 2026-08-09-18:17: + * A report-write outage must retry from a fresh snapshot without waiting for process restart. + * Timers coalesce per task and are cancelled on stop; retries never alter task lifecycle state. + */ +export class SpecDriftReconciler { + private stopped = false; + private readonly retryTimers = new Map>(); + private readonly queuedTaskIds = new Set(); + public constructor(private readonly repository: SpecDriftRepository) {} + + /** + * FNXC:SpecDrift 2026-08-09-18:32: + * Live task events can arrive in one transaction-sized burst. Queue one fresh comparison per + * task rather than making event delivery a polling loop; persistence still fences the snapshot. + */ + enqueue(taskId: string): void { + if (this.stopped || this.queuedTaskIds.has(taskId)) return; + this.queuedTaskIds.add(taskId); + queueMicrotask(() => { + this.queuedTaskIds.delete(taskId); + void this.reconcile(taskId).catch(() => undefined); + }); + } + + stop(): void { + this.stopped = true; + for (const timer of this.retryTimers.values()) clearTimeout(timer); + this.retryTimers.clear(); + this.queuedTaskIds.clear(); + } + + async reconcile(taskId: string): Promise { + if (this.stopped) return undefined; + try { + const snapshot = await this.repository.snapshot(taskId); + if (this.stopped) return undefined; + const report = evaluateSpecDrift(snapshot); + if (this.stopped) return undefined; + await this.repository.persist(taskId, report); + const retry = this.retryTimers.get(taskId); + if (retry) clearTimeout(retry); + this.retryTimers.delete(taskId); + return report; + } catch (error) { + this.scheduleRetry(taskId); + throw error; + } + } + + private scheduleRetry(taskId: string): void { + if (this.stopped || this.retryTimers.has(taskId)) return; + const timer = setTimeout(() => { + this.retryTimers.delete(taskId); + void this.reconcile(taskId).catch(() => undefined); + }, RETRY_DELAY_MS); + this.retryTimers.set(taskId, timer); + } +} diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 3f218a1768..915dd54013 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -4811,6 +4811,17 @@ export class TriageProcessor { logger: { warn: (m: string) => planLog.warn(m) }, }); + /* + FNXC:SpecLock 2026-08-09-07:36: + Planning finalization writes PROMPT.md without going through updateTask({ prompt }), so it must + capture the same canonical evidence before any approval path can release the task. This remains + before the release boundary: a database/parser failure leaves the planning hold intact. + */ + const supportsSpecLock = (this.store as unknown as { isBackendMode?: () => boolean }).isBackendMode?.() === true; + if (supportsSpecLock) { + await this.store.captureCurrentPlanEvidence(task.id, written); + } + let taskIntentSignature: ReturnType = { routePaths: [], filePaths: [], @@ -5122,6 +5133,24 @@ export class TriageProcessor { } } + /* + FNXC:SpecLock 2026-08-09-07:36: + Auto-approved finalization is an accepted-plan path too. Append/reuse its immutable lock before + the scheduler-visible handoff, then persist the matching fingerprint; a crash between these + writes leaves an inert historical lock rather than granting mutable prompt content approval. + */ + if (supportsSpecLock) { + const fingerprint = computePlanApprovalFingerprint(written); + await this.store.lockCurrentPlanWhilePlanningLocked(task.id, fingerprint, written); + if (!await this.updatePlanningStateIfStillCurrent(task, { approvedPlanFingerprint: fingerprint })) return; + /* + FNXC:SpecDrift 2026-08-09-07:36: + Establish the first report while the planning lifecycle fence is still held. A release never + races ahead of the deterministic comparison; later evidence updates can coalesce retries. + */ + await this.store.reconcileSpecDriftWhilePlanningLocked({ ...task, approvedPlanFingerprint: fingerprint }); + } + if (shouldClearWorkflowRunStepInstances) { /* FNXC:WorkflowReplan 2026-06-29-00:33: