diff --git a/docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md b/docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md new file mode 100644 index 0000000000..532fd704a2 --- /dev/null +++ b/docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md @@ -0,0 +1,374 @@ +--- +title: "refactor: IR-driven lifecycle cutover — workflow as the single source of truth" +type: refactor +status: active +date: 2026-07-18 +--- + +# refactor: IR-driven lifecycle cutover — workflow as the single source of truth + +## Summary + +Complete the workflow-native cutover: the workflow IR (columns, traits, node column assignments, edges) becomes the only authority over task lifecycle. The graph moves tasks between columns as traversal crosses node column boundaries; the scheduler, self-healing, merger, and dashboard derive behavior from column traits instead of literal column ids; the graph exclusively owns Plan Review; `reviewLevel` becomes a creation-time preset; and the legacy execution machinery (graph re-entry interceptors, triage's out-of-graph Plan Review gate, per-step review injection, parity/authoritative cutover scaffolding) is deleted in one big-bang change. Acceptance is a user-authored 6-column workflow — Ideas → Todo → In-progress → In-review → Merging → Done — building in the editor and running end-to-end with the card visibly driven through every column by the workflow alone. + +--- + +## Problem Frame + +A user report (verified against the code) demonstrated three architectural failures of the current mixed state: + +1. **The executor hardcodes column names and ignores the IR.** The merge boundary always calls `moveTask(id, "in-review")` (`packages/engine/src/executor.ts` ~6956, explicitly allowlisted); the v1→v2 upgrade maps the merge seam to `"in-review"`; `packages/engine/src/workflow-graph-executor.ts` contains zero `moveTask` calls — node column assignments are cosmetic. A custom "Merging" column never receives the task; Code Review runs while the card sits in whatever column it happens to occupy. +2. **Triage and the graph race on Plan Review.** Triage owns an out-of-graph pre-release gate (`runPlanReviewBeforeExecution`) that writes a `pending` step result; the graph's dedup honors only `status === "passed"` (`workflow-graph-executor.ts:663`), so interleavings launch duplicate reviewers, and the losing verdict is silently discarded. +3. **Review Levels (0–3) are vestigial.** `fn_review_step` is never injected for graph tasks; group enablement comes from `enabledWorkflowSteps`/`defaultOn` only; `reviewLevel` survives as dead runtime state and misleading prompt text. + +This plan is the completion of the active `docs/plans/2026-06-09-001-refactor-big-bang-workflow-native-execution-plan.md` arc — its U6 (runtime column moves), U7 (recovery re-keying), and U9 (legacy deletion) remainder — under the operator's directive: no more mixed state; the workflow drives execution. + +--- + +## Requirements + +**IR as runtime authority** + +- R1. Node column assignments move tasks at runtime: when graph traversal crosses from a node in column A to a node in column B, the task moves to column B through the store's trait-hook `moveTask` path (transition-pending crash-safe), attributed `workflowMoveSource: "workflow-graph"`. +- R2. No engine lifecycle code selects a move target or enumerates tasks by literal column id or legacy status string. All lifecycle predicates re-key on column traits (`intake`, `hold`, `wip`, `merge-blocker`, `human-review`, `merge`, `complete`, `archived`, `timing`, `abort-on-exit`, `reset-on-entry`, `stall-detection`) and workflow step state. (Step statuses like `step.status === "done"` are not columns and are out of scope of this rule.) +- R3. Failure edges terminating at `end` park the task `failed` in its current column; the `complete` trait's semantics (dependency release, timing stop) fire only on success-path terminal arrival, never on mere column entry. + +**Single ownership of review gates** + +- R4. The graph exclusively owns Plan Review. Triage's role reduces to authoring PROMPT.md; `runPlanReviewBeforeExecution` and the triage-owned statuses `planning`, `needs-replan`, and `plan-review-unavailable` are deleted, their semantics re-owned by graph nodes (plan-review group, plan-replan node, reviewer-outage retry) and workflow step state. +- R5. Exactly one reviewer session runs per review gate per attempt: `pending` step results are CAS-claimed leases with owner and staleness floor; graph re-entry after crash/restart honors or reclaims the lease instead of dispatching a second reviewer. +- R6. `reviewLevel` becomes a creation-time preset that writes `enabledWorkflowSteps` and has zero runtime reads in the engine. Existing tasks are backfilled once. + +**Invariants preserved (non-configurable)** + +- R7. The following contracts survive the cutover, restated in trait terms: user hard-cancel on backward move out of a wip/merge column (`abort-on-exit` + user-paused semantics); `autoMerge:false` terminal-until-human (FN-5147/FN-5819, additive gating via `allowsAutoMergeProcessing`); done-only-on-confirmed-merge; FN-8141 blocked/skip-bypass taint guards; triple-proof backward moves in self-healing; file-scope/squash guards; FN-7863-style bounded requeue caps. +- R7b. **Confirmed-merge-must-finalize (dual of done-only-on-confirmed-merge).** Once a merge is confirmed (commit landed on the target branch), finalization to the complete column is unblockable: implementation-proof and step-checklist gates run strictly PRE-merge (merge-gate / implementation-proof nodes); post-merge, a stale or incomplete step checklist is reconciled (steps auto-completed/skipped with a run-audit event), never a park. A task must never sit `failed` with its code already merged. Motivating incident (operator screenshot, 2026-07-18): "Merge confirmed but finalization blocked: task has incomplete steps" with only manual Retry as the exit. +- R8. `builtin:coding` keeps its column ids and observable behavior byte-compatible: an untouched default-workflow project behaves identically before and after the cutover (characterization oracle pins this). + +**Big-bang deletion** + +- R9. Deleted outright, with tombstone assertions: `graphCompletionInterceptors` re-entry machinery (`graphStepRunOnce`, `graphSeamGoverningNodeId`, etc.), `fn_review_step` injection and per-step review prompt scaffolding, `runPlanReviewBeforeExecution`, the legacy workflow-steps runner path, `packages/core/src/workflow-cutover.ts`, `packages/engine/src/workflow-authoritative-driver.ts`, `packages/engine/src/workflow-parity-observer.ts` and parity evidence machinery, and the executor merge-boundary hardcoded move plus its `handoff-invariant-violation-allowlist` mechanism. + +**Migration and acceptance** + +- R10. No silently frozen tasks: every legacy (column, status) combination at upgrade time maps to a graph node via an explicit adoption table; unmappable rows park `paused` with a run-audit reconcile event. Orphaned `pending` Plan Review results are swept. A schema-baseline version gate refuses writes from pre-cutover binaries. +- R11. The 6-column benchmark workflow (Ideas intake/no-AI → Todo hold+triage+Plan Review → In-progress wip/execute → In-review code-review+completion-summary → Merging merge-gate/merge/squash → Done complete) is buildable in the workflow editor and runs end-to-end in an automated acceptance test asserting the observability contract in U11. +- R12. Benchmark column-role purity: each column runs only its own role's sessions. No reviewer session runs while the card is in In-progress; no executor session runs while the card is in In-review; nothing runs in Ideas or Done. The acceptance test asserts this from session/run records. + +### Benchmark column contract (operator-specified) + +The operator's authoritative description of the benchmark's per-column behavior. U11 encodes it as assertions; U2's editor validation must permit this exact shape. + +- **Ideas** — intake, no AI. Task waits with title only; operator manually promotes to Todo. Nothing else fires. +- **Todo** — triage writes PROMPT.md (mission, steps, files, tests, acceptance criteria); an independent reviewer validates it. On REVISE, triage rewrites and the reviewer re-checks **exactly once** (benchmark replan cap = 1; a second REVISE parks awaiting-approval). On APPROVE, the card moves to In-progress. +- **In-progress** — pure execution: isolated worktree, steps executed one by one, each step tested and committed. No Code Review, no summary. All steps done → card moves to In-review. +- **In-review** — senior reviewer analyzes the entire diff. Pass → completion summary (2–4 sentences) is generated, then the card moves to Merging. Bugs found → card moves **back to In-progress** (visible backward move) for fixes, then re-review, up to **3 cycles**; a fourth failure parks. +- **Merging** — clean-room copy of main, dependency install, final diff review, squash merge. Transient failure (model unavailable, conflict) retries up to **3 times**. `autoMerge:false` → the card **waits in Merging** for manual approval (settles the `human-review` trait placement: Merging, not In-review). Merge lands → Done. +- **Done** — terminal. Nothing else happens. + +--- + +## Key Technical Decisions + +- KTD-1. **Failure-terminal ≠ complete-column entry, with an explicit failure-class taxonomy.** `end` is a graph terminal, not a column destination. Success-path arrival at `end` (or a success node in a `complete` column) triggers the complete trait. Failure classes split: **recoverable failures** (worktree repair, tool-failure retry budgets, abort/stuck with retries remaining) are node-internal outcomes that rebound via KTD-10 and never traverse a failure edge; **only terminal exhaustion** (budgets spent, FN-8141 blocked, non-recoverable errors) traverses the failure edge and parks `failed` in place. The ~30 legacy executor rebound sites map to the recoverable class. Rationale: the builtin IR routes `execute → end` on failure; naive column-following would display failed work as Done, while routing all failures down the edge would delete today's retry behavior and break R8. +- KTD-2. **Single mover at the hold→wip seam.** The graph parks the task at a "ready-for-release" seam (workflow run state, not a legacy status string) **only when the boundary being crossed is hold→wip**; the scheduler's capacity sweep remains the sole actor that crosses hold→wip via `moveTask`. Every other boundary — including hold→hold and hold→non-wip exits — is graph-moved, so nodes running inside a hold column (the benchmark's Plan Review in Todo) can never strand a task at a seam the scheduler doesn't serve. Rationale: capacity/WIP arbitration is a substrate concern (in-txn slot counting, KTD-10 of the capacity design); two movers at the busiest seam means double-dispatch or deadlock. +- KTD-3. **IR resolution pinned per node-entry, durably.** A task resolves its workflow IR when entering a node, persists the resolved IR version/content hash on the workflow run state, and holds that resolution until the node settles. Restart recovery compares the stored pin against the current IR and takes the drift-park path on mismatch — the pin survives crashes. The pin field is carried by U9's migration and `getTaskSelectClause` slim projections. If an edit deleted the current node or its column, the task parks with a `task:reconcile-workflow-drift` run-audit event instead of traversing a mutated graph. Rationale: `resolveWorkflowIrForTask` is currently live-per-call; mid-flight edits changing the graph under a running task is the largest determinism hole found in flow analysis. +- KTD-4. **Pending step results are leases.** A `pending` `WorkflowStepResult` carries owner (session/run id) and `startedAt`; claiming is compare-and-set; re-entry honors a live lease and reclaims only past a staleness floor (FN-6736 pattern). Rationale: "also match pending" alone still double-dispatches after crash-restart. +- KTD-5. **One shared transition validator: pure logic in `packages/core/src/workflow-transition-policy.ts`, sole call site in `moves.ts`.** The policy module holds the pure trait-invariant logic (unit-testable without a store); `packages/core/src/task-store/moves.ts` is the single in-lock enforcement point every mover — graph, scheduler release, self-healing rebound, heartbeat, operator drag, dashboard routes — flows through, with return-guard postconditions. No other module may call the policy directly. For direct graph entry into a `wip` column (no-hold workflows), the validator invokes the same `workflow-capacity` counter the scheduler uses, in-txn, so there is exactly one capacity-counting authority; a move into a saturated wip column is rejected and the task parks ready at the boundary. Branch-local checks are defense-in-depth only. Rationale: `docs/solutions/logic-errors/repo-root-task-worktree-requeue-loop.md` — invariants enforced at one branch of one gate loop forever. +- KTD-6. **Additive gating for trait predicates.** Processing gates keep the `allowsAutoMergeProcessing` shape (`X !== false || override === true`), never `task.x ?? settings.x` effective-value collapse. Rationale: `docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md` — plain resolution starves manual-required parking branches. +- KTD-7. **`builtin:coding` is the characterization oracle.** Column ids stay the legacy enum values (zero task-row migration, per the existing KTD-1 of the builtin IR); a parity test pins the default-workflow task pipeline byte-compatible across the cutover. +- KTD-8. **Adoption table over drain-before-upgrade.** Store-open migration maps every legacy (column, status) row to a graph node/run-state; `planning` → planning node re-entry, `needs-replan` → plan-replan node, `plan-review-unavailable` → plan-review retry, replan-cap park → preserved awaiting-approval, mid-flight in-progress/in-review → equivalent seam nodes. Unmappable → `paused` + audit. Rationale: big-bang deletes the legacy runner; there is nothing left to finish un-adopted tasks. +- KTD-9. **WIP accounting: shared budget, pending counts.** Multiple `wip` columns share the `maxConcurrent`/maxWorktrees-style budget via `limitSetting`; per-column `limit` overrides remain available; `countPending: true` semantics are read by the scheduler's single counter. Rationale: operator-visible concurrency must not silently multiply when a workflow has two wip columns. +- KTD-10. **Trait-derived rebound targets with fallback ordering.** Self-healing "requeue to backlog" targets the task's workflow's `hold` column; if absent, the `intake` column; if absent, the first column. Rationale: every `task:reconcile-*` rule currently says literal `"todo"`; custom workflows may lack it. +- KTD-11. **`reviewLevel` preset writes go through the optional-group id pass-through.** The mapper resolves via `resolveAllOptionalGroupIds`/`optionalGroupIdSet` so ids reach `enabledWorkflowSteps` identity-stable; regression tests use a colliding id through the store's create and update paths. Rationale: `docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md` — the exact mechanism failed silently once. +- KTD-12. **Run-audit stays ids/counts/outcomes-only.** New events (`task:column-transition`, `task:reconcile-workflow-drift`, lease claim/reclaim events) follow the established metadata policy; no prose, no model ids. + +--- + +## High-Level Technical Design + +### Ownership after the cutover + +```mermaid +flowchart LR + subgraph IR["Workflow IR (policy)"] + COLS["Columns + traits\n(intake/hold/wip/merge-blocker/\nhuman-review/merge/complete/...)"] + NODES["Nodes with column assignments\n+ edges (success/failure/outcome)"] + end + + subgraph GRAPH["Workflow graph executor (sole lifecycle driver)"] + TRAV["Traversal: node-entry pins IR,\ncrossing column boundary => moveTask"] + GATES["Review gates as nodes\n(plan-review, code-review)\npending = CAS lease"] + end + + subgraph SUBSTRATE["Engine substrate (capabilities, not policy)"] + SCHED["Scheduler: sole hold->wip mover\n(capacity, WIP budget, countPending)"] + STORE["store moveTask: shared transition\nvalidator + trait guards/gates/hooks\n+ transition-pending"] + HEAL["Self-healing: trait-keyed sweeps,\nwake/route recovery nodes,\nnever direct lifecycle policy"] + end + + IR --> TRAV + TRAV -->|"all boundaries except hold->wip"| STORE + TRAV -->|"parks ready-for-release at hold seam"| SCHED + SCHED -->|"hold->wip release"| STORE + HEAL -->|"rebounds via trait-derived targets"| STORE + GATES -->|"workflowStepResults (leased)"| STORE +``` + +### Benchmark card lifecycle (success + principal failure paths) + +```mermaid +stateDiagram-v2 + [*] --> Ideas: create (intake, no AI) + Ideas --> Todo: operator promote + Todo --> Todo: triage writes PROMPT.md,\nPlan Review node (in hold column) + Todo --> Todo: Plan Review REVISE -> plan-replan\n(loops in Todo; replan-cap parks awaiting-approval) + Todo --> InProgress: Plan Review PASS ->\nready-for-release; scheduler releases (sole mover) + InProgress --> InProgress: execute (wip, timing) + InProgress --> InProgress: execute failure -> park failed in place (KTD-1) + InProgress --> InReview: execute success (graph move) + InReview --> InProgress: Code Review REVISE ->\nremediation node's column (visible backward move) + InReview --> Merging: Completion Summary -> merge-gate (graph move) + Merging --> Merging: merge retry / manual hold\n(autoMerge:false parks terminal-until-human here) + Merging --> Merging: merge failure exhausted -> park failed in place + Merging --> Done: merge confirmed -> success terminal;\ncomplete trait fires (deps release, timing stop) + Done --> [*] +``` + +Prose is authoritative where the diagrams compress: the hard-cancel contract applies to any operator backward move out of a wip/merge-orchestration column regardless of target; `reset-on-entry` fires only where the trait exists on the target column. + +--- + +## Scope Boundaries + +**In scope:** engine lifecycle (executor, scheduler, hold-release, self-healing, triage, merger/auto-merge-finalization), store move/transition layer, workflow IR validation, reviewLevel preset + backfill, dashboard/API lifecycle routes and ColumnId typing, the acceptance benchmark, legacy deletion. + +**Out of scope (true non-goals):** dashboard visual redesign; merge machinery internals (conflict resolution strategies, squash policy, scope-partition rules — they are invoked by merge nodes but not rewritten); multi-node/PG storage architecture (the cutover must be PG-correct but does not change storage design); mission/autopilot model. + +### Deferred to Follow-Up Work + +- `needs-replan` reader migration: post-cutover, the durable replan signal is written solely by the graph's plan-replan seam but still carries the legacy status name, with 14 readers (triage discovery, surgical-revision seed selection, hold-release gating, merge-block, dashboard). Migrating readers to a purpose-built workflow run-state signal is deferred as its own unit — implementation-time tracing showed the write is already graph-owned, so this is naming/purity, not legacy machinery (coordinator ruling during U10b). +- Workflow editor UX affordances beyond validation (e.g., visual trait palette polish, guided templates for the 6-column shape). +- Plugin-facing trait hook surface expansion (`gate` verdict UX) beyond what the cutover requires. +- Multi-node lease-sweep hardening beyond the FN-6736 staleness-floor standard (full liveness-proof protocol for cross-node transition-pending recovery) — the cutover ships the single-node-safe + staleness-floor form. +- Capturing the landed design into `docs/solutions/` and `CONCEPTS.md` (post-land `/ce-compound`). + +--- + +## Implementation Units + +Phased for dependency order. Big-bang means one release contains all phases; the phases sequence the work, not the rollout. + +**Landing strategy (operator-decided):** all units land on a single long-lived feature branch in a dedicated worktree (`wt switch --create` per the standing worktree rule — the primary checkout stays on `main`), merged to main once, at the end, as the one cutover moment. Working agreement for surviving concurrent main commits: rebase the branch against main at least at every phase boundary (A→B→C→D→E), and immediately after any main commit touching `executor.ts`, `self-healing.ts`, `scheduler.ts`, `triage.ts`, or `moves.ts`; the merge itself happens in a declared operator freeze window with the U9 baseline bump making downgrade-writes impossible. Main never carries a mixed state. + +### Phase A — Transition foundation + +### U1. Graph-driven column transitions and the shared transition validator + +- **Goal:** Crossing a node column boundary moves the task; all movers share one in-lock validator; failure edges park in place. +- **Requirements:** R1, R2, R3 +- **Dependencies:** none +- **Files:** `packages/core/src/task-store/moves.ts`, `packages/core/src/transition-pending.ts`, `packages/core/src/workflow-ir-resolver.ts` (per-node-entry pinning, KTD-3), `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-graph-task-runner.ts`, new `packages/core/src/workflow-transition-policy.ts` (single validator seam), tests `packages/engine/src/__tests__/workflow-graph-column-moves.test.ts`, `packages/core/src/task-store/__tests__/transition-validator.test.ts` +- **Approach:** Add a boundary-crossing step to graph traversal: on entering a node whose column differs from `task.column`, call `moveTask(id, node.column, { moveSource: "engine", workflowMoveSource: "workflow-graph" })`; transition-pending marks in-txn, hooks run post-commit. `end` and failure edges never produce moves (KTD-1). Pin IR per node-entry; deleted node/column parks with `task:reconcile-workflow-drift` (KTD-3, KTD-12). Emit `task:column-transition` with `{taskId, fromColumn, toColumn, nodeId, workflowId}`. The validator hosts trait invariants (terminal columns never re-enter wip; merge-blocker on complete-bound entry) with return-guard postconditions (KTD-5). +- **Patterns to follow:** existing trait guard/gate machinery in `moves.ts` (U8/KTD-2 comments); `transition-pending.ts` recovery; run-audit metadata policy in AGENTS.md. +- **Test scenarios:** + - Happy path: traversal across a graph-owned boundary (execute success, In-progress→In-review) moves the card once, emits one `task:column-transition`, transitionPending settles null. Hold→wip boundaries are excluded from graph moves from the start (the graph parks at the ready-for-release seam; scheduler release is U4's surface) so this unit's tests survive U4 unchanged. + - Same-column node chain (code-review → completion-summary both in In-review) produces zero moves. + - Failure edge from execute to `end`: card stays in wip column with `failed`; no move event; complete trait does not fire. + - Kill/restart mid-transition: pending marker recovered, move settles exactly once (single event). + - IR edited mid-flight deleting the current node: task parks, `task:reconcile-workflow-drift` emitted, no traversal of the mutated graph. + - Validator postcondition: any mover attempting complete-column entry with unresolved merge-blocker is rejected identically (graph, self-healing, operator route). + - Soft-deleted task racing a graph move: skip-don't-park (FN-8004 shape). +- **Verification:** file-scoped vitest for the new tests; `pnpm verify:fast`. + +### U2. Workflow IR validation hardening + +- **Goal:** The editor cannot save an IR the runtime can't drive; runtime degrades defined-safely when it happens anyway. +- **Requirements:** R1, R11 +- **Dependencies:** none +- **Files:** `packages/core/src/workflow-ir.ts` (parse/validate), `packages/core/src/trait-registry.ts` (`validateColumnTraits`), dashboard editor validation surfaces (`packages/dashboard/app` workflow editor components, `packages/dashboard/src/routes/` workflow save routes), tests `packages/core/src/__tests__/workflow-ir-validation.test.ts` +- **Approach:** Save-time hard errors: node assigned to nonexistent column; `merge-blocker` present with no reachable merge-class node; column deletion while tasks occupy it (route-level guard listing occupant count); workflows must declare a creation column (intake if present, else first column is the documented default). Runtime tolerance: unknown node column → no-move + drift event (from U1). Capability floor: validation must **permit** the operator's benchmark shape — review nodes placed in a hold column (Plan Review in Todo), per-workflow bounded revise/retry caps as node config (replan cap 1, code-review cycles 3, merge retries 3), a remediation edge that moves the card backward across a column boundary (In-review → In-progress), and a completion-summary node ordered after a review node within the same column. If any of these is expressible in the editor but rejected by validation (or vice versa), that is a U2 bug. +- **Test scenarios:** each validation rejects a crafted IR with a specific error; the 6-column benchmark IR passes; a merge-less docs-only workflow with no merge-blocker passes; column-delete-with-occupants returns the occupant guard error. +- **Verification:** file-scoped vitest. + +### Phase B — Ownership cutover + +### U3. Graph-exclusive Plan Review with leased pending results + +- **Goal:** One owner for Plan Review; duplicate reviewers impossible by construction; triage-owned statuses retired. +- **Requirements:** R4, R5 +- **Dependencies:** U1 +- **Files:** `packages/engine/src/triage.ts` (delete `runPlanReviewBeforeExecution`, `retryUnavailablePlanReview`, planning-status lifecycle), `packages/engine/src/workflow-graph-executor.ts` (plan-review group claim semantics at the ~663 dedup site), `packages/core/src/workflow-step-results.ts` (lease fields + CAS claim), `packages/engine/src/hold-release.ts` (`isUnplannedForExecution` re-keyed to workflow step state), `packages/engine/src/replan-target.ts`, tests `packages/engine/src/__tests__/plan-review-single-owner.test.ts`, `packages/engine/src/__tests__/plan-review-lease.test.ts` +- **Approach:** Triage becomes a planning-node runner: it writes PROMPT.md and returns; the graph's plan-review group runs where the IR places it (in the benchmark, inside the hold column Todo). Pending results gain `{owner, startedAt}`; claim is CAS; re-entry with a live lease waits/adopts, reclaim past staleness floor (KTD-4). Statuses `planning`/`needs-replan`/`plan-review-unavailable` are replaced by workflow run state + step results; the replan-cap awaiting-approval park is preserved as a graph-node outcome. "Unplanned never releases" re-keys on: bootstrap-stub PROMPT.md OR plan-review group enabled-and-not-passed (replacing the `status === "planning"` check). +- **Execution note:** Start with a failing regression test reproducing the duplicate-reviewer interleaving from the user report (triage-pending + graph re-entry → assert exactly one reviewer session). +- **Test scenarios:** + - Covers the report's race: pending result exists → graph waits/adopts; zero second reviewer sessions in the session store. + - Crash after reviewer dispatch, restart before verdict: lease honored within staleness floor; reclaimed after it; never two live reviewers. + - REVISE loops within the hold column; replan-cap parks awaiting-approval and survives restart. + - Reviewer-provider outage: retry stays in the plan-review node with backoff; PROMPT.md not rewritten. + - Plan Review disabled (`enabledWorkflowSteps` empty): card releases without any reviewer. +- **Verification:** file-scoped vitest; symptom verification — the exact FN-1315-shaped double "Starting workflow step: Plan Review" interleaving asserted gone. + +### U4. Scheduler and hold-release trait cutover (single mover) + +- **Goal:** Dispatch derives from traits; the scheduler is the sole hold→wip mover; WIP accounting is trait-configured. +- **Requirements:** R2, R7 +- **Dependencies:** U1, U3 +- **Files:** `packages/engine/src/scheduler.ts` (literal `"todo"` watch, `moveTask(id,"in-progress")` sites ~1888/2185/2268–2311), `packages/engine/src/hold-release.ts`, `packages/core/src/workflow-capacity.ts`, tests `packages/engine/src/__tests__/scheduler-trait-dispatch.test.ts` +- **Approach:** Scheduler selects candidates by `hold` trait + ready-for-release run state (KTD-2), counts WIP by `wip`-trait columns against the shared budget with `countPending` (KTD-9), and releases into the edge-adjacent wip column from the IR (`resolveColumnAdjacency`), not `"in-progress"`. Graph parks at the hold seam instead of moving. `isUnplannedForExecution` loses its literal-`"todo"` OR-branch. Workflows with no hold column: graph moves straight across; the wip trait's own limit is the only gate (documented). +- **Test scenarios:** + - Capacity saturation: benchmark card waits in Todo until a slot frees; release moves it to In-progress exactly once (no graph/scheduler double-move under interleaving). + - Pending transition counts toward the cap (`countPending`). + - Two wip columns share one budget; per-column `limit` override respected. + - No-hold workflow dispatches without scheduler involvement; at wip saturation the graph move is rejected by the in-txn validator capacity check (KTD-5) and the task parks ready at the boundary until a slot frees — never unbounded parallelism, never a second counter. + - Ready-for-release + paused/user-paused never releases. +- **Verification:** file-scoped vitest; `scheduler-workflow-cutover` gate suite stays green. + +### U5. Executor cutover: IR-driven boundaries, legacy re-entry machinery deleted + +- **Goal:** The executor is a node runner; the graph owns all lifecycle boundaries; the hardcoded merge-boundary move and interceptor re-entry are gone. +- **Requirements:** R1, R2, R9 +- **Dependencies:** U1, U3, U4 +- **Files:** `packages/engine/src/executor.ts` (merge boundary `ensureWorkflowMergeBoundaryTask` ~6908–6963, `graphCompletionInterceptors` ~5200/6353/10445/11384/12048/12237/12465–12554, `fn_review_step` injection ~11682, review-level prompt scaffolding ~19841–19994, the ~30 `moveTask(id,"todo")` rebound sites re-keyed), `packages/engine/src/workflow-graph-task-runner.ts`, tests `packages/engine/src/__tests__/executor-graph-boundary.test.ts` plus updates to `executor-graph-requeue-gate`, `task-pipeline-smoke` +- **Approach:** The merge node's own column assignment drives the pre-merge handoff (U1 machinery); delete the hardcoded `"in-review"` move and the `handoff-invariant-violation-allowlist` mechanism. Replace interceptor-based re-entry with a direct node-runner call path (the 06-09 plan's KTD-6 deletion list). Executor requeue/rebound targets derive from KTD-10. FN-8141 blocked exit parks `failed` in the wip column with dependency edges, restated on traits. Legacy test-fake seams: minimal executor-core fakes without workflow-selection APIs are the expected breakage surface — update fakes to the workflow-aware store shape rather than keeping a legacy characterization path. +- **Test scenarios:** + - Benchmark merge handoff lands in Merging (not In-review) because the IR says so; builtin:coding still lands in `in-review` (KTD-7 parity). + - Interceptor tombstone: no `graphCompletionInterceptors` symbol; graph tasks complete through the node-runner path. + - `fn_review_step` absent from graph-task tool lists; prompt contains no per-step review instructions. + - Execute failure/abort/stuck rebounds to the trait-derived backlog target preserving progress per flavor. + - FN-8141: `fn_task_done(outcome="blocked")` parks failed-in-place, requeues behind blocker deps, never auto-recovers to review. +- **Verification:** `task-pipeline-smoke` (builtin parity canary) green; file-scoped vitest; `pnpm verify:fast`. + +### Phase C — Recovery and merge re-key + +### U6. Self-healing trait re-key + +- **Goal:** All recovery sweeps enumerate and rebound by trait with unchanged invariants. +- **Requirements:** R2, R7, R8 (characterization oracle: the pre/post byte-identical sweep-decision suite on builtin:coding fixtures is R8's primary evidence for the recovery surface) +- **Dependencies:** U1, U5 +- **Files:** `packages/engine/src/self-healing.ts` (the ~206-hit surface: `listTasks({column: ...})` sites 2859–5414, rebound legality matrices 1302–1317/4202–4206, `moveTask("todo")` rebounds, heartbeat progression 5007–5012), `packages/engine/src/agent-heartbeat.ts`, tests `packages/engine/src/__tests__/self-healing-trait-rekey.test.ts` +- **Approach:** Sweeps enumerate by trait predicates (wip columns, merge-orchestration columns, complete/archived terminal); backward-rebound legality re-keys as "wip/merge column → hold/intake column" with triple-proof unchanged; rebound targets via KTD-10; `autoMerge:false` gating keeps additive shape (KTD-6) across all 19 sweep sites; per-row predicates verify slim-projection columns exist in `getTaskSelectClause` before reading new fields. Sweeps route recovery through workflow nodes (wake/route), never direct lifecycle policy (06-09 plan R6). Every AGENTS.md `task:reconcile-*` event keeps its name; docs re-keyed in U10. +- **Execution note:** Characterization-first — pin the current sweep outcomes on builtin:coding fixtures before re-keying, then assert byte-identical decisions post-cutover. +- **Test scenarios:** + - Matrix: each documented reconcile rule (missing-worktree, phantom-binding, dependency-blocking-lease, stranded-completed, in-review-unmet-deps, workspace variants) fires identically on builtin:coding pre/post cutover. + - `autoMerge:false` in-review/merging task: sweeps provably do not move it (assert `-no-action` events) while per-task `autoMerge: true` override still processes. + - Custom workflow without a hold column: rebound lands per KTD-10 fallback ordering. + - Task in a deleted column: orphan-column reconcile parks + surfaces (not invisible to trait-keyed sweeps). + - Bounded retries: requeue caps and exhaustion events preserved (FN-7863 shape). +- **Verification:** file-scoped vitest; characterization suite green. + +### U7. Merger and finalization trait re-key + +- **Goal:** Merge failure rebounds, finalization moves, and human-review parking derive from the IR — and a confirmed merge always finalizes. +- **Requirements:** R2, R7, R7b +- **Dependencies:** U1, U5, U6 +- **Files:** `packages/engine/src/merger.ts` (rebound sites 6281/7375–7682), `packages/engine/src/auto-merge-finalization.ts` (finalize-to-done 246–254, done-without-merge guard), `packages/engine/src/workflow-merge-nodes.ts`, tests `packages/engine/src/__tests__/merger-trait-rekey.test.ts` plus `merger-merge-lifecycle` updates +- **Approach:** Merge failure rebounds target KTD-10; finalization success arrival at the success terminal fires the complete trait and moves to the complete column via the graph (KTD-1, R3 — dependents unblock on terminal arrival, not column entry, covering post-merge-verification living in the done column); `human-review` parks terminal-until-human in the column carrying the merge-gate node; `merge-blocker` guards the complete-bound boundary. Recovery-rehome tolerates custom merge-column ids. +- **Test scenarios:** + - Benchmark: manual-hold and retry loop stay in Merging; confirmed merge → Done; merge-failure-exhausted parks failed in Merging. + - Done-only-on-confirmed-merge: no path enters a complete-trait column without merge confirmation (or `noCommitsExpected`). + - autoMerge:false benchmark variant: card parks in Merging terminal-until-human; operator release proceeds. + - Dependents of the benchmark task unblock only after the success terminal, not at Done-column entry mid post-merge-verification. + - R7b regression: merge confirmed while the task carries an incomplete/stale step checklist → finalization reconciles the steps (audit event, ids-only), the card reaches the complete column, and no `failed` park occurs. Reproduces the "Merge confirmed but finalization blocked: task has incomplete steps" incident and asserts it is impossible by construction (checklist gates are pre-merge only; assert no post-merge code path can return a blocking verdict from step state). +- **Verification:** `merger-*` gate suites green; file-scoped vitest. + +### Phase D — Data, presets, deletion + +### U8. reviewLevel as a creation-time preset + +- **Goal:** `reviewLevel` maps to `enabledWorkflowSteps` at creation; zero runtime reads. +- **Requirements:** R6 +- **Dependencies:** U5 (runtime reads deleted there; mapper lands here) +- **Files:** `packages/core/src/task-store/task-creation.ts` (three creation paths), new `packages/core/src/review-level-preset.ts`, `packages/engine/src/executor.ts` (remove reads at 920/14471–14598), `packages/engine/src/triage.ts` (remove `Review level:` parse at 3011), dashboard forms `packages/dashboard/app/components/TaskForm.tsx`, `NewTaskModal.tsx`, `packages/dashboard/app/api/tasks.ts`, tests `packages/core/src/__tests__/review-level-preset.test.ts` +- **Approach:** Level mapping (0 → no optional groups; 1 → code-review; 2 → plan-review + code-review; 3 → plan-review + browser-verification + code-review) applied only when `enabledWorkflowSteps` is not explicitly provided; writes flow through the optional-group id pass-through (KTD-11). Post-creation `reviewLevel` mutation becomes a no-op field (create-only); dashboard forms present it as the preset it now is. Scope-leak enforcement mode (the level-1 read) re-keys on an explicit task field set by the preset. +- **Test scenarios:** + - Each level produces the documented step set through all three creation paths. + - Explicit `enabledWorkflowSteps` wins over `reviewLevel`. + - Colliding-id regression: a preset id colliding with a legacy `WORKFLOW_STEP_TEMPLATES` entry survives store create + update untouched (KTD-11). + - No engine file reads `task.reviewLevel` at runtime (tombstone grep test). +- **Verification:** file-scoped vitest. + +### U9. Legacy data adoption and old-binary guard + +- **Goal:** Every pre-cutover row wakes owned; stale binaries cannot write. +- **Requirements:** R10 +- **Dependencies:** U3, U5, U6 (adoption targets exist) +- **Files:** new PG forward migration under `packages/core/src/postgres/` (+ `SCHEMA_BASELINE_VERSION` bump), store-open reconcile in `packages/core/src/task-store/persistence.ts`-adjacent open path, `packages/engine/src/self-healing.ts` (startup adoption sweep), tests `packages/core/src/__tests__/legacy-adoption.test.ts` +- **Approach:** Adoption table (KTD-8): `planning` → planning node; `needs-replan` → plan-replan node; `plan-review-unavailable` → plan-review retry; replan-cap park → awaiting-approval preserved; in-progress with live steps → execute seam; in-review merge-substates → corresponding merge nodes; orphaned `pending` step results without live sessions → cleared with audit; `reviewLevel`-only tasks → one-time `enabledWorkflowSteps` backfill (never both set). Because `task.status` is an open string (not a closed enum), the table is derived from a **write-site census**: grep every `status` literal written anywhere in core/engine/dashboard, and a build-failing assertion test verifies every written status literal has an adoption-table row — a status added during the cutover window fails the build instead of mass-parking rows paused at upgrade. Baseline version gate refuses old-binary writes (established PG forward-migration pattern; also mitigates the known stale-Homebrew-binary failure mode). The durable IR-pin field (KTD-3) is added here alongside the adoption columns. +- **Test scenarios:** + - Fixture DB with every legacy (column, status) combination: post-migration, each task resumes at the mapped node; zero frozen rows. + - Unmappable contrived row parks `paused` with reconcile audit. + - Orphaned pending plan-review result cleared; a live-leased one preserved. + - Backfill: reviewLevel-only task gains the preset step set; task with both fields untouched-and-warned. + - Old-binary simulation (stale baseline) is refused at open. +- **Verification:** migration test suite; file-scoped vitest. + +### U10. Legacy deletion sweep and tombstones + +- **Goal:** The old world is gone and provably stays gone. +- **Requirements:** R9 +- **Dependencies:** U3, U4, U5, U6, U7, U8, U9 +- **Files:** delete `packages/core/src/workflow-cutover.ts`, `packages/engine/src/workflow-authoritative-driver.ts`, `packages/engine/src/workflow-parity-observer.ts` (+ parity evidence machinery in `packages/core/src/workflow-parity.ts` if unreferenced), legacy workflow-steps runner remnants, dead statuses from `packages/core/src/types.ts` and row-mappers/serialization, stale tests; update `AGENTS.md`, `docs/architecture.md`, `docs/testing.md`, `CONCEPTS.md` (re-key event docs to traits); new tombstone test `packages/engine/src/__tests__/legacy-tombstones.test.ts` +- **Approach:** Deletion list from R9 plus flow-analysis M3 additions. Tombstone test asserts the deleted symbols/files are absent (grep-level, cheap). Quarantine-on-sight discipline applies to destabilized lifecycle tests — but treat repeat flakes in graph/scheduler seams as race evidence first (learnings #6/#7); gate allow-list evictions recorded in `packages/engine/vitest.config.ts` as needed. +- **Test scenarios:** tombstone assertions for each deleted symbol/file; typecheck/build clean across packages; no `vi.mock` of deleted modules remains. +- **Verification:** `pnpm verify:fast`; `pnpm test:gate`. + +### Phase E — Acceptance and surfaces + +### U11. 6-column benchmark acceptance test + +- **Goal:** Automated proof that the workflow drives the lifecycle end-to-end. +- **Requirements:** R11, R12, R3, R5, R7, R8 (a builtin:coding end-to-end variant runs alongside the 6-column benchmark and must be byte-compatible with the pre-cutover `task-pipeline-smoke` trace — R8's evidence for the pipeline surface) +- **Dependencies:** U1–U7 +- **Files:** new `packages/engine/src/__tests__/benchmark-six-column-workflow.test.ts` (mock provider/testMode, modeled on `task-pipeline-smoke`), fixture IR `packages/engine/src/__tests__/fixtures/six-column-workflow-ir.ts` +- **Approach:** Scripted-mock end-to-end run asserting the observability contract: (1) ordered `task:column-transition` trail exactly Ideas→Todo→In-progress→In-review→Merging→Done with zero literal-fallback moves; (2) transitionPending null at end, kill/restart variant settles exactly once; (3) exactly one plan-review step result, graph-authored, zero triage-authored, restart variant proves the lease; (4) trait evidence — hold respected capacity, wip counted pending, abort-on-exit fired on the hard-cancel variant, timing excluded hold time; (5) autoMerge:false variant parks in Merging with `-no-action` sweep events; (6) failure variants park in place (never Done). +- **Test scenarios:** as enumerated in Approach (the six assertions are the scenarios), plus the operator contract variants: + - Plan Review REVISE loop: exactly one rewrite+re-review cycle inside Todo; a second REVISE parks awaiting-approval (benchmark replan cap = 1, expressed as workflow config, not engine constant). + - Code Review REVISE round-trip: card visibly moves In-review → In-progress → In-review; up to 3 cycles; the fourth failure parks (bounded, counted — no unbounded loop). + - Completion summary is generated only after Code Review passes and only while the card is in In-review, before the move to Merging. + - Column-role purity (R12): session/run records show zero reviewer sessions while in In-progress, zero executor sessions while in In-review, zero AI sessions while in Ideas or Done. + - Merge retry: 3 bounded retries inside Merging on transient failure; exhaustion parks failed in Merging (never Done). + - `autoMerge:false`: card waits in Merging; operator approval releases the merge; self-healing emits only no-action events while it waits. +- **Verification:** the test itself; candidate for gate admission only after demonstrating value (gate policy). + +### U12. Dashboard and API trait re-key + +- **Goal:** Operator surfaces render and act on any IR's columns; no closed column enum remains. +- **Requirements:** R2, R11 +- **Dependencies:** U1, U4 +- **Files:** `packages/dashboard/src/routes/register-task-workflow-routes.ts` (retry/re-engage `moveTask` targets 731/2469–2647, drift check 2654), `packages/dashboard/src/triage-trait.ts`, `packages/dashboard/src/routes/board-workflows.ts` (client counterpart `packages/dashboard/app/api/board-workflows.ts`), GitHub state mapping (`github-tracking-*`), board rendering + status badges in `packages/dashboard/app`, `packages/core/src/types.ts` (ColumnId open-string audit), tests `packages/dashboard/src/__tests__/routes-trait-rekey.test.ts` +- **Approach:** Audit ColumnId end-to-end for open-string typing (flow-analysis S8 — any closed enum is a cutover blocker, fix here); retry endpoints derive targets from the task's IR (`workflowHasColumn` pattern already exists at 2378–2390); status badges replace `planning`/`needs-replan` vocabulary with workflow-step-state derived labels; board renders columns from the resolved IR (already largely true) including novel ids like Merging; GitHub state mapping keys on `complete`/`archived` traits. +- **Test scenarios:** retry-specification on a plan-in-place workflow targets its intake/hold column; a novel "merging" column id flows through REST list/filter/board APIs untyped-error-free; badge for a card in Plan Review shows the step-derived label; done-mapping to GitHub closed keys on the complete trait; **editor buildability (R11's first half):** the 6-column benchmark workflow is constructed through the editor API (six columns with their documented traits, review nodes in the hold column, remediation edge, per-node caps), passes save validation, and the saved IR is byte-usable by U11's runner — plus a negative case where an invalid configuration (node → missing column) is rejected at save. +- **Verification:** file-scoped vitest (`tsconfig.app.json` for app-side typecheck). + +--- + +## Risks & Dependencies + +- **Blast radius.** ~450 literal column references across four engine files plus dashboard routes. Mitigation: KTD-5 single validator + U6 characterization-first + the per-subsystem inventory above; the surface-enumeration discipline (AGENTS.md) applies to each unit's review. +- **Test-fake breakage.** The prior cutover's documented main breakage surface. Mitigation: named in U5; fakes upgraded to workflow-aware store shape. +- **Concurrent operator use of this checkout.** Fusion runs against this repo; the tree is never assumed clean — stage by explicit path, never `git add -A`. +- **Lifecycle test flakes.** The cutover will destabilize timing-sensitive tests. Policy: quarantine-on-sight, but repeat flakes in graph/scheduler seams are treated as race evidence before quarantine (learnings #6/#7). +- **Mid-flight PG cutover.** Storage is mid-migration to embedded PG; U9's migration must follow the forward-migration + baseline-bump pattern, and new task fields must appear in `getTaskSelectClause` slim projections (learning #3d). +- **Dependency:** the active 06-09 plan's landed units (primitives, run-state, builtin IR full lifecycle, hold/release sweep, trait guards in `moves.ts`) are the foundation this plan builds on; if any is less complete than the research indicates, the affected unit inherits the gap (implementation-time discovery). + +--- + +## Deferred Implementation Notes + +Execution-time unknowns, recorded rather than faked: + +- Exact shape of the "ready-for-release" run-state marker (field on workflow run state vs. task facet) — decide in U4 against the real hold-release sweep code. +- Whether `workflow-parity.ts` evidence tables have remaining consumers (U10 checks before deleting). +- The precise adoption-node mapping for rare in-review merge substates (`merging-pr`, `merging-fix`) — enumerate against live enum values during U9. +- Whether scope-leak enforcement (executor 14471–14598) keeps a preset-derived flag or is absorbed into a workflow setting — decide in U8. + +--- + +## Sources & Research + +- User report verification (this session): `packages/engine/src/executor.ts:6956` hardcoded move; `packages/core/src/workflow-ir.ts:146` seam mapping; `packages/engine/src/workflow-graph-executor.ts:663` passed-only dedup; `packages/engine/src/triage.ts:2542–2561` pending write + reviewer dispatch; `packages/core/src/task-store/task-creation.ts` reviewLevel/enabledWorkflowSteps independence. +- Prior art: `docs/plans/2026-06-09-001-refactor-big-bang-workflow-native-execution-plan.md` (active; this plan completes its U6/U7/U9), `docs/plans/2026-06-07-001-refactor-workflow-runtime-cutover-plan.md` (completed, superseded direction), `docs/plans/workflow-owned-merge-stack/` (draft handoffs; merge nodes landed in code). +- Institutional learnings: `docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md`, `docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md`, `docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`, `docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md`, `docs/solutions/logic-errors/repo-root-task-worktree-requeue-loop.md`, `docs/solutions/architecture-patterns/thin-trusted-merge-gate.md`. +- Domain vocabulary: `CONCEPTS.md` (Column, Trait, Hold node, Transition Pending, Coding Workflow, Runtime Primitive). +- Trait/IR system: `packages/core/src/trait-types.ts`, `builtin-traits.ts`, `trait-registry.ts`, `builtin-coding-workflow-ir.ts`, `workflow-capacity.ts`, `workflow-transitions.ts`, `workflow-ir-resolver.ts`, `transition-pending.ts`. diff --git a/docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md b/docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md new file mode 100644 index 0000000000..66f79fa5bc --- /dev/null +++ b/docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md @@ -0,0 +1,122 @@ +--- +title: "U5e handoff — lift executeCore's implementation body into a standalone runner" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +--- + +# U5e handoff — delete graph re-entry by lifting the implementation phase + +Surgical map produced by U5d. Line numbers are valid as of commit `b22aab024`. + +## What U5d already landed + +- **`140f1cead`** — `fix(cutover)`: the `engine-core` vitest project builds `@fusion/core` + from the gate-safe barrel `packages/core/src/index.gate.ts`, and U3's + workflow-step-results lease exports were only added to `index.ts`. So + `classifyReviewLease` was `undefined` **only** under `engine-core`, throwing + `"classifyReviewLease is not a function"` and failing every `defaultOn` Plan Review + run — which is why the byte-compat oracle `task-pipeline-smoke` was red. Production + (`dist`, via `index.ts`) was never affected. Fixed by syncing the exports, plus a + loud named guard at the lease site and an R5 lock in the smoke. +- **`b22aab024`** — `feat(cutover)`: deleted the `graphCompletionInterceptors` **Map**, + replaced by an explicit `GraphCompletionCallback` threaded + `execute(task, graphCompletion?)` → `executeCore(task, graphCompletion?)`. + +**Validation net is now GREEN and is U5e's oracle:** oracle + trait suites **59/59** +(`task-pipeline-smoke`, `executor-graph-requeue-gate`, `workflow-graph-executor-parity`, +`executor-graph-boundary`, `merger-trait-rekey`, `self-healing-trait-rekey`, +`workflow-graph-column-moves`); engine typecheck clean. + +## What remains: the lift (the operator's "zero legacy re-entry machinery") + +U5d removed the shared-state *signalling*, **not the re-entry**: the graph still calls +`execute()`. Deleting re-entry outright means lifting the implementation body out of the +dual-purpose `executeCore` into a standalone runner the graph calls directly. + +### The core coupling (verified) + +`executor.ts` documents (near the step-inversion driver, ~6549) that worktree / taskEnv / +agent / semaphore state is assembled **inside** `execute()` and is not available +standalone at `createGraphSeams` time — which is exactly why re-entry was chosen. So the +lift is: move that state assembly into `runImplementation(...)` and have the graph call +it, leaving `executeCore` as routing only. + +### Target shape + +- `execute(task)` → **routing only**: dependency/ephemeral gates, `graphRouting`, + `maybeExecuteWorkflowGraph`, `workflowAuthoritativeDispatch`, the process-wide + `executingTaskLock` claim, `maybeDispatchWorkflowWorkEngine`, heartbeat deferral. +- `runImplementation(task, prepared, ctx)` → the lifted body: settings merge, worktree, + agent session, up to the completion boundary. **Returns `{ taskDone, modifiedFiles }` + directly** — no callback, no re-entry. +- The legacy in-review handoff tail is **deleted** (R9). + +### Line map (as of `b22aab024`) + +| Landmark | Location | +| --- | --- | +| `executeCore` declaration | `executor.ts:10645` | +| `executeCore` end (next method) | `executor.ts:13845` (`createTaskUpdateTool`) — body ≈ **3200 lines** | +| Routing-skip gate (`if (!graphCompletion)`) | `10655` | +| Implementation body starts (settings merge) | ≈ `10756` | +| **Completion boundary 1** (step-session) | `11594` | +| **Completion boundary 2** (task completion) | `12494` | +| **Completion boundary 3** (completion retry) | `12810` | +| `fn_review_step` injection gate | `11928` | +| `workflowReviewGatesOwnedByGraph` flags | `12305`, `12721`, `12743` | +| `runImplementationPhase` (delete after lift) | `6515` | +| Its callers | `6596` (step driver memo), `6749` (`runCodingSession`), `7262` (seam) | + +Each completion boundary currently reads: + +```ts +if (graphCompletion) { …; graphCompletion({ modifiedFiles }); return; } +// …then the LEGACY in-review handoff (moveTask → in-review) — delete this +``` + +After the lift each becomes a plain `return { taskDone: true, modifiedFiles }`, and the +legacy handoff below it is removed. + +### Seam maps still to retire (R9) + +These exist only to thread state across the re-entry; convert to **parameters** of +`runImplementation` and delete: + +| Map | Line | Notes | +| --- | --- | --- | +| `graphStepRunOnce` | `5320` | per-run memo of the impl phase; keep the memo, memoize `runImplementation` | +| `graphSeamGoverningNodeId` | `5365` | read inside body at ~`3254`, `7834`, `7905` → pass as param | +| `graphSeamThinkingLevel` | `5371` | → param | +| `graphStepSessionPinned` | `5313` | → param/derived | +| `graphStepActiveContext` | `5330` | foreach instance context | + +### Part 2 (falls out of the lift) + +`fn_review_step` — **30 occurrences** in `executor.ts`. Once every run is graph-owned, +the injection gate at `11928` is always false, so the tool factory (~`15313`+), the +deferred re-raise channel, and the review-level prompt scaffolding (~`20064`–`20220`, +the `workflowReviewGatesOwnedByGraph` branch) are all dead → delete. + +### Part 4 (test fakes) + +Upgrade minimal executor-core fakes to the workflow-aware store shape rather than keeping +a legacy characterization path. U5d already upgraded +`executor-outer-dispatch-dependency-gate.test.ts` to the explicit-callback contract. + +## Known pre-existing reds (NOT caused by the cutover work — do not "fix" by appeasing) + +Red at `HEAD` before U5d's changes; verify before attributing anything to U5e: + +- `executor-column-agent-seams.test.ts` — 8 failures +- `executor-fast-mode-workflows.test.ts` — 3 failures (these assert current + `fn_review_step` behavior and will need rewriting as part of Part 2) +- `executor-task-done-invariant.test.ts` (25–26/27), `workflow-graph-optional-step-fix` + (2/24), FN-8309 dashboard `html2canvas` in `verify:fast` + +## Guardrails + +Keep the branch landable at every commit; scope verification to changed files (no +`allowFullSuite`); port 4040 reserved; never kill the live `fn`; stage by explicit path +(Fusion writes to this checkout concurrently). diff --git a/docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md b/docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md new file mode 100644 index 0000000000..61463bc2b6 --- /dev/null +++ b/docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md @@ -0,0 +1,129 @@ +--- +title: "U5e remainder — unblock the legacy-tail deletions by modernizing the executor test fakes" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +supersedes_map_in: docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md +--- + +# U5e remainder — one unlock gates every remaining deletion + +## What U5e landed + +**`8ed4d8995` — the lift. The unit's headline goal is met: there is ZERO graph re-entry +into `execute()`.** + +- `executeCore(task)` is **routing only**: duplicate-dispatch drop, dependency gate, + ephemeral gate, `maybeExecuteWorkflowGraph`, `workflowAuthoritativeDispatch`. +- `runImplementation(task, { graphCompletion? })` is the lifted ~2400-line body: the + process-wide task lock, soft-delete refusal, work-engine dispatch, heartbeat deferral, + settings merge, worktree acquisition, agent session, up to the completion boundary. +- `runImplementationPhase` (the graph seam) calls `runImplementation` **directly**. +- `execute()` no longer has a `graphCompletion` parameter. +- The routing block lost its `if (!graphCompletion)` wrapper — there is no inner + invocation left to exclude. + +Why the re-entry existed at all: worktree / taskEnv / agent / semaphore state is assembled +inside `execute()` and was not available standalone at `createGraphSeams` time. Lifting the +body puts that assembly behind an ordinary method call, which is what removes the need. + +## What did NOT land, and the single reason why + +Still present: the 3 callback completion boundaries, the legacy in-review handoff tails, +`fn_review_step` and its review-level prompt scaffolding, and the seam maps +(`graphSeamGoverningNodeId`, `graphSeamThinkingLevel`, `graphStepSessionPinned`, +`graphStepRunOnce`, `graphStepActiveContext`). + +**All of them are gated behind exactly one thing** — `maybeExecuteWorkflowGraph`'s +workflow-selection-api-unavailable fallback (`transferPreHeldToLegacy = true; return false;`, +the branch guarded by `hasEnabledSteps`). It fires **only** when a TaskStore exposes neither +`getTaskWorkflowSelection` nor `getTaskWorkflowSelectionAsync` **and** the task has no +`enabledWorkflowSteps`. Production stores always expose a workflow-selection reader, so +**only minimal test fakes ever reach it**. + +The dependency chain is strict and worth stating plainly: + +``` +delete the fallback + -> maybeExecuteWorkflowGraph always owns the task + -> executeCore never calls runImplementation without a callback + -> graphCompletion becomes MANDATORY + -> the 3 boundaries collapse to `return { taskDone: true, modifiedFiles }` + -> every legacy in-review tail below them is dead -> delete + -> `!graphCompletion` fn_review_step injection gate is statically false -> delete the + tool factory, the deferred re-raise channel, and the review-level scaffolding + -> `graphCompletion !== undefined` review-gate flags become the constant `true` +``` + +So this is not five deletions. It is **one unlock plus mechanical fallout.** + +## The cost of the unlock — measured, not estimated + +Do not re-derive this; it was measured directly. + +- **33** engine test files drive `execute()` through legacy-minimal fakes. **28** of them + share `createMockStore()` in `packages/engine/src/__tests__/executor-test-helpers.ts`, + so one helper edit reaches most of the surface. +- Adding `getTaskWorkflowSelection` / `getTaskWorkflowSelectionAsync` (returning + `{ workflowId: "builtin:coding", stepIds: [] }`) to that helper and running a 12-file + sample moved it from **155 failed / 190 passed** to **214 failed / 131 passed** — + **+59 new failures**. +- Root cause split of those 59: + - **38 are `session.subscribe is not a function`.** Making the store workflow-aware + routes these tests through the graph, which pulls them into real **workflow-step** + sessions. Each test file supplies its own `createFnAgent` session mock, and those mocks + lack `subscribe` (read at the workflow-step streaming site in `executor.ts`). This is a + **per-file session-mock** gap, not a store gap — the shared helper cannot fix it. + - The remainder are legacy-behavior assertions (in-review handoff, retry-counter resets) + that the deletion **intentionally** invalidates and which must be rewritten against the + graph contract, not appeased. +- Context for the numbers: this surface **already carries 155 pre-existing reds at HEAD** + in that 12-file sample alone — far more than the ~13 the previous handoff listed. Measure + your own baseline per file before attributing anything. + +`executor-preheld-legacy-handoff.test.ts` is a special case: all 4 of its tests exist +*specifically* to assert the fallback (they `delete` both selection methods from the fake). +Deleting the fallback deletes that file's reason to exist — remove it rather than repair it. + +Also needing hand work after the helper edit: the 3 local `createStore()` fakes in +`executor-soft-delete-guard.test.ts`, `in-review-unmet-dependency-reconcile.test.ts`, and +`reliability-interactions/post-done-continuation-no-wedge.test.ts`. + +## Recommended order + +1. Add the two selection methods to `createMockStore` (one edit, 28 files). +2. Add a `subscribe` stub to the per-file session mocks — sweep the 38 failures; consider + hoisting a shared session-mock factory into `executor-test-helpers.ts` so this class of + drift stops recurring. +3. Rewrite the legacy-behavior assertions against the graph contract. +4. Delete `executor-preheld-legacy-handoff.test.ts`. +5. Only then delete the fallback, and take the mechanical fallout above in one pass. + +## A note on the seam maps + +Threading `governingNodeId` / `thinkingLevel` as explicit `runImplementation` params is +*partially* unblocked — but only 3 of the ~8 read sites live inside the lifted body +(`forceStepSession`, `workflowStepThinkingLevel`, `executorSessionThinkingSource`). The rest +are in helper methods reached deep from the session build. Threading only the 3 creates +**two sources of truth for the same value**, which is worse than the map. Do it as one pass +with the deletion, or not at all. + +## Validation net (the oracle — green at this handoff) + +`task-pipeline-smoke`, `executor-graph-requeue-gate`, `workflow-graph-executor-parity`, +`executor-graph-boundary`, `merger-trait-rekey`, `self-healing-trait-rekey`, +`workflow-graph-column-moves` — **59/59 green**; engine typecheck clean. + +Measured pre-existing reds, unchanged by U5e (verified before *and* after the lift): +- `executor-column-agent-seams` + `executor-fast-mode-workflows` + + `executor-outer-dispatch-dependency-gate` + `executor-task-done-invariant` + + `workflow-graph-optional-step-fix` = **39 failed / 69 passed** +- `executor-step-session` + `reliability-interactions/concurrent-execute-race` = + **14 failed / 22 passed** + +## Guardrails + +Keep the branch landable at every commit; scope verification to changed files (no +`allowFullSuite`); port 4040 reserved; never kill the live `fn`; stage by explicit path +(Fusion writes to this checkout concurrently). diff --git a/docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md b/docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md new file mode 100644 index 0000000000..2a55ca8483 --- /dev/null +++ b/docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md @@ -0,0 +1,148 @@ +--- +title: "U5f remainder — the workflow-aware flip, now measured on a clean surface" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +supersedes: docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md +--- + +# U5f remainder — one flip, 257 migrations, and why that number is now trustworthy + +## What U5f landed: `a7432a338` + +**The "~155 pre-existing reds" on this surface were almost entirely harness drift, not +cutover damage.** Two missing mock surfaces accounted for 219 of them. + +| | failed | passed | red files | +| --- | --- | --- | --- | +| before | 231 | 844 | 30 | +| after | **10** | **1065** | **4** | + +Diff of failing-test *names*: **219 fixed, 0 new.** Command was identical before and after — +the 40 engine test files that drive `execute()` (list: `files_affected.txt` method below). + +The two fixes: +1. **Session shape at the one seam.** ~419 per-file + `mockedCreateFnAgent.mockResolvedValue({session:{...}})` literals define only + `prompt`/`dispose`. Anything reaching the workflow-step session calls + `session.subscribe(...)`. Fixed in `createResolvedAgentSession` (the single seam every + executor session is built through) via `withSessionDefaults`, which fills only what a + stub omitted — a stub's own methods always win. +2. **`getTaskVerificationRequestAsync` / `getTaskVerificationRequest`** were absent from + `createMockStore` but are called unconditionally on the completion path. + +**Do not re-derive this.** The lesson generalizes: mass red on this surface has repeatedly +turned out to be a handful of missing mock surfaces, not hundreds of genuine behavior +disagreements. Look for the shared seam before editing files one at a time. + +The 10 that remain: 7 `restart.integration`, 1 each `executor-fast-mode-workflows` (asserts +`fn_review_step` omission — pt3 rewrites it), `executor-task-done-invariant`, `triage`. + +## The remaining blocker, measured on a CLEAN surface + +Adding the two workflow-selection readers to `createMockStore`: + +```ts +getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "builtin:coding", stepIds: [] }), +getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), +``` + +costs **257 new failures across 29 files** (10 → 269). That is the honest price of routing +this surface through the graph. The earlier "+59" estimate came from a 12-file sample taken +against the 231-red surface, where the noise hid most of the cost. + +Top files: `executor-worktree` 54, `executor-review-verdicts` 53, `executor-prompt` 25, +`executor-task-done-invariant` 22, `executor-step-session` 10. Full list saved during the +run; regenerate with the method below. + +### Failure classes of the 257 — these look systemic, not individual + +``` + 64 TypeError: Cannot read properties of undefined (reading 'execute') + 41 graph abort: no-worktree-for-write-node + 29 "Workflow step failed: Code Review" + 29 "Advisory workflow step failed: Plan Review" + 20 tools.fn_review_step is not a function + 12 this.store.getTaskVerificationRequestAsync is not a function <- per-file local fakes + 7 tools.fn_task_update / fn_task_add_dep is not a function +``` + +**Read this optimistically, and verify before budgeting for 257 hand migrations.** The 219 +collapse came from exactly this shape of list. The `reading 'execute'` cluster (64) and the +`no-worktree-for-write-node` cluster (41) are each almost certainly ONE missing harness +surface, not 105 independent test disagreements. Probe those two first; the residual after +fixing them is the real migration cost. + +Diagnosed root cause of the `no-worktree` cluster: once graph-owned, a run logs +`[pre-merge] Starting workflow step: Plan Review` → `Advisory workflow step failed` → +`[pre-merge] Starting workflow step: Code Review` → +`Code Review failed before producing a verdict: no-worktree-for-write-node`. The execute +seam never produces a worktree, so worktree-mechanics assertions never see +`"Worktree created at"`. + +Two levers already tried and **ruled out** — do not repeat them: +- Explicit `enabledWorkflowSteps: []` on the mock store's default task. No effect: the graph + reads `enabledWorkflowSteps` off the task object *passed to `execute()`*, and these tests + pass inline task literals. +- Adding `enabledWorkflowSteps: []` to those inline literals too. Made it **worse** (54 → 57), + so the degenerate behavior is not merely `defaultOn` Plan Review. + (`workflow-graph-executor.ts:658` does confirm `[]` bypasses `defaultOn` — the bypass works; + it just is not what is breaking these runs.) + +### On the "generalize task-pipeline-smoke's fixture" shortcut + +It does not apply as stated, and knowing why saves a session. **`task-pipeline-smoke` never +constructs a `TaskExecutor`.** It drives `WorkflowTaskRuntime` directly with *injected +primitives* (`stepExecute`, `runReview`, `runVerification`, `requestMerge`, …) that all +return `{outcome:"success"}`. Its "store" is a 3-method stub only because the primitives are +injected — there is no faithful store fixture there to lift. + +The transferable idea is the *primitive injection*, not the store: the executor's graph run +builds its seams internally (`createGraphSeams`), so the harness has no way to substitute +succeeding primitives. If the 64/41 clusters do not fall to a simpler fix, adding a +test-only primitive-injection seam to the executor is the principled next step — and it is +also what would let these tests assert graph behavior without standing up a real worktree. + +## Then the unlock (unchanged, still strictly gated behind the flip) + +``` +delete maybeExecuteWorkflowGraph's legacy fallback (executor.ts ~5493, + `transferPreHeldToLegacy = true; return false;`) + -> graph always owns -> graphCompletion becomes MANDATORY + -> 3 completion boundaries (executor.ts ~11627, ~12527, ~12843) -> plain returns + -> legacy in-review handoff tails -> delete + -> fn_review_step injection gate (~11961) statically false -> delete tool factory + (~15384), deferred re-raise channel, review-level scaffolding + -> workflowReviewGatesOwnedByGraph flags (~12338, ~12754, ~12776) -> constant true + -> seam maps -> explicit runImplementation params +``` + +Also delete `executor-preheld-legacy-handoff.test.ts` outright — all 4 of its tests exist +*only* to assert the fallback (they `delete` both selection methods from the fake). + +## Method — reproduce the measurement exactly + +```bash +cd packages/engine +# the 40-file surface +grep -rln "createMockStore" src/__tests__/ | sort > /tmp/m.txt +grep -rln "\.execute(\|resumeTaskForAgent" src/__tests__/ | sort > /tmp/e.txt +comm -12 /tmp/m.txt /tmp/e.txt > /tmp/files_affected.txt +pnpm exec vitest run $(cat /tmp/files_affected.txt | tr '\n' ' ') --silent=passed-only --reporter=dot +``` +Compare failing-test NAMES, not counts — counts hide simultaneous fix+break: +```bash +grep -aoE "^ *FAIL +\|?[a-z-]*\|? ?src/__tests__/[^ ]+\.test\.ts > .*" run.txt \ + | sed 's/^ *FAIL *|[a-z-]*| *//' | sort -u > names.txt +comm -13 before_names.txt after_names.txt # NEW failures +comm -23 before_names.txt after_names.txt # FIXED +``` +The run takes ~5 minutes; background it (a foreground 2-minute cap will kill it mid-run, and +a partial file silently reads as "still running"). + +## Guardrails + +Oracle net (59/59), `pnpm test:gate` engine-core (294/294) and pg-gate (126/126, fully green +at `a7432a338`) must stay green at every commit. Scope verification to changed files, no +`allowFullSuite`; port 4040 reserved; stage by explicit path (Fusion writes concurrently). diff --git a/docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md b/docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md new file mode 100644 index 0000000000..bbe96c813b --- /dev/null +++ b/docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md @@ -0,0 +1,111 @@ +--- +title: "U5g remainder — the flip, now costing 164 instead of 257" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +supersedes: docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md +--- + +# U5g remainder — five seams closed, one folded deletion landed, the flip still open + +## What U5g landed + +**`29fe4b91c` — the probe. The U5f prediction held: both dominant clusters were +ONE missing harness surface each.** + +| | failed | passed | +| --- | --- | --- | +| flip only (U5f's measurement, reproduced exactly) | 269 | 806 | +| flip + the seam fixes | **174** | 901 | +| the seam fixes, NO flip | **10** | 1065 | + +The last row is why the commit was safe to land alone: identical to `a7432a338`'s +baseline by failing-test **NAME**, not merely by count. **The flip's remaining cost is +164, not 257.** + +**`d0bf24ec9` — U10-pt1**, folded in per the coordinator: parity-observer chain, +`WorkflowAuthoritativeDriver` + `workflowAuthoritativeDispatch`, and +`workflow-cutover.ts`, ~1060 lines. `workflow-parity.ts` stays (live via `store.ts` and +`remaining-ops-7.ts`). + +## The three seams — do not re-derive these + +1. **`getTaskDocument` on `createMockStore` (59 failures).** The `Cannot read properties + of undefined (reading 'execute')` cluster was **not** a session problem, and not the + tool-capture problem it looks like. The builtin coding graph parses PROMPT.md into + task steps at its `parse` node *before* the implementation node, and + `readTaskArtifact` (executor.ts) resolves that artifact as + `getTaskDocument(id,"PROMPT.md")` first, falling back to `getTask().prompt`. ~10 files + install their own `store.getTask` returning a literal with **no `prompt`**, so the read + returned undefined → `parse` failed `parse-error` → **the graph terminated before any + agent session existed**, which is why the captured `fn_task_done` tool was null. + `getTaskDocument` is overridden nowhere on this surface, so one stub fixed every file. +2. **Write-through task state (41 failures, `no-worktree-for-write-node`).** `updateTask` + was a black hole and `getTask` a frozen literal. The graph's write-capable-node guard + re-reads the row (`executionTarget = await this.store.getTask(live.id)`) precisely so + it cannot trust a stale in-memory copy, then rejected because the literal had no + worktree. `updateTask` now records patches and `getTask` replays them. +3. **Tool-capture clobber (11 sites, 9 files).** `doneTool = customTools.find(...)` ran on + *every* session creation, so the workflow-step session (no `fn_task_done`) overwrote it + with undefined. Now `?? `. + +### Two levers the earlier handoffs got WRONG — corrected here (`c1c9629cb`) + +Both were previously recorded as ruled out. Both are real; the earlier verdicts were +measurement artifacts. **Do not re-revert them.** + +4. **Default APPROVE verdict for review sessions.** 29fe4b91c recorded this as "net zero, + reverted". Wrong: the run-level count did not move because a downstream blocker + dominated, but the seam is real and directly observable — the probe goes from + `[pre-merge] Advisory workflow step failed: Plan Review` to + `[pre-merge] Workflow step completed: Plan Review`. Aggregate counts mask per-layer + progress; check the probe log, not just the total. +5. **`enabledWorkflowSteps: []` — WHERE you set it decides everything.** U5f ruled this out + after setting it on the inline task literals passed to `execute()`, where it provably + does nothing (re-confirmed: `executor-prompt` stayed at 25). On the **store's default + task** it works — the graph re-reads the row rather than trusting the passed object, + the same re-read that made seam 2 necessary. `executor-prompt` 25 → 16, surface + 174 → **155**. + + This one also explains the whole failure *shape*: these legacy-shaped stubs assume the + FIRST session is the implementation session. Under graph ownership the first session is + Plan Review, so every stub side effect (pausing, disposing, triggering store events) + fired against the wrong session. + +Still genuinely ruled out: nothing else. The "shared session-completes default" hypothesis +from the previous revision is **answered NO** — stubs define their own `prompt`, so the +harness cannot make them call `fn_task_done` without overriding behavior tests assert. +The remaining `Agent finished without calling fn_task_done` runs are real per-test work. + +## Then the unlock (unchanged) + +`maybeExecuteWorkflowGraph`'s fallback is `executor.ts:5459-5495` (the +`typeof this.store.getTaskWorkflowSelection* !== "function"` block ending +`transferPreHeldToLegacy = true; return false;`). Deleting it makes `graphCompletion` +mandatory → 3 completion boundaries collapse to plain returns → legacy in-review tails, +all remaining `fn_review_step` sites, and the seam maps follow. + +`executor-preheld-legacy-handoff.test.ts` still has 2 tests that `delete` both selection +methods to reach the fallback; they die with it. (Its other 2 authoritative-dispatch tests +were already removed in `d0bf24ec9`.) + +## Method + +```bash +cd packages/engine +grep -rln "createMockStore" src/__tests__/ | sort > /tmp/m.txt +grep -rln "\.execute(\|resumeTaskForAgent" src/__tests__/ | sort > /tmp/e.txt +comm -12 /tmp/m.txt /tmp/e.txt > /tmp/files_affected.txt # 40 files +pnpm exec vitest run $(cat /tmp/files_affected.txt | tr '\n' ' ') --silent=passed-only --reporter=dot +``` +Background it (~5 min; a foreground 2-minute cap kills it mid-run and the partial file +reads as "still running"). Compare failing test **names**, not counts. + +## Guardrails + +Oracle net 59/59 and engine-core gate 294/294 at every commit; final execute()-surface +reds ≤ the 10-red baseline. pg-gate reds are pre-existing rotating suite-level contention — +a different file set every run (3, then 5, then 6 across this session) with **zero** +assertion failures. Excluded; do not chase or appease. Every new `index.ts` export mirrors +into `index.gate.ts`. diff --git a/docs/plans/2026-07-19-005-u10-flip-residual-handoff.md b/docs/plans/2026-07-19-005-u10-flip-residual-handoff.md new file mode 100644 index 0000000000..1ad3fbe50a --- /dev/null +++ b/docs/plans/2026-07-19-005-u10-flip-residual-handoff.md @@ -0,0 +1,125 @@ +--- +title: "U10 remainder — fn_review_step is gone; the flip now costs 102, and two of its seams are identified" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +supersedes: docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md +--- + +# U10 remainder — the fn_review_step slice is retired, the flip is measured, the merge boundary is the next unlock + +## What landed (both green, both on the branch) + +**`e460752f1` — U10 pt2: `fn_review_step` and its exclusive machinery are deleted.** +Ordered first per the coordinator, and the ruling held: the whole +`tools.fn_review_step is not a function` slice is gone before the flip ever pays for it. +Full deletion list and the per-file test triage are in that commit's body. + +**`` — U10's tombstone ratchet + AGENTS.md re-key.** +`packages/engine/src/__tests__/legacy-tombstones.test.ts` (5 tests, 258 ms) asserts 3 deleted +files stay deleted and 14 deleted symbols never reappear in executable production source. It +strips comments first — every deletion left an explanatory FNXC note naming what it removed, and +those notes are the point; they must survive while the code must not. + +## The measured state of the flip + +Surface: the 40 engine test files that drive `execute()` (method in the predecessor handoff). + +| | failed | passed | +| --- | --- | --- | +| baseline before this session | 10 | 1063 | +| after deleting `fn_review_step` (`e460752f1`) | **9** | 1018 | +| + the flip (both selection readers on `createMockStore`) | **111** | 916 | +| + flip + write-through `updateStep` | 114 | 913 | + +**The flip's cost is 102, not 155.** The predecessor's 155 included the ~25 `fn_review_step` +failures plus ~28 tests that no longer exist. The flip itself was NOT landed — 111 reds violates +the green-at-every-commit rule — and the two selection readers were reverted out of +`executor-test-helpers.ts`. Re-apply them next to `getTaskDocument` in `createMockStore`: + +```ts +getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "builtin:coding", stepIds: [] }), +getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), +``` + +### Post-flip failure classes (measured, 111 reds) + +``` + 13 moveTask never called with (id, "in-review") <- ONE seam, diagnosed below + 11 Cannot read properties of undefined ('execute') <- tool-capture, residual after seam 3 + 9 "expected not to be called, but was called" + 8 tools.fn_task_add_dep is not a function <- tool-capture clobber, more sites + 5 step list [pending] vs [pending,pending,pending] +``` + +Files: `restart.integration` (many), `executor-task-done-invariant`, `executor-prompt`, +`executor-review-verdicts`, `executor-worktree`, `executor-stuck-requeue-preserve-progress`, +`executor-step-session`. + +## THE NEXT UNLOCK — do not re-derive this + +**The 13-red `in-review` cluster is one missing harness surface: no merge requester.** + +Traced end to end with a probe (temporarily make the harness's `logger.js` mock write to +`console.error` behind an env flag — worth doing again, it is how every finding below was reached): + +1. Under graph ownership the in-review handoff **is** the merge boundary: + `requestMerge` seam → `ensureWorkflowMergeBoundaryTask` → `moveTask(id, mergeNodeColumn)`. + There is no completion-path `moveTask(id, "in-review")` any more. +2. `requestMerge` returns `merge-unavailable` **before any row mutation** when + `this.mergeRequester` is unset (`executor.ts` ~6717). These tests build a bare + `new TaskExecutor(store, root)`, so the graph terminated failed with **zero** `moveTask` calls. + Production always injects a requester (the work engine wires it), so this is harness absence, + not the contract under test. +3. Injecting a default `{merged:false, noOp:false, reason:"queued"}` requester moves the failure + forward to the **implementation-proof gate** + (`getWorkflowMergeImplementationProofFailure`): `builtin:coding` resolves to + `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR`, which `usesParsedSteps`, so it demands + either all-terminal `task.steps` or a `source:"node"` pre-merge `workflowStepResult`. + +**A prototype accessor does NOT work for the requester** — TS class fields define an own +`mergeRequester` (undefined) per instance, shadowing it. Patch `TaskExecutor.prototype.execute` / +`.resumeTaskForAgent` in the harness to call `setMergeRequester(default)` at entry when unset; a +test that sets its own still wins. (Verified: this is what moved the failure to the proof gate.) + +**Write-through `updateStep` is necessary but not sufficient.** The step-execute node consults the +projection (`getTask().steps[i].status`), and the mock's `updateStep` was a black hole while +`getTask` replayed only `updateTask` patches — so `steps#N:step-execute` reported +`step N not completed by implementation pass` and terminated the graph. A write-through +`updateStep` fixes that, but on its own (no flip) it costs 1 red in +`executor-task-done-invariant`, so land it WITH the flip, not before. Draft: + +```ts +updateStep: vi.fn(async (id, stepIndex, status) => { + const current = await store.getTask(id); + const steps = (current?.steps ?? []).map((s, i) => (i === stepIndex ? { ...s, status } : s)); + applyPatch(id, { steps }); return { ...current, steps }; +}), +``` + +**Ruled out, do not repeat:** removing the `### Step 0` heading from the `getTaskDocument` +PROMPT.md stub. It does let the graph reach `merge`, but it also skips the implementation session +entirely (empty foreach), which is the thing most of these tests assert. + +## Still open after the flip lands + +4. Delete the legacy fallback (`executor.ts` ~5333/5405/5660, `transferPreHeldToLegacy`), making + `graphCompletion` mandatory → 3 completion boundaries collapse to returns → legacy in-review + handoff tails → seam maps become explicit params. `executor-preheld-legacy-handoff.test.ts` + has 2 tests that `delete` both selection methods to reach the fallback; they die with it. +5. Dead statuses. `runPlanReviewBeforeExecution` is already gone (comment references only), but + `needs-replan` is still WRITTEN in `scheduler.ts` (5 sites), `comments-ops.ts`, and + `register-task-workflow-routes.ts` (3 sites). This is a real migration, not a sweep — it was + deliberately NOT attempted at the tail of a session. `legacy-adoption.ts` already maps these + statuses, so the writers are what must go. +6. Add the newly-tombstoned symbols from step 4/5 to `DELETED_SYMBOLS` in + `legacy-tombstones.test.ts` as each lands. + +## Guardrails (held at every commit this session) + +Oracle net 59/59 (`task-pipeline-smoke`, `executor-graph-requeue-gate`, +`workflow-graph-executor-parity`, `executor-graph-boundary`, `merger-trait-rekey`, +`self-healing-trait-rekey`, `workflow-graph-column-moves`); engine-core gate 294/294; engine +typecheck + eslint clean. pg-gate's 3 reds are the known rotating suite-level contention (a +different file set every run, zero assertion failures) and are excluded. diff --git a/docs/plans/2026-07-19-006-u10b-post-flip-handoff.md b/docs/plans/2026-07-19-006-u10b-post-flip-handoff.md new file mode 100644 index 0000000000..a6e5a8da89 --- /dev/null +++ b/docs/plans/2026-07-19-006-u10b-post-flip-handoff.md @@ -0,0 +1,129 @@ +--- +title: "U10b done — the flip landed, the legacy execute fallback is deleted, graph ownership is unconditional" +type: handoff +status: ready +date: 2026-07-19 +parent_plan: docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md +supersedes: docs/plans/2026-07-19-005-u10-flip-residual-handoff.md +--- + +# U10b — the flip is landed and the second executor is gone + +## What landed + +| commit | what | +| --- | --- | +| `3030e1db2` | three more shared harness seams, measured baseline-neutral | +| `7ea9cd039` | **the flip** — the 40-file execute surface runs the workflow graph | +| `` | the legacy execute fallback deleted; `graphCompletion` mandatory; tombstones + docs | + +## The measured story + +| | failed | passed | +| --- | --- | --- | +| baseline (after `e460752f1`) | 9 | 1063 | +| + the flip, before triage | 99 | — | +| + the flip, after triage | **9** (identical BY NAME) | — | +| + the fallback deletion, before triage | 41 | — | +| + the fallback deletion, after triage | **5** | 1021 | + +**The flip cost exactly 90 new reds; the fallback deletion cost 32 more.** Both waves were driven +to zero, and the surface ended BELOW its own baseline: 5 reds, a strict subset of the original 9 +by name. The 4 that disappeared were `restart.integration` tests that had been failing for the +same reason the wave-2 work fixed — its LOCAL `createMockStore` (line ~309, shadowing the shared +one) never implemented the real store contract. Nobody had connected them before because they sat +in the inherited "pre-existing baseline" bucket. + +Remaining 5, all pre-existing: 3 in `restart.integration`, 1 in `triage`, 1 in +`executor-task-done-invariant`. + +Triage tally across both waves: **~130 tests migrated, 1 test deleted, 1 file deleted, 4 +assertions deleted.** Nothing was quarantined, skipped, or weakened. + +## Three shared seams did most of the work — do not re-derive these + +The predecessor found `getTaskDocument`, write-through `updateTask`, and the tool-capture clobber. +Three more were needed, all the same shape (a mock returning a frozen literal where the graph +re-reads the live row): + +1. **`moveTask` must return the LIVE row.** The graph's merge boundary + (`ensureWorkflowMergeBoundaryTask`) feeds `store.moveTask(...)`'s return value straight into the + implementation-proof gate. A mock returning `{}` reported zero steps, so the merge failed + `implementation-incomplete` AFTER the implementation had completed and every step was written + `done`. Measured directly: the proof gate went `steps=["pending"]` → `steps=["done"]`. +2. **`updateStep` write-through**, or `steps#N:step-execute` re-reads the projection and terminates + the graph with `step N not completed by implementation pass`. +3. **A per-file `getTask` override must not defeat write-through.** ~10 files install their own; + wrapping `mockImplementation`/`mockResolvedValue` layers the executor's writes on top. Patches + win (they are the later writes), so `store._setRow(id, patch)` exists for the opposite ordering — + a test simulating an EXTERNAL mutation ("the worktree vanished under us"). + +Plus the **merge-requester seam**: `requestMerge` short-circuits to `merge-unavailable` before any +row mutation when `mergeRequester` is unset, so a bare `new TaskExecutor(store, root)` produced +ZERO `moveTask` calls. A prototype accessor does NOT work (TS class fields shadow it); the harness +patches `execute`/`resumeTaskForAgent` to inject a default when unset. + +**`FUSION_TEST_LOG_PROBE=1`** makes the harness's mocked logger write to console.error. Every +finding above came from it. Keep using it. + +## The two contract changes that explain most migrations + +- **`todo → in-progress` is scheduler-owned (KTD-2).** The graph parks at a ready-for-release seam; + a bare executor test never sees that move. +- **The in-review handoff IS the merge boundary**, carrying + `workflowMoveSource: "workflow-graph"` provenance. There is no completion-path + `moveTask(id, "in-review")`. + +## What the fallback deletion actually removed + +`maybeExecuteWorkflowGraph` → `executeWorkflowGraph`, returning `void`. It could return `false` and +hand the run to a legacy implementation path — an executor with no graph, no gates, and nothing +owning its completion. Gone with it: `transferPreHeldToLegacy` and the pre-held-slot hand-off, the +conditional at three completion boundaries in `runImplementation` (now plain returns) and their +legacy tails, the `graphOwned` branch in completed-task recovery, and +`executor-preheld-legacy-handoff.test.ts` (its 2 tests reached the fallback by `delete`-ing the +selection readers; they had become vacuous). `runImplementation(task, graphCompletion)` now takes +the callback as a REQUIRED positional parameter — the type-level statement that an unowned +implementation pass cannot be constructed. + +A store that cannot resolve a workflow selection now ALWAYS fails closed. Previously it failed +closed only when the task had enabled steps and otherwise fell through. + +## Ruled out / settled — do not redo + +- **Step 3 (`needs-replan` writers) is RECLASSIFIED, not deferred work-in-progress.** Owner ruling: + post-U3 the durable write happens at the graph's OWN `plan-replan` seam + (`requestPreMergeOptionalStepFix` → `executor.ts`), so the workflow IS the writer; the 14 readers + form one coherent graph-owned loop. It is the graph's durable replan signal wearing a legacy + name. The `legacy-adoption.test.ts` census guard requiring the literal in `executor.ts` is + CORRECT and must stay. Reader migration to a run-state signal is a post-cutover follow-up with + its own risk budget. Recorded in AGENTS.md and `docs/architecture.md`. +- **The 40-file surface definition undercounts.** It is + `grep -l createMockStore ∩ grep -l '.execute(|resumeTaskForAgent'`, which misses files that build + their own store — e.g. `post-done-continuation-no-wedge.test.ts` (3 reds) and + `executor-outer-dispatch-dependency-gate.test.ts`. `post-done-continuation-no-wedge` was verified + red WITHOUT the flip too, so it is pre-existing, but a successor should widen the surface to + `grep -l executor-test-helpers` rather than trust the 40. +- **`executor-task-done-invariant > moves a cleanly completed task to in-review via the merge-node + boundary` fails in ISOLATION**, so the inherited "suite-level Postgres contention" diagnosis for + it is wrong. Still pre-existing; still deserves its own look. + +## Still open + +- U11, the 6-column benchmark. Orientation from the coordinator: + `WorkflowGraphExecutorDeps` already accepts an injectable `columnBoundary` dep (wired at + `workflow-graph-executor.ts:544` node entry, `:1052` synthetic merge node, `:1160` drift check) + and `WorkflowTaskRuntimeDeps` passes it through, so the benchmark can assert real column moves via + `createWorkflowColumnBoundary` without standing up a store. There is NO 6-column fixture yet (the + engine fixtures dir has only `triage-duplicate-scenario.ts`). Model it on `task-pipeline-smoke`'s + injected-primitive pattern. +- `executor-prompt.test.ts` has two near-verbatim duplicate `fn_task_done with paused state + (FN-3964 / FN-4167 regression)` describe blocks. Pre-existing; dedup was out of scope. + +## Guardrails held at every commit + +Oracle net 59/59 (`task-pipeline-smoke`, `executor-graph-requeue-gate`, +`workflow-graph-executor-parity`, `executor-graph-boundary`, `merger-trait-rekey`, +`self-healing-trait-rekey`, `workflow-graph-column-moves`); engine-core gate 294/294; engine +typecheck clean. pg-gate excluded — known rotating suite-level contention, a different file set +every run with zero assertion failures. diff --git a/packages/core/src/__tests__/custom-review-lane-merge-blocker.test.ts b/packages/core/src/__tests__/custom-review-lane-merge-blocker.test.ts new file mode 100644 index 0000000000..e2b440905f --- /dev/null +++ b/packages/core/src/__tests__/custom-review-lane-merge-blocker.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { eq } from "drizzle-orm"; + +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, +} from "../__test-utils__/pg-test-harness.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; + +/* +FNXC:WorkflowTransitionPolicy 2026-07-19-10:50: +Regression for the re-hardcoded review lane in the KTD-5 merge-blocker invariant +(PR #2335 review). moveTaskInternal resolved `getTaskMergeBlocker(task)` for +EVERY non-bypassed move into a `complete` column, but that helper hard-rejects +any source column that is not literally "in-review". Two legal edges broke: + - six-column benchmark shape: merging → done (custom review pipeline; the + merge-blocker trait lives on in-review, NOT on merging), and + - builtin:coding in-progress → done (the mission-validation cross edge that + legacy flag-OFF allowed unchecked — its blocker gate was literally + `fromColumn === "in-review"`). +The fix keys blocker resolution on the SOURCE column's `mergeBlocker` trait +flag (the workflow's actual review-lane identity) and neutralizes the helper's +column-identity precondition, keeping only its content checks. + +Surface enumeration (invariant: the merge blocker fires on complete-bound exits +from a merge-blocker column, and ONLY there): + - Custom workflow, non-merge-blocker source into complete: merging → done allowed. + - Builtin cross edge, non-merge-blocker source into complete: in-progress → done allowed. + - Builtin merge-blocker source into complete with unmet content checks: + in-review → done with incomplete steps still rejects. +*/ + +/** Minimal six-column-benchmark-shaped IR: custom `merging` column between the + * merge-blocker review lane and the complete `done` column. */ +function sixColumnShapedIr(): WorkflowIr { + return { + version: "v2", + name: "test:six-column-shape", + columns: [ + { id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] }, + { + id: "todo", + name: "Todo", + traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }], + }, + { + id: "in-progress", + name: "In-progress", + traits: [ + { trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }, + { trait: "abort-on-exit" }, + { trait: "timing" }, + ], + }, + { + id: "in-review", + name: "In-review", + traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }], + }, + { id: "merging", name: "Merging", traits: [{ trait: "merge" }, { trait: "human-review" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "ideas" }, + { id: "triage", kind: "prompt", column: "todo", config: { name: "Triage", prompt: "Specify." } }, + { id: "implement", kind: "prompt", column: "in-progress", config: { name: "Implement", prompt: "Do it." } }, + { id: "review", kind: "prompt", column: "in-review", config: { name: "Review", prompt: "Review it." } }, + { id: "merge-attempt", kind: "merge-attempt", column: "merging", config: { capability: "task-merge" } }, + { id: "finalize", kind: "notify", column: "done", config: { name: "Announce done", event: "task.completed" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "triage" }, + { from: "triage", to: "implement", condition: "success" }, + { from: "implement", to: "review", condition: "success" }, + { from: "review", to: "merge-attempt", condition: "success" }, + { from: "merge-attempt", to: "finalize", condition: "success" }, + { from: "finalize", to: "end", condition: "success" }, + ], + } as WorkflowIr; +} + +pgDescribe("merge-blocker keys on the workflow's review lane, not a hardcoded 'in-review'", () => { + const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_review_lane" }); + beforeAll(harness.beforeAll); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + afterAll(harness.afterAll); + + /* + FNXC:WorkflowTransitionPolicy 2026-07-19-11:05: + The KTD-5 invariant block under test only runs on the flag-ON workflow path + (isWorkflowColumnsCompatibilityFlagEnabled reads the RAW experimental flag, + not the post-cutover "stale false counts as on" helper). Without this the + suite silently exercises the flag-OFF legacy path and cannot catch the bug. + */ + beforeEach(async () => { + await harness.store().updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + /** Force a task's column directly (bypassing move guards) so a single move + * edge can be exercised in isolation. Columnar tasks table (PG cutover). */ + async function forceColumn(taskId: string, column: string): Promise { + const store = harness.store(); + const layer = store.getAsyncLayer(); + if (!layer) throw new Error("expected async layer in backend mode"); + const { project } = await import("../postgres/schema/index.js"); + await layer.db.update(project.tasks).set({ column }).where(eq(project.tasks.id, taskId)); + } + + it("allows a non-bypassed user move merging → done in a six-column-shaped workflow", async () => { + const store = harness.store(); + const def = await store.createWorkflowDefinition({ name: "Six Column Shape", ir: sixColumnShapedIr() }); + const task = await store.createTask({ description: "benchmark card", workflowId: def.id }); + expect(task.column).toBe("ideas"); + + await forceColumn(task.id, "merging"); + const moved = await store.moveTask(task.id, "done", { moveSource: "user" }); + expect(moved.column).toBe("done"); + }); + + it("allows the builtin in-progress → done mission-validation cross edge for user moves", async () => { + const store = harness.store(); + // Explicit workflowId writes a selection row, so the move preflight and the + // in-lock move resolve the SAME catalog IR (a task with no selection hits a + // pre-existing catalog-vs-const signature mismatch unrelated to this test). + const task = await store.createTask({ description: "mission validation card", workflowId: "builtin:coding" }); + + await forceColumn(task.id, "in-progress"); + const moved = await store.moveTask(task.id, "done", { moveSource: "user" }); + expect(moved.column).toBe("done"); + }); + + it("still rejects in-review → done when the merge-blocker content checks fail", async () => { + const store = harness.store(); + const task = await store.createTask({ description: "blocked card", workflowId: "builtin:coding" }); + await store.updateTask(task.id, { steps: [{ name: "step 1", status: "pending" }] }); + + await forceColumn(task.id, "in-review"); + await expect(store.moveTask(task.id, "done", { moveSource: "user" })).rejects.toThrow( + /incomplete steps/, + ); + }); +}); diff --git a/packages/core/src/__tests__/legacy-adoption.test.ts b/packages/core/src/__tests__/legacy-adoption.test.ts new file mode 100644 index 0000000000..a362d26895 --- /dev/null +++ b/packages/core/src/__tests__/legacy-adoption.test.ts @@ -0,0 +1,454 @@ +/* +FNXC:LegacyAdoption 2026-07-19-12:20 (U9 / R10 / KTD-8): +The KTD-8 adoption contract + its build-failing WRITE-SITE CENSUS. The completeness +test greps every task.status write literal in core/engine/dashboard and fails the build if any +lacks an adoption-table row — so a status added during the cutover window is caught +at build time instead of mass-parking rows `paused` at upgrade. Plus adoption-action ++ reviewLevel-backfill unit coverage (fixture rows resume owned; never both fields). +*/ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + LEGACY_STATUS_ADOPTION, + resolveLegacyStatusAdoption, + resolveReviewLevelBackfill, + planLegacyAdoption, + resolveOrphanedPendingStepResults, +} from "../legacy-adoption.js"; +import { CODE_REVIEW_GROUP_ID } from "../builtin-code-review-group.js"; +import { PLAN_REVIEW_GROUP_ID } from "../builtin-plan-review-group.js"; +import { adoptLegacyTaskRowsOnOpen } from "../task-store/lifecycle-ops.js"; +import type { TaskStore } from "../store.js"; +import type { Task } from "../types.js"; + +const coreSrc = dirname(dirname(fileURLToPath(import.meta.url))); +const engineSrc = join(coreSrc, "..", "..", "engine", "src"); +const dashboardSrc = join(coreSrc, "..", "..", "dashboard", "src"); + +/* +FNXC:LegacyAdoption 2026-07-19-13:40 (PR #2341 review; same finding on PR #2335): +The census originally scanned a curated 6-file list while claiming "all of core + +engine" — any task.status write elsewhere (scheduler.ts, comments-ops.ts, dashboard +routes, or a NEW file in either package) silently bypassed the build gate. It now +recursively enumerates every non-test .ts source under core/engine/dashboard src in a +single pass, so the completeness claim matches what is actually scanned. +*/ +function listSourceFiles(root: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + const parent = entry.parentPath ?? root; + if (/(^|\/)(__tests__|dist|node_modules)(\/|$)/.test(parent)) continue; + if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts") || /\.test\.ts$/.test(entry.name)) continue; + out.push(join(parent, entry.name)); + } + return out; +} + +/** + * Census: extract every literal WRITTEN to a task's status field across a source + * tree. Task-status writes are matched via the concrete patterns the code uses — + * `updateTask(... status: "X" ...)`, `.status = "X"`, and + * `{ status: "X" ... } as ...Partial` — deliberately NOT the broad + * `status: "X"` (which would catch agent/session/merge-request statuses that are + * not task rows). Reads (`=== "X"`) are excluded. + */ +function censusTaskStatusWrites(sources: string[]): Set { + const found = new Set(); + /* + FNXC:LegacyAdoption 2026-07-19-13:50 (PR #2341 review): + Precision hardening required by the recursive scan. With the old curated 6-file list + the loose forms were safe; over the whole tree they false-positived on non-task + objects — `step.status = "pending"`, devserver/subtask `session.status`, dashboard + `usage.status = "ok"/"no-auth"` — and the updateTask lookahead crossed a `;` into a + neighboring `moveTask(...)` statement. Pattern 1 now stops at statement boundaries; + pattern 2 requires a task-named receiver (no direct `.status = "X"` write + can be a task row, and every real task write today goes through + updateTask/createTask/`as Partial` anyway). + */ + const patterns: RegExp[] = [ + // updateTask(id, { ... status: "X" ... }) / createTask({ ... status: "X" ... }) + // — `[^;]` so the lookahead cannot cross into the next statement. + /(?:updateTask|createTask)\([^;]{0,600}?status:\s*"([a-z][a-z-]*)"/g, + // .status = "X" (assignment, not === / == / >= / <=) — receiver must be + // task-named so step/session/usage/etc. object statuses are not censused. + /\b\w*[tT]ask\w*\.status\s*=\s*"([a-z][a-z-]*)"/g, + // { status: "X", ... } as (unknown as)? Partial { + it("every task.status write literal in core + engine + dashboard has an adoption row", () => { + const paths = [coreSrc, engineSrc, dashboardSrc].flatMap(listSourceFiles); + // Sanity: the recursive walk found a real tree, not an empty/renamed root. + expect(paths.length).toBeGreaterThan(100); + const files = paths.map((f) => readFileSync(f, "utf-8")); + const written = censusTaskStatusWrites(files); + // `null` clears are not literals; the adoption table covers named statuses. + const uncovered = [...written].filter((s) => LEGACY_STATUS_ADOPTION[s] === undefined); + // A NEW written status with no adoption row fails the build here (KTD-8). + expect(uncovered).toEqual([]); + }); + + it("the census actually finds task-status writes (guards against a broken/vacuous regex)", () => { + const files = [readFileSync(join(engineSrc, "executor.ts"), "utf-8")]; + const written = censusTaskStatusWrites(files); + // executor writes at least these — proves the census pattern is live, not vacuous. + expect(written.has("failed")).toBe(true); + expect(written.has("needs-replan")).toBe(true); + expect(written.size).toBeGreaterThan(3); + }); + + it("the adoption table explicitly covers the critical cutover statuses (census-independent guard)", () => { + // Some writes reach task.status via moveTask/computed values the regex census + // cannot see; assert the cutover-critical vocabulary is covered regardless. + for (const s of ["planning", "needs-replan", "plan-review-unavailable", "merging", "queued", "failed", "done", "awaiting-approval"]) { + expect(LEGACY_STATUS_ADOPTION[s], `missing adoption row for '${s}'`).toBeDefined(); + } + }); +}); + +describe("resolveLegacyStatusAdoption — every legacy (status) resumes owned", () => { + it("triage plan-review statuses resume the graph (writers deleted in U3)", () => { + for (const s of ["planning", "needs-replan", "plan-review-unavailable"]) { + expect(resolveLegacyStatusAdoption(s)?.kind).toBe("resume-graph"); + } + }); + + it("live human/terminal gates are preserved (never disturbed)", () => { + for (const s of ["awaiting-approval", "failed", "error", "blocked", "done", "cancelled"]) { + expect(resolveLegacyStatusAdoption(s)?.kind).toBe("preserve"); + } + }); + + it("no status (null/empty) needs no adoption", () => { + expect(resolveLegacyStatusAdoption(null)).toBeUndefined(); + expect(resolveLegacyStatusAdoption(undefined)).toBeUndefined(); + expect(resolveLegacyStatusAdoption("")).toBeUndefined(); + }); + + it("an UNMAPPABLE (unknown) status parks paused for a human — never silently frozen", () => { + const action = resolveLegacyStatusAdoption("some-future-status-xyz"); + expect(action?.kind).toBe("park-paused"); + expect(action?.note).toContain("some-future-status-xyz"); + }); +}); + +describe("resolveReviewLevelBackfill — never both fields", () => { + it("backfills a reviewLevel-only task with the U8 preset step set", () => { + expect(resolveReviewLevelBackfill({ reviewLevel: 2 })).toEqual({ + kind: "backfill", + enabledWorkflowSteps: [PLAN_REVIEW_GROUP_ID, CODE_REVIEW_GROUP_ID], + }); + expect(resolveReviewLevelBackfill({ reviewLevel: 1 })).toEqual({ + kind: "backfill", + enabledWorkflowSteps: [CODE_REVIEW_GROUP_ID], + }); + }); + + it("leaves a task with BOTH fields untouched and warned (explicit steps win)", () => { + expect(resolveReviewLevelBackfill({ reviewLevel: 3, enabledWorkflowSteps: [CODE_REVIEW_GROUP_ID] })).toEqual({ + kind: "both-set-warn", + }); + // explicit empty opt-out also counts as "set" + expect(resolveReviewLevelBackfill({ reviewLevel: 3, enabledWorkflowSteps: [] })).toEqual({ + kind: "both-set-warn", + }); + }); + + it("no-ops a task with no reviewLevel", () => { + expect(resolveReviewLevelBackfill({})).toEqual({ kind: "no-op" }); + expect(resolveReviewLevelBackfill({ enabledWorkflowSteps: [CODE_REVIEW_GROUP_ID] })).toEqual({ kind: "no-op" }); + }); +}); + +/* +FNXC:LegacyAdoption 2026-07-19-04:40 (U9b / R10 / KTD-8): +The adoption PLAN — the shared brain both consumers (store-open reconcile and the +self-healing startup sweep) run. U9 shipped the table with NO consumer, so these assert the +end-to-end decision each legacy row gets, plus the two properties the whole mechanism rests +on: zero frozen rows, and idempotency across restarts. +*/ +describe("planLegacyAdoption (U9b consumers)", () => { + const NOW = "2026-07-19T04:40:00.000Z"; + + it("clears every resume-graph status so the graph re-enters at its owning node", () => { + for (const status of ["planning", "needs-replan", "plan-review-unavailable", "queued", "triaged"]) { + const plan = planLegacyAdoption({ status }, NOW); + expect(plan.action, status).toBe("resume-graph"); + // Clearing the legacy status IS the re-entry: the graph owns the node again. + expect(plan.patch?.status, status).toBeNull(); + expect(plan.patch?.legacyAdoptedAt, status).toBe(NOW); + expect(plan.auditType, status).toBe("task:reconcile-legacy-adoption"); + } + }); + + it("parks an UNMAPPABLE status paused, leaving the status visible for the operator", () => { + const plan = planLegacyAdoption({ status: "some-status-from-the-future" }, NOW); + expect(plan.action).toBe("park-paused"); + expect(plan.patch?.paused).toBe(true); + expect(plan.patch?.pausedReason).toContain("some-status-from-the-future"); + // The status is deliberately NOT cleared — a human needs to see what the row carried. + expect(plan.patch?.status).toBeUndefined(); + expect(plan.auditType).toBe("task:reconcile-legacy-adoption-unmappable"); + }); + + it("never disturbs a preserve gate", () => { + for (const status of ["awaiting-approval", "failed", "done", "blocked", "cancelled"]) { + expect(planLegacyAdoption({ status }, NOW).action, status).toBe("skip"); + } + }); + + it("backfills reviewLevel-only rows and never writes both fields", () => { + const plan = planLegacyAdoption({ reviewLevel: 1 }, NOW); + expect(plan.patch?.enabledWorkflowSteps).toEqual([CODE_REVIEW_GROUP_ID]); + expect(plan.patch?.legacyAdoptedAt).toBe(NOW); + + // Explicit steps win: no backfill, nothing to adopt. + expect(planLegacyAdoption({ reviewLevel: 3, enabledWorkflowSteps: [CODE_REVIEW_GROUP_ID] }, NOW).action) + .toBe("skip"); + }); + + it("lands a reviewLevel backfill even on a preserve gate (orthogonal metadata)", () => { + const plan = planLegacyAdoption({ status: "awaiting-approval", reviewLevel: 2 }, NOW); + expect(plan.action).not.toBe("skip"); + expect(plan.patch?.enabledWorkflowSteps).toEqual([PLAN_REVIEW_GROUP_ID, CODE_REVIEW_GROUP_ID]); + // ...but the gate's status is still untouched. + expect(plan.patch?.status).toBeUndefined(); + }); + + /* + Idempotency is what makes the sweep safe to run on EVERY startup: without the stamp a + restart loop would re-clear a status a human re-set and re-park a row an operator + un-parked. + */ + it("is idempotent — an already-adopted row is never re-adopted", () => { + const plan = planLegacyAdoption({ status: "planning", legacyAdoptedAt: NOW }, NOW); + expect(plan.action).toBe("skip"); + expect(plan.patch).toBeUndefined(); + }); + + it("only stamps rows it actually mutates (no mass-write of every done row on upgrade)", () => { + expect(planLegacyAdoption({ status: "done" }, NOW).patch).toBeUndefined(); + expect(planLegacyAdoption({}, NOW).patch).toBeUndefined(); + }); + + /* + ZERO FROZEN ROWS — the headline U9/R10 property. Every status the adoption table knows + about, plus an unknown one, must resolve to a decision. A row that resolved to neither a + mutation nor a deliberate preserve/no-op would be exactly the silent freeze this exists to + prevent. + */ + it("leaves zero frozen rows across every known status and an unknown one", () => { + const statuses = [...Object.keys(LEGACY_STATUS_ADOPTION), "totally-unknown-status"]; + for (const status of statuses) { + const plan = planLegacyAdoption({ status }, NOW); + const owned = plan.patch !== undefined + || resolveLegacyStatusAdoption(status)?.kind === "preserve"; + expect(owned, `status '${status}' resolved to no adoption and no preserve gate`).toBe(true); + } + }); +}); + +/* +FNXC:LegacyAdoption 2026-07-19-04:40 (U9b / KTD-8): +Orphaned pending step results. A pre-cutover crash leaves a `pending` result with no live +session and the graph waits on it forever; a LEASED one is real work in flight. +*/ +describe("resolveOrphanedPendingStepResults (U9b)", () => { + it("clears pending results with no live session and preserves live ones", () => { + const results = [ + { stepIndex: 0, status: "done" }, + { stepIndex: 1, status: "pending" }, // orphaned + { stepIndex: 2, status: "pending" }, // live — leased + { stepIndex: 3, status: "failed" }, + ]; + const { cleared, clearedCount } = resolveOrphanedPendingStepResults( + results, + (r) => r.stepIndex === 2, + ); + expect(clearedCount).toBe(1); + expect(cleared.map((r) => r.stepIndex)).toEqual([0, 2, 3]); + }); + + it("is a no-op on empty/absent results", () => { + expect(resolveOrphanedPendingStepResults([], () => false).clearedCount).toBe(0); + expect(resolveOrphanedPendingStepResults(null, () => false).clearedCount).toBe(0); + }); +}); + +/* +FNXC:LegacyAdoption 2026-07-19-09:00 (PR #2335 review): +Pagination drain. `listTasks` returns newest-first pages, so a capped single fetch would +re-scan the same newest 500 rows on every open/restart and strand every older legacy row — +the frozen-row failure R10 forbids. These prove the sweep pages past the cap until the +active census is drained, and that the `legacyAdoptedAt` stamp keeps a drained sweep +idempotent on the next open. +*/ +/* +FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): +The fake store optionally carries a fake PG asyncLayer so the drained-marker +short-circuit is testable: `db.execute` answers the marker SELECT from +`markerPresent`, records marker INSERTs, and can be forced to throw to prove the +fail-open-toward-sweeping path. Omitting `backend` models SQLite mode (no +bookkeeping table → no marker, sweep always runs). +*/ +function makeFakeStore( + rows: Array & { id: string }>, + opts?: { backend?: boolean; markerPresent?: boolean; markerReadThrows?: boolean }, +) { + const listCalls: Array<{ limit?: number; offset?: number }> = []; + const markerWrites: string[] = []; + let markerPresent = opts?.markerPresent ?? false; + // Flatten a drizzle SQL object's chunks into inspectable text. + const sqlText = (q: unknown): string => { + const chunks = (q as { queryChunks?: unknown[] }).queryChunks ?? []; + return chunks + .map((c) => { + const v = (c as { value?: unknown }).value; + return Array.isArray(v) ? v.join("") : String(v ?? ""); + }) + .join(" "); + }; + const asyncLayer = opts?.backend + ? { + db: { + execute: async (q: unknown) => { + const text = sqlText(q); + if (text.includes("SELECT")) { + if (opts?.markerReadThrows) throw new Error("marker read boom"); + return markerPresent ? [{ version: "legacy-adoption-drained" }] : []; + } + if (text.includes("INSERT")) { + markerWrites.push(text); + markerPresent = true; + return []; + } + return []; + }, + }, + } + : undefined; + const store = { + asyncLayer, + listTasks: async (options?: { limit?: number; offset?: number }) => { + listCalls.push({ limit: options?.limit, offset: options?.offset }); + const offset = options?.offset ?? 0; + const limit = options?.limit ?? rows.length; + return rows.slice(offset, offset + limit) as Task[]; + }, + updateTask: async (id: string, patch: Partial) => { + const row = rows.find((r) => r.id === id)!; + Object.assign(row, patch); + return row as Task; + }, + } as unknown as TaskStore; + return { store, listCalls, rows, markerWrites }; +} + +describe("adoptLegacyTaskRowsOnOpen — paginates past the 500-row page cap", () => { + it("adopts every legacy row beyond the first page, not just the newest 500", async () => { + // 1101 legacy rows → 3 pages (500 + 500 + 101); a capped scan would strand 601. + const rows: Array & { id: string }> = Array.from({ length: 1101 }, (_, i) => ({ + id: `task-${i + 1}`, + status: "planning", + })); + const { store, listCalls } = makeFakeStore(rows); + + const adopted = await adoptLegacyTaskRowsOnOpen(store); + + expect(adopted).toBe(1101); + expect(rows.every((r) => r.status === null && typeof r.legacyAdoptedAt === "string")).toBe(true); + expect(listCalls.map((c) => c.offset)).toEqual([0, 500, 1000]); + expect(listCalls.every((c) => c.limit === 500)).toBe(true); + }); + + it("stops after one page when the census fits under the cap, and stays idempotent", async () => { + const rows = Array.from({ length: 3 }, (_, i) => ({ id: `task-${i + 1}`, status: "queued" })); + const { store, listCalls } = makeFakeStore(rows); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(3); + expect(listCalls.length).toBe(1); + + // Second open: every row is stamped `legacyAdoptedAt` — nothing is re-adopted. + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); + }); +}); + +/* +FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): +Drained-marker short-circuit contract: the sweep must skip when the marker is present, +sweep when it is absent or unreadable, write the marker only after a fully-clean drain, +and withhold it on any cycle that produced a mutating plan. +*/ +describe("adoptLegacyTaskRowsOnOpen — drained-marker completion short-circuit", () => { + it("writes the non-numeric marker after a clean drain (no mutating plan)", async () => { + const rows = [ + { id: "task-1", status: "done" }, // preserve gate → skip + { id: "task-2", status: "planning", legacyAdoptedAt: "2026-07-19" }, // already adopted → skip + { id: "task-3" }, // nothing legacy → skip + ]; + const { store, listCalls, markerWrites } = makeFakeStore(rows, { backend: true }); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); + // The sweep still ran (marker was absent) … + expect(listCalls.length).toBe(1); + // … and a clean drain recorded the durable marker exactly once, upsert-style. + expect(markerWrites.length).toBe(1); + expect(markerWrites[0]).toContain("INSERT"); + expect(markerWrites[0]).toContain("ON CONFLICT"); + }); + + it("skips the sweep entirely when the marker is present", async () => { + const rows = [{ id: "task-1", status: "planning" }]; + const { store, listCalls } = makeFakeStore(rows, { backend: true, markerPresent: true }); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); + expect(listCalls.length).toBe(0); + // The (hypothetical) legacy row is untouched — marker presence means it cannot exist. + expect(rows[0].status).toBe("planning"); + }); + + it("a mutating drain adopts but does NOT write the marker that cycle", async () => { + const rows = [{ id: "task-1", status: "planning" }]; + const { store, markerWrites } = makeFakeStore(rows, { backend: true }); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1); + expect(rows[0].status).toBeNull(); + expect(markerWrites.length).toBe(0); + + // Next open: the census is now clean → the marker lands, then later opens skip. + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); + expect(markerWrites.length).toBe(1); + }); + + it("a userPaused legacy row withholds the marker without being mutated", async () => { + const rows = [{ id: "task-1", status: "planning", userPaused: true }]; + const { store, markerWrites } = makeFakeStore(rows, { backend: true }); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(0); + // Operator-paused rows are never adopted … + expect(rows[0].status).toBe("planning"); + // … but they keep the census "not drained" so they stay adoptable after unpause. + expect(markerWrites.length).toBe(0); + }); + + it("falls back to sweeping when the marker read fails (fail-open toward correctness)", async () => { + const rows = [{ id: "task-1", status: "planning" }]; + const { store } = makeFakeStore(rows, { backend: true, markerReadThrows: true }); + + expect(await adoptLegacyTaskRowsOnOpen(store)).toBe(1); + expect(rows[0].status).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/no-selection-default-workflow-move.test.ts b/packages/core/src/__tests__/no-selection-default-workflow-move.test.ts new file mode 100644 index 0000000000..76c97f6983 --- /dev/null +++ b/packages/core/src/__tests__/no-selection-default-workflow-move.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, +} from "../__test-utils__/pg-test-harness.js"; +import { serializeWorkflowIr } from "../workflow-ir.js"; +import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js"; +import { + BUILTIN_WORKFLOWS, + DEFAULT_WORKFLOW_ID, + getBuiltinWorkflow, + resolveDefaultWorkflowIr, +} from "../builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; + +/* +FNXC:WorkflowBuiltins 2026-07-19-10:40: +Regression for the flag-ON "workflow move policy preflight is stale" throw on a +task with NO `task_workflow_selection` row. + +Root cause: two independent implementations of the same no-selection default. +`prepareWorkflowMovePolicyPreflightImpl` resolved it through the builtin catalog +(`builtin:coding` -> BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR) while +`resolveTaskWorkflowIrForMove` used the raw legacy `BUILTIN_CODING_WORKFLOW_IR` +constant (which the catalog now publishes as `builtin:legacy-coding`). The two +IRs serialize differently, so the signature the preflight stamped never matched +the one the move re-derived and every such move was rejected as stale. + +Fix: one authority — `resolveDefaultWorkflowIr()` — shared by the async move +resolver, the sync resolver, and `workflow-ir-resolver`'s `defaultCodingWorkflowIr`. + +Surface enumeration (invariant: EVERY no-selection default resolution yields the +same IR, and a no-selection move is not rejected): + - the shared helper resolves the CATALOG `builtin:coding` entry, not the legacy constant; + - the public async resolver (`resolveWorkflowIrForTask`) agrees with the helper for a + task whose selection row is absent; + - a real store move on a task with the selection row cleared succeeds (the throw's surface); + - the whole default column trail (triage -> todo -> in-progress) stays walkable, not just + the first hop that happened to reproduce. +*/ +describe("no-selection default workflow IR (single authority)", () => { + it("resolves the catalog builtin:coding entry, not the legacy coding IR", () => { + const catalog = getBuiltinWorkflow(DEFAULT_WORKFLOW_ID); + expect(catalog).toBeDefined(); + expect(serializeWorkflowIr(resolveDefaultWorkflowIr())).toBe( + serializeWorkflowIr(catalog!.ir as never), + ); + }); + + it("does not resolve to the legacy BUILTIN_CODING_WORKFLOW_IR constant", () => { + // Guards the exact drift: the legacy constant is `builtin:legacy-coding`, a + // DIFFERENT catalog entry. If these ever serialize the same the test is inert. + const legacyEntry = BUILTIN_WORKFLOWS.find((wf) => wf.ir === BUILTIN_CODING_WORKFLOW_IR); + expect(legacyEntry?.id).toBe("builtin:legacy-coding"); + expect(serializeWorkflowIr(resolveDefaultWorkflowIr())).not.toBe( + serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR), + ); + }); + + it("agrees with the public async resolver when a task has no selection", async () => { + const store = { + getTaskWorkflowSelection: () => undefined, + getTaskWorkflowSelectionAsync: async () => undefined, + getWorkflowDefinition: async () => undefined, + }; + const resolved = await resolveWorkflowIrForTask(store, "FN-NO-SELECTION"); + expect(serializeWorkflowIr(resolved)).toBe(serializeWorkflowIr(resolveDefaultWorkflowIr())); + }); +}); + +pgDescribe("moves on a task with no workflow-selection row", () => { + const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_no_selection_move" }); + beforeAll(harness.beforeAll); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + afterAll(harness.afterAll); + + it("moves through the default column trail without a stale-preflight rejection", async () => { + const store = harness.store(); + // The stale-preflight comparison only runs on the flag-ON workflow path. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const task = await store.createTask({ description: "no selection row" }); + await store.clearTaskWorkflowSelection(task.id); + expect(await store.getTaskWorkflowSelectionAsync(task.id)).toBeUndefined(); + + const toTodo = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(toTodo.column).toBe("todo"); + + const toInProgress = await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + expect(toInProgress.column).toBe("in-progress"); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 318cd64907..0e3af0517f 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -32,6 +32,9 @@ import { applySchemaBaseline, getAppliedMigrations, SCHEMA_BASELINE_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, + assertBinaryNotOlderThanDatabase, + StaleBinarySchemaError, cePluginSchemaInit, cliPressPluginSchemaInit, reportsPluginSchemaInit, @@ -761,6 +764,75 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", expect(await getAppliedMigrations(ctx.db)).toContain(BULK_COMPLETION_REFUSAL_AT_VERSION); }); + /* + FNXC:WorkflowIrPin 2026-07-19-03:30 (U9b / KTD-3 + KTD-8): + Same existing-DB shape as the 0018 regression above, for the durable IR pin (+ its node + and column entry) and the one-time legacy-adoption stamp. All five columns are in the + slim TaskStore projection, so a cluster that recorded 0000 and never received 0026 would + crash on the first slim SELECT rather than degrade — which is exactly why the baseline + edit alone is insufficient and the forward migration has to exist. + */ + it("upgrades an existing DB missing the IR-pin and legacy-adoption columns (0027)", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version = '0027'; + ALTER TABLE project.tasks DROP COLUMN workflow_ir_pin; + ALTER TABLE project.tasks DROP COLUMN workflow_ir_pin_node_id; + ALTER TABLE project.tasks DROP COLUMN workflow_ir_pin_column_id; + ALTER TABLE project.tasks DROP COLUMN legacy_adopted_at; + `)); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + const columns = (await ctx.db.execute(sql` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'project' + AND table_name = 'tasks' + AND column_name IN ('workflow_ir_pin', 'workflow_ir_pin_node_id', 'workflow_ir_pin_column_id', 'legacy_adopted_at') + ORDER BY column_name + `)) as unknown as Array<{ column_name: string }>; + expect(columns.map((c) => c.column_name)).toEqual([ + "legacy_adopted_at", + "workflow_ir_pin", + "workflow_ir_pin_column_id", + "workflow_ir_pin_node_id", + ]); + expect(await getAppliedMigrations(ctx.db)).toContain(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION); + }); + + /* + FNXC:StaleBinaryGuard 2026-07-19-03:30 (U9b / R10): + Old-binary write refusal. A database migrated by a NEWER Fusion must not be opened by an + older binary: the old binary writes rows under the previous schema's assumptions and + re-runs reconciles the newer version already superseded (the observed stale-Homebrew + failure mode, where a pre-fix binary re-ran the Ideas evacuation against a shared DB and + the audit trail showed an unidentifiable writer). + */ + it("refuses to open a database migrated by a newer binary (stale-binary guard)", () => { + const future = String(Number(SCHEMA_BASELINE_VERSION) + 1).padStart(4, "0"); + expect(() => assertBinaryNotOlderThanDatabase([SCHEMA_BASELINE_VERSION, future])) + .toThrow(StaleBinarySchemaError); + // Current and older versions are fine — this guard only fires on a FUTURE version. + expect(() => assertBinaryNotOlderThanDatabase(["0000", "0018", SCHEMA_BASELINE_VERSION])) + .not.toThrow(); + // Non-numeric markers (plugin / hand-inserted) must not brick every open. + expect(() => assertBinaryNotOlderThanDatabase(["plugin-roadmap-001", SCHEMA_BASELINE_VERSION])) + .not.toThrow(); + }); + + /* + Numeric, not lexical. A bare "9" is the case where the two disagree: numerically 9 is far + BELOW the current baseline (so an old marker must not trip the guard), but lexically "9" + sorts ABOVE "0027" and a string compare would refuse every open against such a database. + */ + it("compares schema versions numerically, not lexically", () => { + expect(() => assertBinaryNotOlderThanDatabase(["9"])).not.toThrow(); + expect(() => assertBinaryNotOlderThanDatabase(["0009"])).not.toThrow(); + // A genuinely newer version still throws regardless of padding. + expect(() => assertBinaryNotOlderThanDatabase(["0028"])).toThrow(StaleBinarySchemaError); + }); + /* FNXC:ProjectDataIsolation 2026-07-14-12:10: @@ -1294,6 +1366,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_VERIFICATION_REQUEST_VERSION, SYMBOL_LOCKS_SCHEMA_VERSION, BIGINT_COUNTERS_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1346,6 +1419,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_VERIFICATION_REQUEST_VERSION, SYMBOL_LOCKS_SCHEMA_VERSION, BIGINT_COUNTERS_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, ]); }); @@ -1531,6 +1605,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_VERIFICATION_REQUEST_VERSION, SYMBOL_LOCKS_SCHEMA_VERSION, BIGINT_COUNTERS_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, ]); }); @@ -1597,6 +1672,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_VERIFICATION_REQUEST_VERSION, SYMBOL_LOCKS_SCHEMA_VERSION, BIGINT_COUNTERS_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, ]); }); @@ -1663,6 +1739,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { TASK_VERIFICATION_REQUEST_VERSION, SYMBOL_LOCKS_SCHEMA_VERSION, BIGINT_COUNTERS_VERSION, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, ]); }); }); diff --git a/packages/core/src/__tests__/review-level-preset.test.ts b/packages/core/src/__tests__/review-level-preset.test.ts new file mode 100644 index 0000000000..d718e6342b --- /dev/null +++ b/packages/core/src/__tests__/review-level-preset.test.ts @@ -0,0 +1,75 @@ +/* +FNXC:ReviewLevelPreset 2026-07-19-10:20 (U8 / R6 / KTD-11): +Unit coverage for the reviewLevel creation-time preset mapper. The per-level step +sets are the R6 contract; the explicit-wins + colliding-id cases lock KTD-11 +(preset ids flow through the same optional-group id pass-through the creation paths +use, so a preset id colliding with a legacy template id stays identity-stable). +*/ +import { describe, expect, it } from "vitest"; +import { resolveReviewLevelSteps, applyReviewLevelPreset } from "../review-level-preset.js"; +import { PLAN_REVIEW_GROUP_ID } from "../builtin-plan-review-group.js"; +import { CODE_REVIEW_GROUP_ID } from "../builtin-code-review-group.js"; +import { BROWSER_VERIFICATION_GROUP_ID } from "../builtin-browser-verification-group.js"; + +type PresetInput = { reviewLevel?: number; enabledWorkflowSteps?: string[]; description?: string }; +const preset = (input: PresetInput): PresetInput => applyReviewLevelPreset(input); + +describe("resolveReviewLevelSteps — R6 level mapping", () => { + it("level 0 → no optional groups", () => { + expect(resolveReviewLevelSteps(0)).toEqual([]); + }); + it("level 1 → code-review", () => { + expect(resolveReviewLevelSteps(1)).toEqual([CODE_REVIEW_GROUP_ID]); + }); + it("level 2 → plan-review + code-review", () => { + expect(resolveReviewLevelSteps(2)).toEqual([PLAN_REVIEW_GROUP_ID, CODE_REVIEW_GROUP_ID]); + }); + it("level 3 → plan-review + browser-verification + code-review", () => { + expect(resolveReviewLevelSteps(3)).toEqual([ + PLAN_REVIEW_GROUP_ID, + BROWSER_VERIFICATION_GROUP_ID, + CODE_REVIEW_GROUP_ID, + ]); + }); + it("unknown / out-of-range levels map to the empty set (never silently enable a gate)", () => { + expect(resolveReviewLevelSteps(99)).toEqual([]); + expect(resolveReviewLevelSteps(-1)).toEqual([]); + }); +}); + +describe("applyReviewLevelPreset — normalization (explicit wins)", () => { + it("derives enabledWorkflowSteps from reviewLevel when none is provided", () => { + expect(preset({ reviewLevel: 2 }).enabledWorkflowSteps).toEqual([ + PLAN_REVIEW_GROUP_ID, + CODE_REVIEW_GROUP_ID, + ]); + }); + + it("leaves input untouched when reviewLevel is absent", () => { + const input: PresetInput = { description: "x" }; + expect(preset(input)).toBe(input); + expect(preset(input).enabledWorkflowSteps).toBeUndefined(); + }); + + it("explicit enabledWorkflowSteps ALWAYS wins over reviewLevel (including explicit empty opt-out)", () => { + expect(preset({ reviewLevel: 3, enabledWorkflowSteps: [CODE_REVIEW_GROUP_ID] }).enabledWorkflowSteps).toEqual([CODE_REVIEW_GROUP_ID]); + // explicit [] is an opt-out and must survive the preset + expect(preset({ reviewLevel: 3, enabledWorkflowSteps: [] }).enabledWorkflowSteps).toEqual([]); + }); + + it("does not mutate the argument", () => { + const input: PresetInput = { reviewLevel: 1 }; + const out = preset(input); + expect(out).not.toBe(input); + expect((input as { enabledWorkflowSteps?: string[] }).enabledWorkflowSteps).toBeUndefined(); + }); + + it("colliding id (KTD-11): a preset id equal to a legacy template id is passed through verbatim, not remapped", () => { + // The preset emits the canonical optional-group ids as-is; the creation path's + // resolveEnabledWorkflowSteps + optionalGroupIdSet pass-through keeps them + // identity-stable. Here we assert the mapper never rewrites/aliases the id. + const out = preset({ reviewLevel: 1 }); + expect(out.enabledWorkflowSteps).toEqual([CODE_REVIEW_GROUP_ID]); + expect(out.enabledWorkflowSteps?.[0]).toBe("code-review"); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir-validation.test.ts b/packages/core/src/__tests__/workflow-ir-validation.test.ts new file mode 100644 index 0000000000..b2416eeec4 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-validation.test.ts @@ -0,0 +1,299 @@ +/* +FNXC:WorkflowValidation 2026-07-18-22:35: +U2 — workflow IR validation hardening. Two axes: + 1. Save-time HARD ERRORS: node → nonexistent column; merge-blocker column with + no reachable merge-class node; (route-level) column-delete-with-occupants; + the creation-column rule (intake-else-first). + 2. CAPABILITY FLOOR: validation must PERMIT the operator's benchmark shape — + review nodes in a hold column, bounded revise/retry caps as node config, + a backward remediation edge across a column boundary, and a completion- + summary node ordered after a review node in the same column. Anything the + editor can express but validation rejects (or vice versa) is a U2 bug. +*/ +import { describe, expect, it } from "vitest"; + +import { + BUILTIN_CODING_WORKFLOW_IR, + parseWorkflowIr, + resolveCreationColumn, +} from "../index.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { planReviewOptionalGroupNode } from "../builtin-plan-review-group.js"; +import { completionSummaryNode } from "../builtin-completion-summary-node.js"; +import { computeRemovedOccupiedColumns } from "../workflow-reconciliation.js"; + +// ── Save-time hard errors ───────────────────────────────────────────────────── + +describe("workflow IR validation — node → nonexistent column (hard error)", () => { + it("rejects a node assigned to a column the workflow does not declare", () => { + const ir: WorkflowIr = { + version: "v2", + name: "bad-column", + columns: [{ id: "todo", name: "Todo", traits: [{ trait: "intake" }] }], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "nonexistent" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [ + { from: "start", to: "work" }, + { from: "work", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'nonexistent'/); + }); +}); + +describe("workflow IR validation — merge-blocker reachability (hard error)", () => { + const columns = [ + { id: "in-review", name: "In review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ]; + + it("rejects a merge-blocker column with no reachable merge-class node", () => { + const ir: WorkflowIr = { + version: "v2", + name: "blocker-without-merge", + columns, + nodes: [ + { id: "start", kind: "start", column: "in-review" }, + { id: "review", kind: "prompt", column: "in-review" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "review" }, + { from: "review", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).toThrow(/merge-blocker trait but the graph has\s+no reachable merge-class node/); + }); + + it("passes when a merge-class node is reachable from start", () => { + const ir: WorkflowIr = { + version: "v2", + name: "blocker-with-merge", + columns, + nodes: [ + { id: "start", kind: "start", column: "in-review" }, + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "merge-gate" }, + { from: "merge-gate", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("does not fire for a merge-less docs-only workflow (no merge-blocker trait)", () => { + const ir: WorkflowIr = { + version: "v2", + name: "docs-only", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "write", kind: "prompt", column: "todo" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "write" }, + { from: "write", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + +describe("workflow IR validation — column-delete-with-occupants (route-level guard)", () => { + it("computeRemovedOccupiedColumns surfaces per-column occupant counts", () => { + const existing: WorkflowIr = { + version: "v2", + name: "before", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "intake" }] }, + { id: "review", name: "Review", traits: [] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "todo" }], + edges: [], + }; + const next: WorkflowIr = { ...existing, columns: [existing.columns![0], existing.columns![2]] }; + const removed = computeRemovedOccupiedColumns(existing, next, new Map([["review", 3]])); + expect(removed).toEqual([{ columnId: "review", count: 3 }]); + }); + + it("does not flag a removed column that holds no occupants", () => { + const existing: WorkflowIr = { + version: "v2", + name: "before", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "intake" }] }, + { id: "review", name: "Review", traits: [] }, + ], + nodes: [{ id: "start", kind: "start", column: "todo" }], + edges: [], + }; + const next: WorkflowIr = { ...existing, columns: [existing.columns![0]] }; + expect(computeRemovedOccupiedColumns(existing, next, new Map([["review", 0]]))).toEqual([]); + }); +}); + +describe("workflow IR validation — creation column rule (intake-else-first)", () => { + it("resolves the intake-flagged column when present", () => { + const ir: WorkflowIr = { + version: "v2", + name: "with-intake", + columns: [ + { id: "ideas", name: "Ideas", traits: [] }, + { id: "todo", name: "Todo", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "todo" }], + edges: [], + }; + expect(resolveCreationColumn(ir)?.id).toBe("todo"); + }); + + it("falls back to the first column when no intake column exists (documented default)", () => { + const ir: WorkflowIr = { + version: "v2", + name: "no-intake", + columns: [ + { id: "backlog", name: "Backlog", traits: [] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "backlog" }], + edges: [], + }; + expect(resolveCreationColumn(ir)?.id).toBe("backlog"); + }); + + it("returns undefined for a v1 / column-less IR", () => { + const v1: WorkflowIr = { + version: "v1", + name: "legacy", + nodes: [{ id: "start", kind: "start" }], + edges: [], + }; + expect(resolveCreationColumn(v1)).toBeUndefined(); + }); +}); + +// ── Capability floor: validation MUST permit the benchmark shape ─────────────── + +describe("workflow IR validation — capability floor (benchmark shape permitted)", () => { + it("passes the full built-in coding workflow (optional-group reviews, bounded caps, remediation edges, summary-after-review)", () => { + // BUILTIN_CODING_WORKFLOW_IR is already parsed+validated; re-parsing proves the + // canonical full-lifecycle shape survives U2's added rules unchanged (R8). + expect(() => parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR)).not.toThrow(); + }); + + it("permits a Plan Review optional-group node placed in a HOLD column (Plan Review in Todo)", () => { + const ir: WorkflowIr = { + version: "v2", + name: "review-in-hold", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + // The real Plan Review node builder, placed in the hold column. + planReviewOptionalGroupNode("todo"), + { id: "execute", kind: "prompt", column: "in-progress" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "plan-review" }, + { from: "plan-review", to: "execute", condition: "success" }, + { from: "execute", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("permits bounded revise/retry caps as node config (replan cap, code-review cycles, merge retries)", () => { + const ir: WorkflowIr = { + version: "v2", + name: "bounded-caps", + columns: [ + { id: "in-review", name: "In review", traits: [{ trait: "merge-blocker" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "in-review" }, + // merge retries = 3 as a retry-backoff cap. + { id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } }, + { id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "merge-gate" }, + { from: "merge-gate", to: "merge-retry", condition: "failure" }, + { from: "merge-gate", to: "end", condition: "success" }, + { from: "merge-retry", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("permits a remediation edge that moves the card BACKWARD across a column boundary (In-review → In-progress)", () => { + const ir: WorkflowIr = { + version: "v2", + name: "backward-remediation", + columns: [ + { id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] }, + { id: "in-review", name: "In review", traits: [{ trait: "human-review" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "in-progress" }, + { id: "execute", kind: "prompt", column: "in-progress" }, + { id: "review", kind: "prompt", column: "in-review" }, + { id: "fix", kind: "prompt", column: "in-progress" }, // remediation node in the backward column + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + // The distinctive benchmark capability: a REVISE edge routing the card + // backward from the In-review column to a node in the In-progress column. + { from: "review", to: "fix", condition: "outcome:revise" }, + { from: "review", to: "end", condition: "success" }, + { from: "fix", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("permits a completion-summary node ordered AFTER a review node within the same column", () => { + const ir: WorkflowIr = { + version: "v2", + name: "summary-after-review", + columns: [ + { id: "in-review", name: "In review", traits: [{ trait: "human-review" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "in-review" }, + { id: "review", kind: "prompt", column: "in-review" }, + completionSummaryNode("in-review"), // same column, ordered after review + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "review" }, + { from: "review", to: "completion-summary", condition: "success" }, + { from: "completion-summary", to: "end", condition: "success" }, + ], + }; + expect(() => parseWorkflowIr(ir)).not.toThrow(); + // The summary node keeps its identity (engine keys behavior off it). + const parsed = parseWorkflowIr(ir); + expect(parsed.nodes.some((n) => n.id === "completion-summary")).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/workflow-lifecycle-traits.test.ts b/packages/core/src/__tests__/workflow-lifecycle-traits.test.ts new file mode 100644 index 0000000000..3cc381012f --- /dev/null +++ b/packages/core/src/__tests__/workflow-lifecycle-traits.test.ts @@ -0,0 +1,120 @@ +/* +FNXC:WorkflowLifecycleTraits 2026-07-19-06:20 (U6 / KTD-10 / R8): +Unit coverage for the trait→column primitives that self-healing's trait re-key is +built on. The builtin:coding cases are the R8 evidence — every trait resolves to +exactly the legacy column id the old literals used, so a re-key keyed on these is +byte-identical on the default workflow. The custom cases prove KTD-10 fallback. +*/ +import { describe, expect, it } from "vitest"; +import "../builtin-traits.js"; // register built-in traits +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "../workflow-lifecycle-traits.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; + +describe("columnsWithFlag — builtin:coding trait→columnIds (R8)", () => { + const ir = BUILTIN_CODING_WORKFLOW_IR; + it("maps each lifecycle trait to exactly the legacy column ids", () => { + expect(columnsWithFlag(ir, "countsTowardWip")).toEqual(["in-progress"]); + expect(columnsWithFlag(ir, "hold")).toEqual(["todo"]); + expect(columnsWithFlag(ir, "intake")).toEqual(["triage"]); + expect(columnsWithFlag(ir, "mergeOrchestration")).toEqual(["in-review"]); + expect(columnsWithFlag(ir, "complete")).toEqual(["done"]); + expect(columnsWithFlag(ir, "archived")).toEqual(["archived"]); + }); + + it("columnHasFlag agrees with the literal columns", () => { + expect(columnHasFlag(ir, "in-progress", "countsTowardWip")).toBe(true); + expect(columnHasFlag(ir, "todo", "hold")).toBe(true); + expect(columnHasFlag(ir, "in-review", "mergeOrchestration")).toBe(true); + expect(columnHasFlag(ir, "done", "complete")).toBe(true); + expect(columnHasFlag(ir, "in-progress", "complete")).toBe(false); + expect(columnHasFlag(ir, "nonexistent", "hold")).toBe(false); + }); +}); + +describe("resolveReboundTarget — KTD-10 ordering", () => { + it("targets the hold column for builtin:coding (== legacy 'todo', R8 byte-identical)", () => { + expect(resolveReboundTarget(BUILTIN_CODING_WORKFLOW_IR)).toBe("todo"); + }); + + it("prefers hold, then intake, then the first column", () => { + const holdWf: WorkflowIr = { + version: "v2", name: "h", + columns: [ + { id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }, + { id: "backlog", name: "Backlog", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "wip", name: "WIP", traits: [{ trait: "wip" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "inbox" }], + edges: [], + } as WorkflowIr; + expect(resolveReboundTarget(holdWf)).toBe("backlog"); // hold beats intake + }); + + it("falls back to the intake column when there is no hold column (custom workflow)", () => { + const noHold: WorkflowIr = { + version: "v2", name: "n", + columns: [ + { id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] }, + { id: "doing", name: "Doing", traits: [{ trait: "wip" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "ideas" }], + edges: [], + } as WorkflowIr; + expect(resolveReboundTarget(noHold)).toBe("ideas"); + }); + + it("falls back to the first column when there is neither hold nor intake", () => { + const bare: WorkflowIr = { + version: "v2", name: "b", + columns: [ + { id: "first", name: "First", traits: [] }, + { id: "second", name: "Second", traits: [{ trait: "wip" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "first" }], + edges: [], + } as WorkflowIr; + expect(resolveReboundTarget(bare)).toBe("first"); + }); + + it("returns undefined for a column-less (v1) IR (caller keeps its literal fallback)", () => { + const v1: WorkflowIr = { version: "v1", name: "v1", nodes: [{ id: "start", kind: "start" }], edges: [] } as WorkflowIr; + expect(resolveReboundTarget(v1)).toBeUndefined(); + }); +}); + +describe("resolveCompleteColumn / resolveMergeOrchestrationColumn — U7", () => { + it("resolves to done / in-review for builtin:coding (R8 byte-identical)", () => { + expect(resolveCompleteColumn(BUILTIN_CODING_WORKFLOW_IR)).toBe("done"); + expect(resolveMergeOrchestrationColumn(BUILTIN_CODING_WORKFLOW_IR)).toBe("in-review"); + }); + + it("resolves a custom workflow's own complete + merge-orchestration columns (benchmark shape)", () => { + const benchmark: WorkflowIr = { + version: "v2", name: "benchmark", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] }, + { id: "in-review", name: "In review", traits: [{ trait: "human-review" }] }, + { id: "merging", name: "Merging", traits: [{ trait: "merge" }, { trait: "merge-blocker" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "todo" }], + edges: [], + } as WorkflowIr; + expect(resolveCompleteColumn(benchmark)).toBe("shipped"); + expect(resolveMergeOrchestrationColumn(benchmark)).toBe("merging"); + }); + + it("returns undefined when the workflow declares no complete / merge column", () => { + const bare: WorkflowIr = { + version: "v2", name: "b", + columns: [{ id: "only", name: "Only", traits: [] }], + nodes: [{ id: "start", kind: "start", column: "only" }], + edges: [], + } as WorkflowIr; + expect(resolveCompleteColumn(bare)).toBeUndefined(); + expect(resolveMergeOrchestrationColumn(bare)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 5c74be2578..de68f44299 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -175,12 +175,51 @@ interface BuiltinSpec { }; } -function defaultColumnForLinearNode(node: WorkflowIrNode): string { +/* +FNXC:WorkflowBuiltins 2026-07-19-11:05: +Column defaulting for a linear built-in's nodes. Post-cutover a node's column IS +the card's lifecycle position, so an unseamed node (a custom `gate`/`prompt` an +author drops between the seams) can no longer default to a fixed column: the old +blanket `todo` default sent the card BACKWARD into the capacity-hold column +mid-run. Observed on `builtin:review-heavy` (in-review -> todo -> in-review at +the `security` gate), `builtin:design` (in-progress -> todo at `design-review`), +and `builtin:compound-engineering` (`review-handoff`/`document`). Re-entering the +hold column mid-flight also re-arms its `reset-on-entry` trait and re-subjects a +live card to the release sweep. + +Rule: seams keep their fixed lifecycle homes; an unseamed node INHERITS the +column of the node before it, so it stays wherever the pipeline already is. The +one exception is a node that follows intake — planning happens in the hold column +(plan-in-place), which is what `builtin:compound-engineering`'s `plan` node needs. +*/ +function columnForLinearNode(node: WorkflowIrNode, previousColumn: string): string { + // `start`/`end` are graph terminals, not column destinations (the boundary + // never enters them), but they must still name a sane column: intake for the + // creation column and the complete column for the terminal. + if (node.kind === "start") return "triage"; + if (node.kind === "end") return "done"; const seam = node.config?.seam; if (seam === "execute") return "in-progress"; if (seam === "review") return "in-review"; if (seam === "merge") return "in-review"; - return "todo"; + return previousColumn === "triage" ? "todo" : previousColumn; +} + +/** Resolve every linear-spec node's column in graph order, threading the + * previously-resolved column so unseamed nodes inherit it. */ +function assignLinearNodeColumns(nodes: WorkflowIrNode[]): WorkflowIrNode[] { + let previousColumn = "triage"; + return nodes.map((node) => { + if (node.column) { + previousColumn = node.column; + return node; + } + const column = columnForLinearNode(node, previousColumn); + // `end` names the complete column but must not drag the inheritance chain + // there — nothing follows it, so this is only defensive. + if (node.kind !== "end") previousColumn = column; + return { ...node, column }; + }); } function canonicalBuiltinWorkflowColumns(): WorkflowIrColumn[] { @@ -267,7 +306,7 @@ function linear(spec: BuiltinSpec): WorkflowDefinition { version: "v2", name: spec.name, columns: canonicalBuiltinWorkflowColumns(), - nodes: nodes.map((node) => (node.column ? node : { ...node, column: defaultColumnForLinearNode(node) })), + nodes: assignLinearNodeColumns(nodes), edges, }); if (ir.version !== "v2" || !ir.columns.find((column) => column.id === "todo")?.traits.some((trait) => trait.trait === "hold")) { @@ -777,3 +816,24 @@ const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf])); export function getBuiltinWorkflow(id: string): WorkflowDefinition | undefined { return BUILTIN_BY_ID.get(id); } + +/** The operator-facing default workflow id used when a task has no + * `task_workflow_selection` row. */ +export const DEFAULT_WORKFLOW_ID = "builtin:coding"; + +/* +FNXC:WorkflowBuiltins 2026-07-19-10:20: +Single authority for the no-selection default IR. `builtin:coding` is an id +that the catalog maps to BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR — it +is NOT the legacy `BUILTIN_CODING_WORKFLOW_IR` constant (that constant is now +`builtin:legacy-coding`). Two move-path resolvers had drifted apart on exactly +this point: prepareWorkflowMovePolicyPreflightImpl resolved the default through +the catalog while resolveTaskWorkflowIrForMove used the raw constant, so a task +with NO selection row produced two different workflow signatures and every +flag-ON move threw "workflow move policy preflight is stale". Both sides (and +the sync resolver) now call this helper so the default cannot drift again. +*/ +export function resolveDefaultWorkflowIr(): WorkflowIr { + const ir = getBuiltinWorkflow(DEFAULT_WORKFLOW_ID)?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + return typeof ir === "string" ? parseWorkflowIr(ir) : ir; +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 97f1e7f301..44c3eaadae 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -42,7 +42,7 @@ version of this file was falsified: 141/335 gate tests failed on missing etc. pulled in by production modules, not test files). */ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { @@ -482,6 +482,8 @@ export { isBuiltinWorkflowId, isBuiltinWorkflowPluginGated, isBuiltinWorkflowDeprecated, + DEFAULT_WORKFLOW_ID, + resolveDefaultWorkflowIr, } from "./builtin-workflows.js"; export { COMPLETION_SUMMARY_NODE_ID, @@ -1958,14 +1960,6 @@ export type { WorkflowColumnsGraduationReport, GraduationReportInputs, } from "./workflow-parity.js"; -export { - WORKFLOW_INTERPRETER_AUTHORITATIVE_FLAG, - evaluateInterpreterCutoverReadiness, -} from "./workflow-cutover.js"; -export type { - InterpreterCutoverReadinessInput, - InterpreterCutoverReadinessResult, -} from "./workflow-cutover.js"; export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js"; export type { ResolvedResearchSettings } from "./research-settings.js"; export { isEvalsExperimentalEnabled, resolveEvalSettings } from "./eval-settings.js"; @@ -2222,9 +2216,31 @@ export { hasSyncPassphraseConfigured, } from "./secrets-sync-passphrase.js"; export { suggestTaskPrefix } from "./task-prefix.js"; +/* +FNXC:WorkflowStepResults 2026-07-19-01:00: +Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the main barrel (index.ts). The `engine-core` vitest project builds its @fusion/core from THIS file (scripts/build-engine-core-gate-bundle.mjs), so any lease/step-result export present in index.ts but missing here resolves to `undefined` ONLY under engine-core — which is exactly how U3's `classifyReviewLease` went missing and threw "classifyReviewLease is not a function" on every defaultOn Plan Review run in that project (caught by task-pipeline-smoke). When adding an export to the index.ts workflow-step-results block, add it here too. +*/ export { upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS, + PLAN_REVIEW_LEASE_STALENESS_MS, + classifyReviewLease, + makeReviewLeaseRecord, + isTerminalStepResult, + type ReviewLeaseDisposition, } from "./workflow-step-results.js"; +/* +FNXC:GateBarrelSync 2026-07-19-01:10: +Cutover (IR-driven lifecycle) barrel sync — same failure class as the classifyReviewLease incident above, found again when U4's resolveWipBudgetColumns threw "is not a function" in the engine-core hold/release sweep and silently zeroed all scheduler releases (scheduler-workflow-cutover gate suite 10/28 red). RULE: every runtime export the cutover adds to index.ts MUST be mirrored here; the engine-core gate bundle builds @fusion/core from THIS barrel. This block mirrors the cutover's lifecycle modules (transition policy, lifecycle traits, capacity budget, IR pin/drift, review-level preset, legacy adoption, creation column). +*/ +export { resolveCreationColumn } from "./workflow-ir.js"; +export { resolveWipBudgetColumns } from "./workflow-capacity.js"; +export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "./workflow-lifecycle-traits.js"; +export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; +export { LEGACY_STATUS_ADOPTION, resolveLegacyStatusAdoption, resolveReviewLevelBackfill, planLegacyAdoption, resolveOrphanedPendingStepResults, type LegacyAdoptionPlan, type LegacyAdoptionCandidate, type LegacyAdoptionAction, type LegacyAdoptionKind } from "./legacy-adoption.js"; +export { hashWorkflowIr, computeWorkflowIrPin, detectWorkflowDrift, type WorkflowIrPin } from "./workflow-ir-resolver.js"; +export { evaluateTransitionInvariants, evaluateMergeBlockerPostcondition, evaluateTerminalReentryPostcondition, evaluateCapacityRejection, isWipColumn, isTerminalColumn, isCompleteColumn, isHoldColumn, isHoldToWipBoundary, type CapacityFacts } from "./workflow-transition-policy.js"; +// FNXC:GateBarrelSync 2026-07-19-12:20: stale-binary guard exports mirrored per the sync rule above (PR #2341 review). +export { StaleBinarySchemaError, assertBinaryNotOlderThanDatabase } from "./postgres/schema-applier.js"; export { promoteResearchFinding } from "./research-feature-promotion.js"; export type { ResearchFeaturePromotionInput } from "./research-feature-promotion.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4c2293848c..4d7f30f5e5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export type { NativeStructureRef, NativeStructureEmbed, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js"; export type { @@ -192,6 +192,7 @@ export { parseWorkflowIr, serializeWorkflowIr, stripApprovalBypassFlags, + resolveCreationColumn, WorkflowIrError, DEFAULT_WORKFLOW_COLUMN_IDS, WORKFLOW_SETTING_TYPES, @@ -409,7 +410,21 @@ export { } from "./plugin-gate-verdict.js"; export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── -export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +export { resolveColumnCapacity, resolveWipBudgetColumns, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +export { columnsWithFlag, columnHasFlag, resolveReboundTarget, resolveCompleteColumn, resolveMergeOrchestrationColumn } from "./workflow-lifecycle-traits.js"; +export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js"; +export { + LEGACY_STATUS_ADOPTION, + resolveLegacyStatusAdoption, + resolveReviewLevelBackfill, + type LegacyAdoptionKind, + type LegacyAdoptionAction, + type ReviewLevelBackfillDecision, + planLegacyAdoption, + resolveOrphanedPendingStepResults, + type LegacyAdoptionPlan, + type LegacyAdoptionCandidate, +} from "./legacy-adoption.js"; export type { ColumnCapacity } from "./workflow-capacity.js"; // ── U5: workflow lifecycle reconciliation (switch / edit / delete) ─────────── export { @@ -491,6 +506,8 @@ export { isBuiltinWorkflowId, isBuiltinWorkflowPluginGated, isBuiltinWorkflowDeprecated, + DEFAULT_WORKFLOW_ID, + resolveDefaultWorkflowIr, } from "./builtin-workflows.js"; export { COMPLETION_SUMMARY_NODE_ID, @@ -504,8 +521,28 @@ export { resolvePlanningPromptFromIr, resolveTaskSeamPrompt, resolveTaskPlanningPrompt, + hashWorkflowIr, + computeWorkflowIrPin, + detectWorkflowDrift, + type WorkflowIrPin, + type WorkflowDriftReason, type WorkflowIrResolverStore, } from "./workflow-ir-resolver.js"; +export { + type TransitionColumnFacts, + type CapacityFacts, + type TransitionInvariantInput, + type TransitionPolicyDecision, + evaluateTransitionInvariants, + evaluateMergeBlockerPostcondition, + evaluateTerminalReentryPostcondition, + evaluateCapacityRejection, + isWipColumn, + isTerminalColumn, + isCompleteColumn, + isHoldColumn, + isHoldToWipBoundary, +} from "./workflow-transition-policy.js"; export { resolveEffectiveSettings, resolveEffectiveSettingsDetailed, @@ -2396,6 +2433,9 @@ export { applySchemaBaseline, getAppliedMigrations, SCHEMA_BASELINE_VERSION, + // FNXC:StaleBinaryGuard 2026-07-19-03:10 (U9b / R10): old-binary write refusal. + StaleBinarySchemaError, + assertBinaryNotOlderThanDatabase, // FNXC:BackendFlip 2026-06-26-14:30: // Runtime startup factory (cutover milestone). Production construction sites // (engine, dashboard, CLI serve/dashboard, desktop) consult this to boot @@ -2517,6 +2557,11 @@ export { export { upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS, + PLAN_REVIEW_LEASE_STALENESS_MS, + classifyReviewLease, + makeReviewLeaseRecord, + isTerminalStepResult, + type ReviewLeaseDisposition, } from "./workflow-step-results.js"; // FNXC:SqliteRemoval 2026-07-14: Export async audit reader so engine tests can // query run-audit events in backend mode (sync getRunAuditEvents returns [] in PG mode). diff --git a/packages/core/src/legacy-adoption.ts b/packages/core/src/legacy-adoption.ts new file mode 100644 index 0000000000..c899857ceb --- /dev/null +++ b/packages/core/src/legacy-adoption.ts @@ -0,0 +1,261 @@ +/* +FNXC:LegacyAdoption 2026-07-19-12:00 (U9 / R10 / KTD-8): +Every pre-cutover task row must wake OWNED — no silently frozen rows. Because +`task.status` is an OPEN string (not a closed enum), the adoption contract is +derived from a WRITE-SITE CENSUS: the completeness assertion in +legacy-adoption.test.ts greps every task.status write literal in every non-test .ts +source under core/engine/dashboard src (recursive scan, PR #2341 review) and +fails the build if any lacks an adoption row here. So a status added during the +cutover window fails the build instead of mass-parking rows `paused` at upgrade. + +Adoption action per legacy status (KTD-8), for the FOUNDATIONAL targets (U9 scope A): + - resume-graph : clear the legacy triage-owned status so the graph re-enters + cleanly at the owning node (planning → planning node, + needs-replan → plan-replan, plan-review-unavailable → + plan-review retry, queued/triaged → scheduler re-pickup). + - preserve : a live human/terminal gate the graph must NOT disturb + (awaiting-approval, awaiting-user-input, failed, error, + blocked, done, cancelled). Pausing is NOT a status: it is + the boolean `task.paused` field, which no adoption action + touches except the explicit park-paused write — so there + is deliberately no "paused" adoption row. + - clear : a transient in-flight status with no durable meaning post- + restart — clear to null and let normal dispatch resume. + - park-paused : UNMAPPABLE — an unknown status parks `paused` with a + `task:reconcile-legacy-adoption-unmappable` audit for a human. + +The execute-seam (in-progress + live steps) and in-review merge-substate +(merging/merging-pr/merging-fix) adoption rows are marked `resume-graph`. + +FNXC:LegacyAdoption 2026-07-19-05:20 (U9b resolution): +U9 deferred these to U9b "to refine the exact node". U9b's finding: `resume-graph` is the +CORRECT final answer, not a placeholder. Naming an exact re-entry node here would require +resolving the task's workflow IR, which this module deliberately cannot do — it is pure and +storage-agnostic so both consumers (store-open reconcile and the self-healing startup +sweep) can share one decision. Clearing the legacy substate hands the row back to the graph +runner, which resolves its own owning node from the task's actual IR. That is strictly more +correct than a hard-coded node id, which would go stale the moment a workflow is edited — +exactly the drift KTD-3's IR pin exists to catch. Keep them `resume-graph`. +*/ + +/** What store-open adoption does with a legacy task.status value. */ +export type LegacyAdoptionKind = "resume-graph" | "preserve" | "clear" | "park-paused"; + +export interface LegacyAdoptionAction { + kind: LegacyAdoptionKind; + /** Human-facing note (audit metadata; ids/outcomes-only elsewhere). */ + note: string; +} + +/** + * The KTD-8 adoption table: every task.status literal a pre-cutover row can carry + * maps to an adoption action. The census test (legacy-adoption.test.ts) asserts + * this covers every task.status WRITE literal in core/engine — a new one fails the + * build. `null`/`undefined` (no status) needs no row (nothing to adopt). + */ +export const LEGACY_STATUS_ADOPTION: Readonly> = { + // ── Triage plan-review statuses whose writers U3 deleted → graph re-entry ── + "planning": { kind: "resume-graph", note: "re-enter planning node" }, + "needs-replan": { kind: "resume-graph", note: "re-enter plan-replan node" }, + "plan-review-unavailable": { kind: "resume-graph", note: "plan-review retry (leased)" }, + // ── Scheduler / dispatch transient states → re-pickup ───────────────────── + "queued": { kind: "resume-graph", note: "scheduler re-queue" }, + "triaged": { kind: "resume-graph", note: "scheduler re-pickup" }, + // ── Merge substates (execute-seam/merge refinement DEFERRED to U9b) ─────── + "merging": { kind: "resume-graph", note: "resume merge node (U9b refines)" }, + "merging-pr": { kind: "resume-graph", note: "resume merge-pr node (U9b refines)" }, + "merging-fix": { kind: "resume-graph", note: "resume merge-fix node (U9b refines)" }, + // ── Live human / terminal gates — do NOT disturb ────────────────────────── + "awaiting-approval": { kind: "preserve", note: "manual plan approval gate" }, + "awaiting-user-input": { kind: "preserve", note: "awaiting operator input" }, + "awaiting-user-review": { kind: "preserve", note: "awaiting operator review" }, + "awaiting-cli-approval": { kind: "preserve", note: "awaiting CLI operator approval" }, + "failed": { kind: "preserve", note: "terminal failure park" }, + "error": { kind: "preserve", note: "durable error park" }, + "blocked": { kind: "preserve", note: "dependency-blocked" }, + "done": { kind: "preserve", note: "terminal complete" }, + "cancelled": { kind: "preserve", note: "operator-cancelled" }, + // ── Transient in-flight → clear so normal dispatch resumes ──────────────── + "cancelling": { kind: "clear", note: "transient cancel — clear on restart" }, + "stuck-killed": { kind: "resume-graph", note: "stuck-detector kill — clear and re-dispatch" }, +}; + +/** + * Resolve the adoption action for a legacy task.status value. A `null`/empty + * status needs no adoption (returns undefined — nothing to do). An UNKNOWN status + * (no table row) resolves to `park-paused` so it surfaces to a human rather than + * silently freezing or mass-parking every row. + */ +export function resolveLegacyStatusAdoption( + status: string | null | undefined, +): LegacyAdoptionAction | undefined { + if (status === null || status === undefined || status === "") return undefined; + return ( + LEGACY_STATUS_ADOPTION[status] ?? { + kind: "park-paused", + note: `unmappable legacy status '${status}'`, + } + ); +} + +// ── reviewLevel backfill (U9 / R6 follow-through of U8) ──────────────────────── + +import { resolveReviewLevelSteps } from "./review-level-preset.js"; + +/** + * One-time backfill decision for a pre-cutover task carrying a `reviewLevel`. + * NEVER writes both fields: a task that already has `enabledWorkflowSteps` keeps + * it (explicit steps win) and is only warned; a `reviewLevel`-only task gains the + * preset step set derived by the same U8 mapper; a task with neither is a no-op. + */ +export type ReviewLevelBackfillDecision = + | { kind: "backfill"; enabledWorkflowSteps: string[] } + | { kind: "both-set-warn" } + | { kind: "no-op" }; + +export function resolveReviewLevelBackfill( + task: { reviewLevel?: number | null; enabledWorkflowSteps?: string[] | null }, +): ReviewLevelBackfillDecision { + if (typeof task.reviewLevel !== "number") return { kind: "no-op" }; + // Explicit steps ALWAYS win — never overwrite, never set both. Warn only. + if (task.enabledWorkflowSteps !== undefined && task.enabledWorkflowSteps !== null) { + return { kind: "both-set-warn" }; + } + return { kind: "backfill", enabledWorkflowSteps: resolveReviewLevelSteps(task.reviewLevel) }; +} + +// ── The adoption PLAN: one brain, two consumers (U9b) ───────────────────────── + +/* +FNXC:LegacyAdoption 2026-07-19-04:00 (U9b / R10 / KTD-8): +U9 landed the adoption TABLE but shipped no consumer — `resolveLegacyStatusAdoption` was +exported and never called, so no pre-cutover row was actually adopted. This closes that +gap with a single pure planner that both consumers (store-open reconcile and the +self-healing STARTUP sweep) share, so the two can never drift into disagreeing about what +a legacy row means. + +Idempotency rule: only a plan that MUTATES stamps `legacyAdoptedAt`. `preserve` is a +deliberate no-op (a live human/terminal gate), and stamping it would mean mass-writing +every `done` row on first boot after upgrade. Re-evaluating a preserve row each boot is +free and idempotent by construction. +*/ + +/** A concrete, ready-to-apply adoption decision for one legacy row. */ +export interface LegacyAdoptionPlan { + /** `skip` means nothing to do (already adopted, nothing legacy, or a preserve gate). */ + action: LegacyAdoptionKind | "skip"; + /** Why — audit metadata and operator-facing logs. Never row prose. */ + reason: string; + /** The patch to apply through updateTask. Absent for `skip`. */ + patch?: { + status?: null; + paused?: boolean; + pausedReason?: string; + enabledWorkflowSteps?: string[]; + legacyAdoptedAt: string; + }; + /** Run-audit mutation type for this adoption, when it mutates. */ + auditType?: "task:reconcile-legacy-adoption" | "task:reconcile-legacy-adoption-unmappable"; +} + +/** The subset of a task the planner needs. Keeps the planner storage-agnostic. */ +export interface LegacyAdoptionCandidate { + status?: string | null; + reviewLevel?: number | null; + enabledWorkflowSteps?: string[] | null; + legacyAdoptedAt?: string | null; +} + +/** + * Decide how to adopt one pre-cutover row. Pure — the caller performs the write and the + * run-audit emit, so store-open and self-healing apply byte-identical semantics. + * + * @param now ISO timestamp used for the adoption stamp (injected so the decision is + * deterministic and testable). + */ +export function planLegacyAdoption( + task: LegacyAdoptionCandidate, + now: string, +): LegacyAdoptionPlan { + // Already adopted — never re-clear a status a human has since re-set, and never re-park + // a row an operator already un-parked. + if (task.legacyAdoptedAt) { + return { action: "skip", reason: "already-adopted" }; + } + + const statusAction = resolveLegacyStatusAdoption(task.status); + const backfill = resolveReviewLevelBackfill(task); + + // A preserve gate is untouchable, but a reviewLevel backfill is orthogonal metadata and + // is still safe to land on it. + if (statusAction?.kind === "preserve" && backfill.kind !== "backfill") { + return { action: "skip", reason: `preserve: ${statusAction.note}` }; + } + + if (!statusAction && backfill.kind !== "backfill") { + return { + action: "skip", + reason: backfill.kind === "both-set-warn" + ? "review-level-and-steps-both-set (left untouched, warned)" + : "nothing-to-adopt", + }; + } + + const patch: NonNullable = { legacyAdoptedAt: now }; + if (backfill.kind === "backfill") patch.enabledWorkflowSteps = backfill.enabledWorkflowSteps; + + // UNMAPPABLE: surface to a human rather than guessing. The status is deliberately LEFT IN + // PLACE so the operator can see what the row actually carried. + if (statusAction?.kind === "park-paused") { + patch.paused = true; + patch.pausedReason = `legacy-adoption-unmappable: ${task.status}`; + return { + action: "park-paused", + reason: statusAction.note, + patch, + auditType: "task:reconcile-legacy-adoption-unmappable", + }; + } + + // resume-graph / clear both clear the legacy status so the graph re-enters at its owning + // node (resume-graph) or normal dispatch resumes (clear). + if (statusAction?.kind === "resume-graph" || statusAction?.kind === "clear") { + patch.status = null; + return { + action: statusAction.kind, + reason: statusAction.note, + patch, + auditType: "task:reconcile-legacy-adoption", + }; + } + + // Backfill-only (no legacy status, or a preserve gate carrying a reviewLevel). + return { + action: statusAction?.kind ?? "clear", + reason: statusAction ? `${statusAction.note} + reviewLevel backfill` : "reviewLevel backfill", + patch, + auditType: "task:reconcile-legacy-adoption", + }; +} + +/* +FNXC:LegacyAdoption 2026-07-19-04:00 (U9b / KTD-8): +Orphaned `pending` workflow-step results. A pre-cutover crash can leave a step result +`pending` with no live session behind it; the graph will wait on it forever. Clear those, +but ONLY when the caller proves no live session holds the step — a leased/live one is real +work in flight and must survive. Pure: the caller supplies liveness. +*/ +export function resolveOrphanedPendingStepResults( + results: readonly T[] | null | undefined, + isLive: (result: T) => boolean, +): { cleared: T[]; clearedCount: number } { + if (!results || results.length === 0) return { cleared: [], clearedCount: 0 }; + let clearedCount = 0; + const cleared = results.filter((result) => { + if (result.status !== "pending") return true; + if (isLive(result)) return true; + clearedCount++; + return false; + }); + return { cleared, clearedCount }; +} diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index 7c1f5a77e4..0c0e9de497 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -83,6 +83,10 @@ export { getAppliedMigrations, readBaselineMigrationSql, SCHEMA_BASELINE_VERSION, + // FNXC:StaleBinaryGuard 2026-07-19-03:10 (U9b / R10): old-binary write refusal. + StaleBinarySchemaError, + assertBinaryNotOlderThanDatabase, + WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MIGRATION_BOOKKEEPING_TABLE, diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index ac640340a0..e62a391033 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -83,6 +83,12 @@ CREATE TABLE IF NOT EXISTS project.tasks ( task_done_retry_count integer DEFAULT 0, -- FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (nullable ISO timestamp). bulk_completion_refusal_at text, + -- FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 durable per-node-entry IR pin (see migration 0026). + workflow_ir_pin text, + workflow_ir_pin_node_id text, + workflow_ir_pin_column_id text, + -- FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 one-time adoption stamp (see migration 0026). + legacy_adopted_at text, worktree_session_retry_count integer DEFAULT 0, completion_handoff_limbo_recovery_count integer DEFAULT 0, merge_conflict_bounce_count integer DEFAULT 0, diff --git a/packages/core/src/postgres/migrations/0027_workflow_ir_pin_and_legacy_adoption.sql b/packages/core/src/postgres/migrations/0027_workflow_ir_pin_and_legacy_adoption.sql new file mode 100644 index 0000000000..904b99b364 --- /dev/null +++ b/packages/core/src/postgres/migrations/0027_workflow_ir_pin_and_legacy_adoption.sql @@ -0,0 +1,26 @@ +-- FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3): +-- Durable per-node-entry IR pin. `resolveWorkflowIrForTask` is live-per-call, so a +-- workflow edited mid-flight changes the graph under a running task — the largest +-- determinism hole in the flow analysis. A task now persists the IR version/content +-- hash it resolved when ENTERING a node and holds it until that node settles, so +-- restart recovery compares the stored pin against the current IR and takes the +-- drift-park path on mismatch instead of traversing a mutated graph. +-- +-- `workflow_ir_pin_node_id` records WHICH node entry the pin was taken for. Without +-- it a restart cannot tell a stale pin from the current node's pin, and every +-- resumed task would look drifted. +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS workflow_ir_pin text; +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS workflow_ir_pin_node_id text; +-- `workflow_ir_pin_column_id` is the pinned node's column AT ENTRY, so drift detection can +-- flag a column deleted out from under the task even when the node id itself survives. +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS workflow_ir_pin_column_id text; + +-- FNXC:LegacyAdoption 2026-07-19-03:10 (U9b / R10 / KTD-8): +-- One-time adoption stamp. The store-open reconcile and the self-healing startup +-- sweep both resolve legacy `task.status` values through the KTD-8 adoption table; +-- this column records that a row has already been adopted so the sweep is +-- idempotent across restarts (it must never re-clear a status a human has since +-- re-set, and must never re-park a row an operator already un-parked). It is also +-- what makes "zero frozen rows" provable: an un-stamped pre-cutover row is by +-- definition one adoption never reached. +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS legacy_adopted_at text; diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index a85b6e198b..4ee9119a75 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -175,6 +175,14 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ schema?: string; table: s // timestamp column absent from older embedded-PG snapshots, so it must self-heal via // ALTER TABLE ADD COLUMN IF NOT EXISTS on boot (CREATE TABLE IF NOT EXISTS never upgrades). { table: "tasks", column: "bulk_completion_refusal_at", type: "text" }, + // FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 — same self-heal contract as the marker + // above; migration 0026 lands these on upgraded clusters, and boot repairs a snapshot that + // predates them so the first slim TaskStore SELECT cannot crash on a missing column. + { table: "tasks", column: "workflow_ir_pin", type: "text" }, + { table: "tasks", column: "workflow_ir_pin_node_id", type: "text" }, + { table: "tasks", column: "workflow_ir_pin_column_id", type: "text" }, + // FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 one-time adoption stamp. + { table: "tasks", column: "legacy_adopted_at", type: "text" }, // distributed_task_id_state { table: "distributed_task_id_state", column: "prefix", type: "text" }, { table: "distributed_task_id_state", column: "next_sequence", type: "integer" }, diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index ff53b5469e..f113f15dd0 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -1,748 +1,839 @@ -/** - * PostgreSQL schema applier. - * - * FNXC:PostgresSchema 2026-06-24-03:40: - * Applies the fresh Drizzle migration baseline to a PostgreSQL connection - * and records it in a migration bookkeeping table. The baseline migration - * (migrations/0000_initial.sql) is the snapshot of the final SQLite schema - * (SCHEMA_VERSION=128) translated to PostgreSQL — applying it to an empty - * database yields final-schema parity (VAL-SCHEMA-001). - * - * After the baseline lands, plugin-owned tables are materialized via the - * schema-init hook (VAL-SCHEMA-007). The applier calls each registered plugin - * hook so plugins evolve their own tables independently of the core migration. - * - * Migration tracking uses a single-row bookkeeping table in the public schema - * so the applier is idempotent: re-running against an already-migrated database - * is a no-op. The version-gate discipline (the institutional learning that - * fresh-DB tests cannot catch a skipped-on-upgrade migration) is carried - * forward via the applier's explicit baseline marker. - */ - -import { readFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { sql } from "drizzle-orm"; -import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; -import { acquireSchemaMutationLocks } from "./advisory-locks.js"; - -/** The latest PostgreSQL schema version known to this applier. */ -/* -FNXC:GitHubImportTranslate 2026-07-17-23:48: -Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves. - -FNXC:PostgresBigintCounters 2026-07-19-12:00: -SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration. -Per-migration identities above stay fixed; only this latest-version marker moves. -*/ -export const SCHEMA_BASELINE_VERSION = "0026"; -const INITIAL_SCHEMA_VERSION = "0000"; -const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; -const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; -/** - * FNXC:PostgresMigrationIdentity 2026-07-14-01:41: - * Each migration keeps an immutable bookkeeping identity even as SCHEMA_BASELINE_VERSION advances to newer migrations. Upgrade checks and inserts must use this dedicated 0003 identifier so a later latest-version marker cannot make an unrecorded monitor/approval migration look applied. - */ -export const MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION = "0003"; -export const LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION = "0004"; -export const MULTI_PROJECT_CUTOVER_SCHEMA_VERSION = "0005"; -export const PROJECT_OWNERSHIP_SCHEMA_VERSION = "0006"; -export const SQLITE_SCHEMA_PARITY_VERSION = "0007"; -/** - * FNXC:PlannerOversight 2026-07-14-18:49: - * Version 0008 adds project.tasks.session_advisor_enabled for per-task session - * advisor overrides. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. - */ -export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008"; -export const MISSION_FIX_IDEMPOTENCY_VERSION = "0009"; -/* -FNXC:GitHubImportTranslate 2026-07-15-09:30: -Import-translation cache advances to 0010. Migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs. -*/ -export const IMPORT_TRANSLATION_CACHE_VERSION = "0010"; -/** - * FNXC:GitHubImportTranslate 2026-07-16-23:30: - * Existing databases already recorded 0010, so the cache scope correction is - * deliberately a new forward migration rather than a retroactive SQL edit. - */ -export const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION = "0016"; -/* -FNXC:GitHubImportTranslate 2026-07-17-23:48: -0016 aligned future cache writes with the normalized legacy partition, but a -pre-0016 cache row can still carry a historic blank project_id. Migration 0019 -backfills that durable data before a restarted store scopes cache reads to -__legacy_unscoped__, preventing an avoidable re-translation. -*/ -export const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION = "0019"; -/* -FNXC:MultiProjectIsolation 2026-07-15-23:40: -Version 0011 splits the domain "project" field from the RLS partition on the tables -that conflated them: `project_id` stays the trigger/GUC-owned isolation partition, -`owner_project_id` becomes the caller-supplied domain field. Writing domain values -into the partition put parent rows and child rows in different partitions and broke -the composite FKs (SQLSTATE 23503). Keep this identity fixed when -SCHEMA_BASELINE_VERSION advances. -*/ -export const OWNER_PROJECT_ID_SPLIT_VERSION = "0011"; -/* -FNXC:ChatPinned 2026-07-16-12:30: -Version 0012 makes the persisted pin timestamp available on databases that -already applied the baseline before Direct conversations can be pinned. -*/ -export const CHAT_SESSION_PINS_VERSION = "0012"; -/** FNXC:ExecutorToolFailureRetry 2026-07-16-12:00: upgrades existing PostgreSQL task rows before retry-state reads. */ -export const EXECUTOR_TOOL_FAILURE_RETRY_VERSION = "0013"; -/** FNXC:ExecutorEscalation 2026-07-16-21:00: Existing clusters need the durable single-shot latch before executor reads it during post-FN-7996 escalation. */ -export const EXECUTOR_ESCALATION_ATTEMPT_VERSION = "0014"; -/** FNXC:PostgresSchema 2026-07-16-22:00: central global routines follow main's already-landed 0014 migration. */ -export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015"; -/** FNXC:Settings-MergerModel 2026-07-16-12:00: per-task merger lane is an additive upgrade. */ -export const TASK_MERGER_MODEL_LANE_VERSION = "0017"; -/** - * FNXC:Lifecycle 2026-07-16-22:35: - * Version 0018 lands project.tasks.bulk_completion_refusal_at (FN-8141) on - * existing clusters. PR #2260 added the column to the model + 0000 baseline but - * forgot the forward migration, so every pre-#2260 database crashed on its first - * TaskStore SELECT. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. - */ -export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018"; -/** FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: durable project-scoped proposal key/index protects task creation across crash and reclaim races. */ -export const TASK_PROPOSAL_CLAIM_VERSION = "0020"; -/** FNXC:ConfigVersioning 2026-07-18-00:00: existing clusters need immutable configuration history before write paths use it. */ -export const CONFIGURATION_REVISIONS_VERSION = "0021"; -/** FNXC:Ideation 2026-07-30-15:30: Persisted ideation needs its own forward migration because configuration revisions already own 0021. */ -export const IDEATION_SCHEMA_VERSION = "0022"; -/** FNXC:ResearchMissionBridge 2026-07-18-12:00: forward migration stores stable research finding provenance on canonical features. */ -export const RESEARCH_FEATURE_PROVENANCE_VERSION = "0023"; -/** FNXC:TaskVerificationRequest 2026-07-30-00:00: upgrades need the project-scoped chat-to-executor verification queue. */ -export const TASK_VERIFICATION_REQUEST_VERSION = "0024"; -/** FNXC:SymbolLock 2026-07-30-14:10: upgraded projects need the durable lock table and RLS contract before scheduler admission can use it. */ -export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025"; -/** FNXC:PostgresBigintCounters 2026-07-18-21:45: widen overflow-prone counters to bigint before SQLite migration. */ -export const BIGINT_COUNTERS_VERSION = "0026"; - -/** Bookkeeping table for the fresh Drizzle migration history. */ -export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -/* -FNXC:StandaloneExeMigrations 2026-07-17-13:30: -The bun-compiled standalone `fn` binary runs its bundled code from the virtual -/$bunfs/root filesystem, so the historical `join(__dirname, "migrations")` -resolution points at a path that does not exist on disk (bun --compile does not -embed readFile assets) and every DATABASE_URL boot died with -ENOENT /$bunfs/root/migrations/0000_initial.sql. Resolution order: - 1. FUSION_MIGRATIONS_DIR env override — always wins when set (operator escape hatch). - 2. join(__dirname, "migrations") — the npm/tsup and desktop layout; kept first - among the probes so nothing changes for existing installs. - 3. join(dirname(process.execPath), "migrations") — the standalone-exe layout, - where build.ts / the release tarball stage migrations/ next to the binary. -The existsSync probe (not a runtime-detection heuristic) picks between (2) and -(3): inside the compiled binary the module-relative dir simply does not exist, -while for node-based installs it always does, so npm/desktop behavior is untouched. -*/ -function resolveMigrationsDir(): string { - const envDir = process.env.FUSION_MIGRATIONS_DIR; - if (envDir) return envDir; - const moduleDir = join(__dirname, "migrations"); - if (existsSync(join(moduleDir, "0000_initial.sql"))) return moduleDir; - const execDir = join(dirname(process.execPath), "migrations"); - if (existsSync(join(execDir, "0000_initial.sql"))) return execDir; - // Preserve the historical default (and its historical error message) when - // neither location exists — the readFile ENOENT remains the diagnostic. - return moduleDir; -} - -const MIGRATIONS_DIR = resolveMigrationsDir(); -const BASELINE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0000_initial.sql"); -const AUTOMATION_ISOLATION_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0001_automation_project_isolation.sql", -); -const ANALYTICS_ISOLATION_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0002_analytics_project_isolation.sql", -); -const MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0003_monitor_approval_project_isolation.sql", -); -const LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0004_legacy_cutover_preservation.sql", -); -const MULTI_PROJECT_CUTOVER_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0005_multi_project_cutover.sql", -); -const PROJECT_OWNERSHIP_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0006_project_ownership.sql", -); -const SQLITE_SCHEMA_PARITY_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0007_sqlite_schema_parity.sql", -); -const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0008_session_advisor_enabled.sql", -); -const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0009_mission_fix_idempotency.sql", -); -const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0010_import_translation_cache.sql", -); -const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0016_import_translation_cache_scope_fix.sql", -); -const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0019_import_translation_cache_legacy_partition_backfill.sql", -); -const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0011_owner_project_id.sql", -); -const CHAT_SESSION_PINS_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0012_chat_session_pins.sql", -); -const EXECUTOR_TOOL_FAILURE_RETRY_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0013_executor_tool_failure_retry.sql", -); -const EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0014_executor_escalation_attempt.sql", -); -const GLOBAL_ROUTINES_MIGRATION_PATH = join( - MIGRATIONS_DIR, - "0015_global_routines.sql", -); -const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_merger_model_lane.sql"); -const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql"); -const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql"); -const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql"); -const IDEATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0022_ideation.sql"); -const RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0023_research_feature_provenance.sql"); -const TASK_VERIFICATION_REQUEST_MIGRATION_PATH = join(MIGRATIONS_DIR, "0024_task_verification_request.sql"); -const SYMBOL_LOCKS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0025_symbol_locks.sql"); -const BIGINT_COUNTERS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0026_bigint_counters.sql"); - -/** - * Ensure the migration bookkeeping table exists. Lives in the public schema so - * it survives across the three application schemas and is queryable without - * search_path qualification. - */ -async function ensureBookkeepingTable(db: PostgresJsDatabase>): Promise { - await db.execute(sql.raw(` - CREATE TABLE IF NOT EXISTS public.${MIGRATION_BOOKKEEPING_TABLE} ( - version text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - ) - `)); -} - -/** Read the baseline migration SQL from disk. Exported for tests. */ -export async function readBaselineMigrationSql(): Promise { - return readFile(BASELINE_MIGRATION_PATH, "utf8"); -} - -/** Return the set of already-applied migration versions, or empty if none. */ -export async function getAppliedMigrations( - db: PostgresJsDatabase>, -): Promise { - await ensureBookkeepingTable(db); - const rows = (await db.execute( - sql`SELECT version FROM public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} ORDER BY version`, - )) as unknown as Array<{ version: string }>; - return rows.map((row) => row.version); -} - -/** - * Apply the fresh baseline migration to the given connection. - * - * Idempotent: if the baseline version is already recorded, this is a no-op. - * After the baseline lands, all registered plugin schema-init hooks run so - * plugin-owned tables (e.g. roadmap) materialize (VAL-SCHEMA-007). - * - * The baseline SQL is applied as a single batch via postgres.js's file/unsafe - * execution path. It uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT - * EXISTS throughout, so a partial prior apply is safe to resume. - */ -export async function applySchemaBaseline( - db: PostgresJsDatabase>, - options: { pluginHooks?: readonly PluginSchemaInitHook[] } = {}, -): Promise<{ applied: boolean; pluginHooksRun: number }> { - /* - * FNXC:PostgresSchema 2026-07-14-00:05: - * Schema versions are a cluster-wide invariant. Serialize version discovery, - * DDL, and bookkeeping in one transaction so concurrent Fusion processes - * cannot both apply a version or race its primary-key marker. - */ - return db.transaction(async (tx) => { - await acquireSchemaMutationLocks(tx); - await ensureBookkeepingTable(tx); - /* - FNXC:PostgresSchema 2026-07-16-00:55: - FN-8051 requires project, central, and archive to exist before plugin schema-init hooks run. - Hooks run even when migration markers are already recorded and target project tables, so - ensure the namespaces unconditionally inside the advisory-locked transaction rather than - relying on the baseline batch that a marker-present database skips. - */ - await tx.execute(sql.raw(` - CREATE SCHEMA IF NOT EXISTS project; - CREATE SCHEMA IF NOT EXISTS central; - CREATE SCHEMA IF NOT EXISTS archive; - `)); - const applied = await getAppliedMigrations(tx); - const baselineAlreadyApplied = applied.includes(INITIAL_SCHEMA_VERSION); - const automationIsolationAlreadyApplied = applied.includes(AUTOMATION_ISOLATION_SCHEMA_VERSION); - const analyticsIsolationAlreadyApplied = applied.includes(ANALYTICS_ISOLATION_SCHEMA_VERSION); - const monitorApprovalIsolationAlreadyApplied = applied.includes(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION); - const legacyCutoverPreservationAlreadyApplied = applied.includes(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION); - const multiProjectCutoverAlreadyApplied = applied.includes(MULTI_PROJECT_CUTOVER_SCHEMA_VERSION); - const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION); - const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION); - const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); - const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); - const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION); - const importTranslationCacheScopeFixAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); - const importTranslationCacheLegacyPartitionBackfillAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION); - const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION); - const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION); - const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION); - const executorEscalationAttemptAlreadyApplied = applied.includes(EXECUTOR_ESCALATION_ATTEMPT_VERSION); - const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION); - const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION); - const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION); - const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION); - const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION); - const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION); - const researchFeatureProvenanceAlreadyApplied = applied.includes(RESEARCH_FEATURE_PROVENANCE_VERSION); - const taskVerificationRequestAlreadyApplied = applied.includes(TASK_VERIFICATION_REQUEST_VERSION); - const symbolLocksAlreadyApplied = applied.includes(SYMBOL_LOCKS_SCHEMA_VERSION); - const bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION); - let schemaChanged = false; - - if (!baselineAlreadyApplied) { - const baselineSql = await readBaselineMigrationSql(); - // The baseline contains multiple statements including CREATE SCHEMA, CREATE - // TABLE, CREATE INDEX, and seed INSERTs. postgres.js executes a single - // query string as one batch (simple query protocol when unparameterized). - await tx.execute(sql.raw(baselineSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${INITIAL_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - * FNXC:AutomationIsolation 2026-07-13-22:37: - * A database that already recorded the initial PostgreSQL baseline must still receive project-scoped automation storage. Apply this version independently of 0000; ambiguous legacy ownership fails closed before any bound cron runner can silently omit those schedules. - */ - if (!automationIsolationAlreadyApplied) { - const migrationSql = await readFile(AUTOMATION_ISOLATION_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AUTOMATION_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:AnalyticsIsolation 2026-07-14-00:05: - Existing PostgreSQL databases that already recorded 0001 must independently receive analytics project partitions before project-scoped readers and writers start. Keep 0002 versioned so a fresh baseline cannot hide a skipped upgrade path. - */ - if (!analyticsIsolationAlreadyApplied) { - const migrationSql = await readFile(ANALYTICS_ISOLATION_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${ANALYTICS_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:CommandCenterTenantIsolation 2026-07-14-01:04: - Version 0003 supplies durable ownership for monitor and approval analytics. It must run independently after 0002 so databases that already accepted the earlier analytics migration cannot silently skip the remaining tenant partitions. - */ - if (!monitorApprovalIsolationAlreadyApplied) { - const migrationSql = await readFile(MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:PostgresMigrationCompleteness 2026-07-14-09:27: - Apply retired-table preservation independently of 0000 so an older partial migration target can retry without losing board, project-auth, or task-reviewer rows. The DDL is additive and idempotent. - */ - if (!legacyCutoverPreservationAlreadyApplied) { - const migrationSql = await readFile(LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: - Existing targets may already contain one completed project plus a partially copied second project. Apply metadata partitioning and collision-safe revision identity before any retry builds its migration plan. - */ - if (!multiProjectCutoverAlreadyApplied) { - const migrationSql = await readFile(MULTI_PROJECT_CUTOVER_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MULTI_PROJECT_CUTOVER_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - // Run plugin schema-init hooks regardless of whether the baseline was just - // applied or already present — plugin tables must exist on every connection - // the applier touches. The hooks are themselves idempotent (CREATE TABLE IF - // NOT EXISTS), so re-running is safe. - const pluginHooks = options.pluginHooks ?? DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS; - await runPluginSchemaInitHooks(tx, pluginHooks); - - /* - FNXC:ProjectDataIsolation 2026-07-14-12:10: - Run universal ownership once, after plugin hooks, so first application covers core and plugin tables without duplicate DDL. Later boots validate that every newly introduced plugin table declared the same ownership contract instead of rebuilding primary keys, foreign keys, and policies on every startup. - - FNXC:ProjectArchiveIsolation 2026-07-14-14:31: - The steady-state audit includes archive.archived_tasks because archived task IDs are project-local and must retain the same forced-RLS boundary as live task rows. - */ - if (!projectOwnershipAlreadyApplied) { - const projectOwnershipSql = await readFile(PROJECT_OWNERSHIP_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(projectOwnershipSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } else { - const ownershipGaps = (await tx.execute(sql` - SELECT n.nspname || '.' || c.relname AS table_name - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - LEFT JOIN information_schema.columns col - ON col.table_schema = n.nspname - AND col.table_name = c.relname - AND col.column_name = 'project_id' - WHERE (n.nspname = 'project' OR (n.nspname = 'archive' AND c.relname = 'archived_tasks')) - AND c.relkind = 'r' - AND ( - col.column_name IS NULL - OR NOT c.relrowsecurity - OR NOT c.relforcerowsecurity - OR NOT EXISTS ( - SELECT 1 FROM pg_policy p - WHERE p.polrelid = c.oid AND p.polname = 'fusion_project_isolation' - ) - ) - ORDER BY c.relname - `)) as unknown as Array<{ table_name: string }>; - if (ownershipGaps.length > 0) { - throw new Error( - `Project-owned tables are missing required isolation: ${ownershipGaps.map(({ table_name }) => table_name).join(", ")}`, - ); - } - const relationalGaps = (await tx.execute(sql` - SELECT c.conrelid::regclass::text AS object_name, c.conname AS detail - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE (n.nspname = 'project' OR (n.nspname = 'archive' AND t.relname = 'archived_tasks')) - AND c.contype IN ('p', 'u') - AND NOT EXISTS ( - SELECT 1 FROM unnest(c.conkey) key_attnum - JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key_attnum - WHERE a.attname = 'project_id' - ) - UNION ALL - SELECT t.oid::regclass::text, idx.relname - FROM pg_index i - JOIN pg_class t ON t.oid = i.indrelid - JOIN pg_class idx ON idx.oid = i.indexrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE n.nspname = 'project' AND i.indisunique - AND NOT EXISTS ( - SELECT 1 FROM unnest(i.indkey::smallint[]) key_attnum - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = key_attnum - WHERE a.attname = 'project_id' - ) - UNION ALL - SELECT c.conrelid::regclass::text, c.conname - FROM pg_constraint c - JOIN pg_class child ON child.oid = c.conrelid - JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace - JOIN pg_class parent ON parent.oid = c.confrelid - JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace - WHERE c.contype = 'f' AND child_ns.nspname = 'project' AND parent_ns.nspname = 'project' - AND ( - NOT EXISTS ( - SELECT 1 FROM unnest(c.conkey) key_attnum - JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key_attnum - WHERE a.attname = 'project_id' - ) OR NOT EXISTS ( - SELECT 1 FROM unnest(c.confkey) key_attnum - JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = key_attnum - WHERE a.attname = 'project_id' - ) - ) - ORDER BY 1, 2 - `)) as unknown as Array<{ object_name: string; detail: string }>; - if (relationalGaps.length > 0) { - throw new Error( - `Project-owned keys or relationships are globally scoped: ${relationalGaps.map(({ object_name, detail }) => `${object_name}.${detail}`).join(", ")}`, - ); - } - } - - /* - FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: - Apply SQLite schema parity independently of the original baseline and ownership migration. Existing partial cutover targets must gain all late source columns before the idempotent migration retry rebuilds its table plan. - */ - if (!sqliteSchemaParityAlreadyApplied) { - const sqliteSchemaParitySql = await readFile(SQLITE_SCHEMA_PARITY_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(sqliteSchemaParitySql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SQLITE_SCHEMA_PARITY_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:PlannerOversight 2026-07-14-18:49: - Apply session_advisor_enabled independently of 0007 so databases that already - recorded SQLite schema parity still gain the per-task session-advisor column - before TaskStore/Drizzle SELECT paths run on boot. - */ - if (!sessionAdvisorEnabledAlreadyApplied) { - const sessionAdvisorEnabledSql = await readFile(SESSION_ADVISOR_ENABLED_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(sessionAdvisorEnabledSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SESSION_ADVISOR_ENABLED_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:MissionFixIdempotency 2026-07-14-18:55: - Existing PostgreSQL databases receive the validator-run lineage uniqueness invariant independently of earlier schema versions. Duplicate historical rows fail the migration visibly instead of being silently discarded. - - FNXC:PostgresConflictResolution 2026-07-14-20:52: - Main assigned migration 0008 to session-advisor state before the cutover landed, so mission lineage uniqueness advances to 0009. Both migrations must run in order; sharing a bookkeeping version would silently skip one invariant. - */ - if (!missionFixIdempotencyAlreadyApplied) { - const migrationSql = await readFile(MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_FIX_IDEMPOTENCY_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:GitHubImportTranslate 2026-07-15-09:30: - Create the import-translation cache table independently of earlier schema versions so existing databases gain it on boot before any Import Tasks translate/import read runs against it. - */ - if (!importTranslationCacheAlreadyApplied) { - const importTranslationCacheSql = await readFile(IMPORT_TRANSLATION_CACHE_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(importTranslationCacheSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:MultiProjectIsolation 2026-07-15-23:40: - Apply the owner_project_id domain/partition split independently of earlier - schema versions so existing databases gain the domain column (backfilled from - the previously conflated partition value) before any store read/write path - that now targets owner_project_id runs on boot. - */ - if (!ownerProjectIdSplitAlreadyApplied) { - const ownerProjectIdSplitSql = await readFile(OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(ownerProjectIdSplitSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${OWNER_PROJECT_ID_SPLIT_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:ChatPinned 2026-07-16-12:30: - Apply the pin timestamp separately from the baseline so all pre-existing - databases can safely read and write Direct chat pins after this rollout. - */ - if (!chatSessionPinsAlreadyApplied) { - const chatSessionPinsSql = await readFile(CHAT_SESSION_PINS_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(chatSessionPinsSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_PINS_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - if (!executorToolFailureRetryAlreadyApplied) { - const executorToolFailureRetrySql = await readFile(EXECUTOR_TOOL_FAILURE_RETRY_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(executorToolFailureRetrySql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${EXECUTOR_TOOL_FAILURE_RETRY_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - if (!executorEscalationAttemptAlreadyApplied) { - const executorEscalationAttemptSql = await readFile(EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(executorEscalationAttemptSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${EXECUTOR_ESCALATION_ATTEMPT_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - if (!globalRoutinesAlreadyApplied) { - const migrationSql = await readFile(GLOBAL_ROUTINES_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${GLOBAL_ROUTINES_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - if (!taskMergerModelLaneAlreadyApplied) { - const migrationSql = await readFile(TASK_MERGER_MODEL_LANE_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_MERGER_MODEL_LANE_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - /* - FNXC:Lifecycle 2026-07-16-22:35: - FN-8141 bulk-completion-refusal taint marker. PR #2260 added the column to - the model + 0000 baseline but no forward migration, so existing clusters - (baseline marker already present) never gained it and crashed on the first - TaskStore SELECT. Apply it as a forward migration so those clusters recover. - */ - if (!bulkCompletionRefusalAtAlreadyApplied) { - const migrationSql = await readFile(BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${BULK_COMPLETION_REFUSAL_AT_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - if (!taskProposalClaimAlreadyApplied) { - const migrationSql = await readFile(TASK_PROPOSAL_CLAIM_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_PROPOSAL_CLAIM_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - /* - FNXC:GitHubImportTranslate 2026-07-16-23:30: - 0010's marker prevents its corrected fresh-install definition from running - on upgrades. Apply 0016 separately before runtime cache reads so existing - rows, RLS, and unbound compatibility stores share one partition contract. - */ - /* - FNXC:ConfigVersioning 2026-07-18-00:00: - Migrations are explicitly registered rather than discovered. Keep 0021's - bookkeeping check adjacent to its apply block so upgrades cannot silently - omit configuration history while fresh installs appear healthy. - */ - if (!configurationRevisionsAlreadyApplied) { - const migrationSql = await readFile(CONFIGURATION_REVISIONS_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CONFIGURATION_REVISIONS_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - /* - FNXC:Ideation 2026-07-30-15:30: - Register the ideation migration explicitly. New SQL files are never auto-discovered, - and upgrades must receive project-scoped session/candidate tables before the store opens. - */ - if (!ideationAlreadyApplied) { - const migrationSql = await readFile(IDEATION_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IDEATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - if (!researchFeatureProvenanceAlreadyApplied) { - const migrationSql = await readFile(RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${RESEARCH_FEATURE_PROVENANCE_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - if (!importTranslationCacheScopeFixAlreadyApplied) { - const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - if (!taskVerificationRequestAlreadyApplied) { - const migrationSql = await readFile(TASK_VERIFICATION_REQUEST_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_VERIFICATION_REQUEST_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - /* - FNXC:SymbolLock 2026-07-30-14:10: - Migration files are manually registered. Apply 0025 independently so both - fresh and upgraded installations gain forced RLS after 0006 owns its setup. - */ - if (!symbolLocksAlreadyApplied) { - const migrationSql = await readFile(SYMBOL_LOCKS_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SYMBOL_LOCKS_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`); - schemaChanged = true; - } - - if (!importTranslationCacheLegacyPartitionBackfillAlreadyApplied) { - const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(migrationSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - - /* - FNXC:PostgresBigintCounters 2026-07-18-21:45: - Existing embedded-PG clusters that were created before this migration still have - integer columns for unbounded token/usage counters, so the SQLite migrator fails on - values larger than int4. Apply the column type widening after ownership/parity and - record the version so fresh baselines already benefit from the bigint DDL. - */ - if (!bigintCountersAlreadyApplied) { - const bigintCountersSql = await readFile(BIGINT_COUNTERS_MIGRATION_PATH, "utf8"); - await tx.execute(sql.raw(bigintCountersSql)); - await tx.execute( - sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${BIGINT_COUNTERS_VERSION}) ON CONFLICT (version) DO NOTHING`, - ); - schemaChanged = true; - } - return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; - }); -} +/** + * PostgreSQL schema applier. + * + * FNXC:PostgresSchema 2026-06-24-03:40: + * Applies the fresh Drizzle migration baseline to a PostgreSQL connection + * and records it in a migration bookkeeping table. The baseline migration + * (migrations/0000_initial.sql) is the snapshot of the final SQLite schema + * (SCHEMA_VERSION=128) translated to PostgreSQL — applying it to an empty + * database yields final-schema parity (VAL-SCHEMA-001). + * + * After the baseline lands, plugin-owned tables are materialized via the + * schema-init hook (VAL-SCHEMA-007). The applier calls each registered plugin + * hook so plugins evolve their own tables independently of the core migration. + * + * Migration tracking uses a single-row bookkeeping table in the public schema + * so the applier is idempotent: re-running against an already-migrated database + * is a no-op. The version-gate discipline (the institutional learning that + * fresh-DB tests cannot catch a skipped-on-upgrade migration) is carried + * forward via the applier's explicit baseline marker. + */ + +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { sql } from "drizzle-orm"; +import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; +import { acquireSchemaMutationLocks } from "./advisory-locks.js"; + +/** The latest PostgreSQL schema version known to this applier. */ +/* +FNXC:GitHubImportTranslate 2026-07-17-23:48: +Advances to 0019 for the import-translation legacy-partition backfill. Per-migration identities above stay fixed; only this latest-version marker moves. + +FNXC:PostgresBigintCounters 2026-07-19-12:00: +SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration. +Per-migration identities above stay fixed; only this latest-version marker moves. +*/ +export const SCHEMA_BASELINE_VERSION = "0027"; +const INITIAL_SCHEMA_VERSION = "0000"; +const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; +const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; +/** + * FNXC:PostgresMigrationIdentity 2026-07-14-01:41: + * Each migration keeps an immutable bookkeeping identity even as SCHEMA_BASELINE_VERSION advances to newer migrations. Upgrade checks and inserts must use this dedicated 0003 identifier so a later latest-version marker cannot make an unrecorded monitor/approval migration look applied. + */ +export const MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION = "0003"; +export const LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION = "0004"; +export const MULTI_PROJECT_CUTOVER_SCHEMA_VERSION = "0005"; +export const PROJECT_OWNERSHIP_SCHEMA_VERSION = "0006"; +export const SQLITE_SCHEMA_PARITY_VERSION = "0007"; +/** + * FNXC:PlannerOversight 2026-07-14-18:49: + * Version 0008 adds project.tasks.session_advisor_enabled for per-task session + * advisor overrides. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. + */ +export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008"; +export const MISSION_FIX_IDEMPOTENCY_VERSION = "0009"; +/* +FNXC:GitHubImportTranslate 2026-07-15-09:30: +Import-translation cache advances to 0010. Migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs. +*/ +export const IMPORT_TRANSLATION_CACHE_VERSION = "0010"; +/** + * FNXC:GitHubImportTranslate 2026-07-16-23:30: + * Existing databases already recorded 0010, so the cache scope correction is + * deliberately a new forward migration rather than a retroactive SQL edit. + */ +export const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION = "0016"; +/* +FNXC:GitHubImportTranslate 2026-07-17-23:48: +0016 aligned future cache writes with the normalized legacy partition, but a +pre-0016 cache row can still carry a historic blank project_id. Migration 0019 +backfills that durable data before a restarted store scopes cache reads to +__legacy_unscoped__, preventing an avoidable re-translation. +*/ +export const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION = "0019"; +/* +FNXC:MultiProjectIsolation 2026-07-15-23:40: +Version 0011 splits the domain "project" field from the RLS partition on the tables +that conflated them: `project_id` stays the trigger/GUC-owned isolation partition, +`owner_project_id` becomes the caller-supplied domain field. Writing domain values +into the partition put parent rows and child rows in different partitions and broke +the composite FKs (SQLSTATE 23503). Keep this identity fixed when +SCHEMA_BASELINE_VERSION advances. +*/ +export const OWNER_PROJECT_ID_SPLIT_VERSION = "0011"; +/* +FNXC:ChatPinned 2026-07-16-12:30: +Version 0012 makes the persisted pin timestamp available on databases that +already applied the baseline before Direct conversations can be pinned. +*/ +export const CHAT_SESSION_PINS_VERSION = "0012"; +/** FNXC:ExecutorToolFailureRetry 2026-07-16-12:00: upgrades existing PostgreSQL task rows before retry-state reads. */ +export const EXECUTOR_TOOL_FAILURE_RETRY_VERSION = "0013"; +/** FNXC:ExecutorEscalation 2026-07-16-21:00: Existing clusters need the durable single-shot latch before executor reads it during post-FN-7996 escalation. */ +export const EXECUTOR_ESCALATION_ATTEMPT_VERSION = "0014"; +/** FNXC:PostgresSchema 2026-07-16-22:00: central global routines follow main's already-landed 0014 migration. */ +export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015"; +/** FNXC:Settings-MergerModel 2026-07-16-12:00: per-task merger lane is an additive upgrade. */ +export const TASK_MERGER_MODEL_LANE_VERSION = "0017"; +/** + * FNXC:Lifecycle 2026-07-16-22:35: + * Version 0018 lands project.tasks.bulk_completion_refusal_at (FN-8141) on + * existing clusters. PR #2260 added the column to the model + 0000 baseline but + * forgot the forward migration, so every pre-#2260 database crashed on its first + * TaskStore SELECT. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. + */ +export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018"; +/** FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: durable project-scoped proposal key/index protects task creation across crash and reclaim races. */ +export const TASK_PROPOSAL_CLAIM_VERSION = "0020"; +/** FNXC:ConfigVersioning 2026-07-18-00:00: existing clusters need immutable configuration history before write paths use it. */ +export const CONFIGURATION_REVISIONS_VERSION = "0021"; +/** FNXC:Ideation 2026-07-30-15:30: Persisted ideation needs its own forward migration because configuration revisions already own 0021. */ +export const IDEATION_SCHEMA_VERSION = "0022"; +/** FNXC:ResearchMissionBridge 2026-07-18-12:00: forward migration stores stable research finding provenance on canonical features. */ +export const RESEARCH_FEATURE_PROVENANCE_VERSION = "0023"; +/** FNXC:TaskVerificationRequest 2026-07-30-00:00: upgrades need the project-scoped chat-to-executor verification queue. */ +export const TASK_VERIFICATION_REQUEST_VERSION = "0024"; +/** FNXC:SymbolLock 2026-07-30-14:10: upgraded projects need the durable lock table and RLS contract before scheduler admission can use it. */ +export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025"; +/** FNXC:PostgresBigintCounters 2026-07-18-21:45: widen overflow-prone counters to bigint before SQLite migration. */ +export const BIGINT_COUNTERS_VERSION = "0026"; + +/** + * Thrown when the database was migrated by a NEWER Fusion binary than the one now + * opening it. Carries the offending versions so the operator sees what to upgrade. + */ +export class StaleBinarySchemaError extends Error { + constructor( + readonly databaseVersion: string, + readonly binaryVersion: string, + ) { + super( + `This Fusion binary is older than the database it opened: the database has schema ` + + `migration ${databaseVersion} applied, but this binary only knows up to ` + + `${binaryVersion}. Refusing to open so an old binary cannot write rows the newer ` + + `schema's invariants depend on. Upgrade Fusion (e.g. \`brew upgrade fusion\`) and retry.`, + ); + this.name = "StaleBinarySchemaError"; + } +} + +/* +FNXC:StaleBinaryGuard 2026-07-19-03:10 (U9b / R10): +Old-binary write refusal. Migration bookkeeping is forward-only, so a database carrying a +version ABOVE this binary's SCHEMA_BASELINE_VERSION was migrated by a newer Fusion. Letting +the old binary proceed writes rows using the previous schema's assumptions (the observed +stale-Homebrew-binary failure mode). Compared numerically, not lexically; unparseable +identifiers are ignored so a plugin marker cannot brick every open. + +FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): +The ignore-unparseable rule is a load-bearing coupling, not just plugin defense: +LEGACY_ADOPTION_DRAINED_MARKER (below) is a deliberately NON-NUMERIC bookkeeping row that +`adoptLegacyTaskRowsOnOpen` (task-store/lifecycle-ops.ts) writes into +fusion_schema_migrations after a fully-drained adoption sweep. This guard MUST keep +skipping non-numeric versions, or the marker would present as a "newer database" and +brick every open. +*/ +export function assertBinaryNotOlderThanDatabase(applied: readonly string[]): void { + const binaryVersion = Number(SCHEMA_BASELINE_VERSION); + if (!Number.isFinite(binaryVersion)) return; + let highest = -Infinity; + let highestRaw = ""; + for (const version of applied) { + const parsed = Number(version); + if (!Number.isFinite(parsed)) continue; + if (parsed > highest) { + highest = parsed; + highestRaw = version; + } + } + if (highest > binaryVersion) { + throw new StaleBinarySchemaError(highestRaw, SCHEMA_BASELINE_VERSION); + } +} + +/** Bookkeeping table for the fresh Drizzle migration history. */ +export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; + +/* +FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): +Durable "legacy adoption fully drained" marker row in fusion_schema_migrations. +Written by the store-open adoption sweep after a full paginated drain in which no +row produced a mutating adoption plan; its presence lets subsequent opens skip the +whole-active-census scan (which would otherwise run on every open forever). +Deliberately NON-NUMERIC so assertBinaryNotOlderThanDatabase ignores it by design +(see that guard's FNXC note — the two sites are coupled). +*/ +export const LEGACY_ADOPTION_DRAINED_MARKER = "legacy-adoption-drained"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/* +FNXC:StandaloneExeMigrations 2026-07-17-13:30: +The bun-compiled standalone `fn` binary runs its bundled code from the virtual +/$bunfs/root filesystem, so the historical `join(__dirname, "migrations")` +resolution points at a path that does not exist on disk (bun --compile does not +embed readFile assets) and every DATABASE_URL boot died with +ENOENT /$bunfs/root/migrations/0000_initial.sql. Resolution order: + 1. FUSION_MIGRATIONS_DIR env override — always wins when set (operator escape hatch). + 2. join(__dirname, "migrations") — the npm/tsup and desktop layout; kept first + among the probes so nothing changes for existing installs. + 3. join(dirname(process.execPath), "migrations") — the standalone-exe layout, + where build.ts / the release tarball stage migrations/ next to the binary. +The existsSync probe (not a runtime-detection heuristic) picks between (2) and +(3): inside the compiled binary the module-relative dir simply does not exist, +while for node-based installs it always does, so npm/desktop behavior is untouched. +*/ +function resolveMigrationsDir(): string { + const envDir = process.env.FUSION_MIGRATIONS_DIR; + if (envDir) return envDir; + const moduleDir = join(__dirname, "migrations"); + if (existsSync(join(moduleDir, "0000_initial.sql"))) return moduleDir; + const execDir = join(dirname(process.execPath), "migrations"); + if (existsSync(join(execDir, "0000_initial.sql"))) return execDir; + // Preserve the historical default (and its historical error message) when + // neither location exists — the readFile ENOENT remains the diagnostic. + return moduleDir; +} + +const MIGRATIONS_DIR = resolveMigrationsDir(); +const BASELINE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0000_initial.sql"); +const AUTOMATION_ISOLATION_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0001_automation_project_isolation.sql", +); +const ANALYTICS_ISOLATION_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0002_analytics_project_isolation.sql", +); +const MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0003_monitor_approval_project_isolation.sql", +); +const LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0004_legacy_cutover_preservation.sql", +); +const MULTI_PROJECT_CUTOVER_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0005_multi_project_cutover.sql", +); +const PROJECT_OWNERSHIP_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0006_project_ownership.sql", +); +const SQLITE_SCHEMA_PARITY_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0007_sqlite_schema_parity.sql", +); +const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0008_session_advisor_enabled.sql", +); +const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0009_mission_fix_idempotency.sql", +); +const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0010_import_translation_cache.sql", +); +const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0016_import_translation_cache_scope_fix.sql", +); +const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0019_import_translation_cache_legacy_partition_backfill.sql", +); +const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0011_owner_project_id.sql", +); +const CHAT_SESSION_PINS_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0012_chat_session_pins.sql", +); +const EXECUTOR_TOOL_FAILURE_RETRY_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0013_executor_tool_failure_retry.sql", +); +const EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0014_executor_escalation_attempt.sql", +); +const GLOBAL_ROUTINES_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0015_global_routines.sql", +); +const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_merger_model_lane.sql"); +const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql"); +const TASK_PROPOSAL_CLAIM_MIGRATION_PATH = join(MIGRATIONS_DIR, "0020_task_proposal_claim.sql"); +const CONFIGURATION_REVISIONS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0021_configuration_revisions.sql"); +const IDEATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0022_ideation.sql"); +const RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0023_research_feature_provenance.sql"); +const TASK_VERIFICATION_REQUEST_MIGRATION_PATH = join(MIGRATIONS_DIR, "0024_task_verification_request.sql"); +const SYMBOL_LOCKS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0025_symbol_locks.sql"); +const BIGINT_COUNTERS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0026_bigint_counters.sql"); +/** + * FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3 + KTD-8; renumbered 0026->0027 after + * main claimed 0026 for bigint counters): + * Durable workflow IR pin (+ its node/column entry) and the one-time legacy-adoption + * stamp. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. + */ +export const WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION = "0027"; +const WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH = join( + MIGRATIONS_DIR, + "0027_workflow_ir_pin_and_legacy_adoption.sql", +); + +/** + * Ensure the migration bookkeeping table exists. Lives in the public schema so + * it survives across the three application schemas and is queryable without + * search_path qualification. + */ +async function ensureBookkeepingTable(db: PostgresJsDatabase>): Promise { + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS public.${MIGRATION_BOOKKEEPING_TABLE} ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `)); +} + +/** Read the baseline migration SQL from disk. Exported for tests. */ +export async function readBaselineMigrationSql(): Promise { + return readFile(BASELINE_MIGRATION_PATH, "utf8"); +} + +/** Return the set of already-applied migration versions, or empty if none. */ +export async function getAppliedMigrations( + db: PostgresJsDatabase>, +): Promise { + await ensureBookkeepingTable(db); + const rows = (await db.execute( + sql`SELECT version FROM public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} ORDER BY version`, + )) as unknown as Array<{ version: string }>; + return rows.map((row) => row.version); +} + +/** + * Apply the fresh baseline migration to the given connection. + * + * Idempotent: if the baseline version is already recorded, this is a no-op. + * After the baseline lands, all registered plugin schema-init hooks run so + * plugin-owned tables (e.g. roadmap) materialize (VAL-SCHEMA-007). + * + * The baseline SQL is applied as a single batch via postgres.js's file/unsafe + * execution path. It uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT + * EXISTS throughout, so a partial prior apply is safe to resume. + */ +export async function applySchemaBaseline( + db: PostgresJsDatabase>, + options: { pluginHooks?: readonly PluginSchemaInitHook[] } = {}, +): Promise<{ applied: boolean; pluginHooksRun: number }> { + /* + * FNXC:PostgresSchema 2026-07-14-00:05: + * Schema versions are a cluster-wide invariant. Serialize version discovery, + * DDL, and bookkeeping in one transaction so concurrent Fusion processes + * cannot both apply a version or race its primary-key marker. + */ + return db.transaction(async (tx) => { + await acquireSchemaMutationLocks(tx); + await ensureBookkeepingTable(tx); + /* + FNXC:PostgresSchema 2026-07-16-00:55: + FN-8051 requires project, central, and archive to exist before plugin schema-init hooks run. + Hooks run even when migration markers are already recorded and target project tables, so + ensure the namespaces unconditionally inside the advisory-locked transaction rather than + relying on the baseline batch that a marker-present database skips. + */ + await tx.execute(sql.raw(` + CREATE SCHEMA IF NOT EXISTS project; + CREATE SCHEMA IF NOT EXISTS central; + CREATE SCHEMA IF NOT EXISTS archive; + `)); + const applied = await getAppliedMigrations(tx); + const baselineAlreadyApplied = applied.includes(INITIAL_SCHEMA_VERSION); + const automationIsolationAlreadyApplied = applied.includes(AUTOMATION_ISOLATION_SCHEMA_VERSION); + const analyticsIsolationAlreadyApplied = applied.includes(ANALYTICS_ISOLATION_SCHEMA_VERSION); + const monitorApprovalIsolationAlreadyApplied = applied.includes(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION); + const legacyCutoverPreservationAlreadyApplied = applied.includes(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION); + const multiProjectCutoverAlreadyApplied = applied.includes(MULTI_PROJECT_CUTOVER_SCHEMA_VERSION); + const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION); + const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION); + const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); + const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); + const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION); + const importTranslationCacheScopeFixAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION); + const importTranslationCacheLegacyPartitionBackfillAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION); + const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION); + const chatSessionPinsAlreadyApplied = applied.includes(CHAT_SESSION_PINS_VERSION); + const executorToolFailureRetryAlreadyApplied = applied.includes(EXECUTOR_TOOL_FAILURE_RETRY_VERSION); + const executorEscalationAttemptAlreadyApplied = applied.includes(EXECUTOR_ESCALATION_ATTEMPT_VERSION); + const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION); + const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION); + const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION); + const taskProposalClaimAlreadyApplied = applied.includes(TASK_PROPOSAL_CLAIM_VERSION); + const configurationRevisionsAlreadyApplied = applied.includes(CONFIGURATION_REVISIONS_VERSION); + const ideationAlreadyApplied = applied.includes(IDEATION_SCHEMA_VERSION); + const researchFeatureProvenanceAlreadyApplied = applied.includes(RESEARCH_FEATURE_PROVENANCE_VERSION); + const taskVerificationRequestAlreadyApplied = applied.includes(TASK_VERIFICATION_REQUEST_VERSION); + const symbolLocksAlreadyApplied = applied.includes(SYMBOL_LOCKS_SCHEMA_VERSION); + const bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION); + const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION); + assertBinaryNotOlderThanDatabase(applied); + let schemaChanged = false; + + if (!baselineAlreadyApplied) { + const baselineSql = await readBaselineMigrationSql(); + // The baseline contains multiple statements including CREATE SCHEMA, CREATE + // TABLE, CREATE INDEX, and seed INSERTs. postgres.js executes a single + // query string as one batch (simple query protocol when unparameterized). + await tx.execute(sql.raw(baselineSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${INITIAL_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + * FNXC:AutomationIsolation 2026-07-13-22:37: + * A database that already recorded the initial PostgreSQL baseline must still receive project-scoped automation storage. Apply this version independently of 0000; ambiguous legacy ownership fails closed before any bound cron runner can silently omit those schedules. + */ + if (!automationIsolationAlreadyApplied) { + const migrationSql = await readFile(AUTOMATION_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AUTOMATION_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:AnalyticsIsolation 2026-07-14-00:05: + Existing PostgreSQL databases that already recorded 0001 must independently receive analytics project partitions before project-scoped readers and writers start. Keep 0002 versioned so a fresh baseline cannot hide a skipped upgrade path. + */ + if (!analyticsIsolationAlreadyApplied) { + const migrationSql = await readFile(ANALYTICS_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${ANALYTICS_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:CommandCenterTenantIsolation 2026-07-14-01:04: + Version 0003 supplies durable ownership for monitor and approval analytics. It must run independently after 0002 so databases that already accepted the earlier analytics migration cannot silently skip the remaining tenant partitions. + */ + if (!monitorApprovalIsolationAlreadyApplied) { + const migrationSql = await readFile(MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:PostgresMigrationCompleteness 2026-07-14-09:27: + Apply retired-table preservation independently of 0000 so an older partial migration target can retry without losing board, project-auth, or task-reviewer rows. The DDL is additive and idempotent. + */ + if (!legacyCutoverPreservationAlreadyApplied) { + const migrationSql = await readFile(LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: + Existing targets may already contain one completed project plus a partially copied second project. Apply metadata partitioning and collision-safe revision identity before any retry builds its migration plan. + */ + if (!multiProjectCutoverAlreadyApplied) { + const migrationSql = await readFile(MULTI_PROJECT_CUTOVER_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MULTI_PROJECT_CUTOVER_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + // Run plugin schema-init hooks regardless of whether the baseline was just + // applied or already present — plugin tables must exist on every connection + // the applier touches. The hooks are themselves idempotent (CREATE TABLE IF + // NOT EXISTS), so re-running is safe. + const pluginHooks = options.pluginHooks ?? DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS; + await runPluginSchemaInitHooks(tx, pluginHooks); + + /* + FNXC:ProjectDataIsolation 2026-07-14-12:10: + Run universal ownership once, after plugin hooks, so first application covers core and plugin tables without duplicate DDL. Later boots validate that every newly introduced plugin table declared the same ownership contract instead of rebuilding primary keys, foreign keys, and policies on every startup. + + FNXC:ProjectArchiveIsolation 2026-07-14-14:31: + The steady-state audit includes archive.archived_tasks because archived task IDs are project-local and must retain the same forced-RLS boundary as live task rows. + */ + if (!projectOwnershipAlreadyApplied) { + const projectOwnershipSql = await readFile(PROJECT_OWNERSHIP_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(projectOwnershipSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } else { + const ownershipGaps = (await tx.execute(sql` + SELECT n.nspname || '.' || c.relname AS table_name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN information_schema.columns col + ON col.table_schema = n.nspname + AND col.table_name = c.relname + AND col.column_name = 'project_id' + WHERE (n.nspname = 'project' OR (n.nspname = 'archive' AND c.relname = 'archived_tasks')) + AND c.relkind = 'r' + AND ( + col.column_name IS NULL + OR NOT c.relrowsecurity + OR NOT c.relforcerowsecurity + OR NOT EXISTS ( + SELECT 1 FROM pg_policy p + WHERE p.polrelid = c.oid AND p.polname = 'fusion_project_isolation' + ) + ) + ORDER BY c.relname + `)) as unknown as Array<{ table_name: string }>; + if (ownershipGaps.length > 0) { + throw new Error( + `Project-owned tables are missing required isolation: ${ownershipGaps.map(({ table_name }) => table_name).join(", ")}`, + ); + } + const relationalGaps = (await tx.execute(sql` + SELECT c.conrelid::regclass::text AS object_name, c.conname AS detail + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE (n.nspname = 'project' OR (n.nspname = 'archive' AND t.relname = 'archived_tasks')) + AND c.contype IN ('p', 'u') + AND NOT EXISTS ( + SELECT 1 FROM unnest(c.conkey) key_attnum + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key_attnum + WHERE a.attname = 'project_id' + ) + UNION ALL + SELECT t.oid::regclass::text, idx.relname + FROM pg_index i + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_class idx ON idx.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'project' AND i.indisunique + AND NOT EXISTS ( + SELECT 1 FROM unnest(i.indkey::smallint[]) key_attnum + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = key_attnum + WHERE a.attname = 'project_id' + ) + UNION ALL + SELECT c.conrelid::regclass::text, c.conname + FROM pg_constraint c + JOIN pg_class child ON child.oid = c.conrelid + JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace + JOIN pg_class parent ON parent.oid = c.confrelid + JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + WHERE c.contype = 'f' AND child_ns.nspname = 'project' AND parent_ns.nspname = 'project' + AND ( + NOT EXISTS ( + SELECT 1 FROM unnest(c.conkey) key_attnum + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key_attnum + WHERE a.attname = 'project_id' + ) OR NOT EXISTS ( + SELECT 1 FROM unnest(c.confkey) key_attnum + JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = key_attnum + WHERE a.attname = 'project_id' + ) + ) + ORDER BY 1, 2 + `)) as unknown as Array<{ object_name: string; detail: string }>; + if (relationalGaps.length > 0) { + throw new Error( + `Project-owned keys or relationships are globally scoped: ${relationalGaps.map(({ object_name, detail }) => `${object_name}.${detail}`).join(", ")}`, + ); + } + } + + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: + Apply SQLite schema parity independently of the original baseline and ownership migration. Existing partial cutover targets must gain all late source columns before the idempotent migration retry rebuilds its table plan. + */ + if (!sqliteSchemaParityAlreadyApplied) { + const sqliteSchemaParitySql = await readFile(SQLITE_SCHEMA_PARITY_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(sqliteSchemaParitySql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SQLITE_SCHEMA_PARITY_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:PlannerOversight 2026-07-14-18:49: + Apply session_advisor_enabled independently of 0007 so databases that already + recorded SQLite schema parity still gain the per-task session-advisor column + before TaskStore/Drizzle SELECT paths run on boot. + */ + if (!sessionAdvisorEnabledAlreadyApplied) { + const sessionAdvisorEnabledSql = await readFile(SESSION_ADVISOR_ENABLED_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(sessionAdvisorEnabledSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SESSION_ADVISOR_ENABLED_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:MissionFixIdempotency 2026-07-14-18:55: + Existing PostgreSQL databases receive the validator-run lineage uniqueness invariant independently of earlier schema versions. Duplicate historical rows fail the migration visibly instead of being silently discarded. + + FNXC:PostgresConflictResolution 2026-07-14-20:52: + Main assigned migration 0008 to session-advisor state before the cutover landed, so mission lineage uniqueness advances to 0009. Both migrations must run in order; sharing a bookkeeping version would silently skip one invariant. + */ + if (!missionFixIdempotencyAlreadyApplied) { + const migrationSql = await readFile(MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_FIX_IDEMPOTENCY_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:GitHubImportTranslate 2026-07-15-09:30: + Create the import-translation cache table independently of earlier schema versions so existing databases gain it on boot before any Import Tasks translate/import read runs against it. + */ + if (!importTranslationCacheAlreadyApplied) { + const importTranslationCacheSql = await readFile(IMPORT_TRANSLATION_CACHE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(importTranslationCacheSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:MultiProjectIsolation 2026-07-15-23:40: + Apply the owner_project_id domain/partition split independently of earlier + schema versions so existing databases gain the domain column (backfilled from + the previously conflated partition value) before any store read/write path + that now targets owner_project_id runs on boot. + */ + if (!ownerProjectIdSplitAlreadyApplied) { + const ownerProjectIdSplitSql = await readFile(OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(ownerProjectIdSplitSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${OWNER_PROJECT_ID_SPLIT_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:ChatPinned 2026-07-16-12:30: + Apply the pin timestamp separately from the baseline so all pre-existing + databases can safely read and write Direct chat pins after this rollout. + */ + if (!chatSessionPinsAlreadyApplied) { + const chatSessionPinsSql = await readFile(CHAT_SESSION_PINS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(chatSessionPinsSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_PINS_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + if (!executorToolFailureRetryAlreadyApplied) { + const executorToolFailureRetrySql = await readFile(EXECUTOR_TOOL_FAILURE_RETRY_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(executorToolFailureRetrySql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${EXECUTOR_TOOL_FAILURE_RETRY_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + if (!executorEscalationAttemptAlreadyApplied) { + const executorEscalationAttemptSql = await readFile(EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(executorEscalationAttemptSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${EXECUTOR_ESCALATION_ATTEMPT_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + if (!globalRoutinesAlreadyApplied) { + const migrationSql = await readFile(GLOBAL_ROUTINES_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${GLOBAL_ROUTINES_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + if (!taskMergerModelLaneAlreadyApplied) { + const migrationSql = await readFile(TASK_MERGER_MODEL_LANE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_MERGER_MODEL_LANE_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + /* + FNXC:Lifecycle 2026-07-16-22:35: + FN-8141 bulk-completion-refusal taint marker. PR #2260 added the column to + the model + 0000 baseline but no forward migration, so existing clusters + (baseline marker already present) never gained it and crashed on the first + TaskStore SELECT. Apply it as a forward migration so those clusters recover. + */ + if (!bulkCompletionRefusalAtAlreadyApplied) { + const migrationSql = await readFile(BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${BULK_COMPLETION_REFUSAL_AT_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + if (!taskProposalClaimAlreadyApplied) { + const migrationSql = await readFile(TASK_PROPOSAL_CLAIM_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_PROPOSAL_CLAIM_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + /* + FNXC:GitHubImportTranslate 2026-07-16-23:30: + 0010's marker prevents its corrected fresh-install definition from running + on upgrades. Apply 0016 separately before runtime cache reads so existing + rows, RLS, and unbound compatibility stores share one partition contract. + */ + /* + FNXC:ConfigVersioning 2026-07-18-00:00: + Migrations are explicitly registered rather than discovered. Keep 0021's + bookkeeping check adjacent to its apply block so upgrades cannot silently + omit configuration history while fresh installs appear healthy. + */ + if (!configurationRevisionsAlreadyApplied) { + const migrationSql = await readFile(CONFIGURATION_REVISIONS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CONFIGURATION_REVISIONS_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + /* + FNXC:Ideation 2026-07-30-15:30: + Register the ideation migration explicitly. New SQL files are never auto-discovered, + and upgrades must receive project-scoped session/candidate tables before the store opens. + */ + if (!ideationAlreadyApplied) { + const migrationSql = await readFile(IDEATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IDEATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + if (!researchFeatureProvenanceAlreadyApplied) { + const migrationSql = await readFile(RESEARCH_FEATURE_PROVENANCE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${RESEARCH_FEATURE_PROVENANCE_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + if (!importTranslationCacheScopeFixAlreadyApplied) { + const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + if (!taskVerificationRequestAlreadyApplied) { + const migrationSql = await readFile(TASK_VERIFICATION_REQUEST_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${TASK_VERIFICATION_REQUEST_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + /* + FNXC:SymbolLock 2026-07-30-14:10: + Migration files are manually registered. Apply 0025 independently so both + fresh and upgraded installations gain forced RLS after 0006 owns its setup. + */ + if (!symbolLocksAlreadyApplied) { + const migrationSql = await readFile(SYMBOL_LOCKS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SYMBOL_LOCKS_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + + if (!importTranslationCacheLegacyPartitionBackfillAlreadyApplied) { + const migrationSql = await readFile(IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + + /* + FNXC:PostgresBigintCounters 2026-07-18-21:45: + Existing embedded-PG clusters that were created before this migration still have + integer columns for unbounded token/usage counters, so the SQLite migrator fails on + values larger than int4. Apply the column type widening after ownership/parity and + record the version so fresh baselines already benefit from the bigint DDL. + */ + if (!bigintCountersAlreadyApplied) { + const bigintCountersSql = await readFile(BIGINT_COUNTERS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(bigintCountersSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${BIGINT_COUNTERS_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + /* + FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3 + KTD-8): + Migration files are manually registered — a .sql that is not wired through a version + constant AND an apply block here silently never runs. Apply 0027 independently of the + baseline so databases that already recorded 0000 gain the IR-pin and adoption columns; + without the forward migration those clusters crash on the first slim TaskStore SELECT. + */ + if (!workflowIrPinAndLegacyAdoptionAlreadyApplied) { + const migrationSql = await readFile(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_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 d2c4eec24c..ed9122f0d4 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -116,6 +116,12 @@ export const tasks = projectSchema.table("tasks", { taskDoneRetryCount: integer("task_done_retry_count").default(0), // FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (nullable ISO timestamp). bulkCompletionRefusalAt: text("bulk_completion_refusal_at"), + // FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 durable per-node-entry IR pin + the node it was taken for. + workflowIrPin: text("workflow_ir_pin"), + workflowIrPinNodeId: text("workflow_ir_pin_node_id"), + workflowIrPinColumnId: text("workflow_ir_pin_column_id"), + // FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 one-time legacy-adoption stamp. + legacyAdoptedAt: text("legacy_adopted_at"), worktreeSessionRetryCount: integer("worktree_session_retry_count").default(0), completionHandoffLimboRecoveryCount: integer("completion_handoff_limbo_recovery_count").default(0), mergeConflictBounceCount: integer("merge_conflict_bounce_count").default(0), diff --git a/packages/core/src/review-level-preset.ts b/packages/core/src/review-level-preset.ts new file mode 100644 index 0000000000..5293fe0899 --- /dev/null +++ b/packages/core/src/review-level-preset.ts @@ -0,0 +1,57 @@ +/* +FNXC:ReviewLevelPreset 2026-07-19-10:00 (U8 / R6 / KTD-11): +`reviewLevel` is a CREATION-TIME preset over `enabledWorkflowSteps`, not a runtime +signal. At task creation, when the caller did NOT provide an explicit +`enabledWorkflowSteps`, the numeric level maps to the optional-group ids to enable: + + 0 → (none) + 1 → code-review + 2 → plan-review + code-review + 3 → plan-review + browser-verification + code-review + +The derived ids flow through the SAME optional-group id pass-through the creation +paths already use (`resolveEnabledWorkflowSteps` + `optionalGroupIdSet`, KTD-11), so +a preset id that collides with a legacy `WORKFLOW_STEP_TEMPLATES` entry stays +identity-stable through store create + update. An explicit `enabledWorkflowSteps` +(including an explicit empty `[]` opt-out) ALWAYS wins — the preset never overrides +operator intent. Post-creation `reviewLevel` mutation is a no-op (create-only). +*/ + +import { PLAN_REVIEW_GROUP_ID } from "./builtin-plan-review-group.js"; +import { CODE_REVIEW_GROUP_ID } from "./builtin-code-review-group.js"; +import { BROWSER_VERIFICATION_GROUP_ID } from "./builtin-browser-verification-group.js"; + +/** + * Map a numeric review level to the optional-group ids it enables. Unknown / + * out-of-range levels map to the empty set (no optional groups) so a stray value + * can never silently enable a gate. + */ +export function resolveReviewLevelSteps(level: number): string[] { + switch (level) { + case 1: + return [CODE_REVIEW_GROUP_ID]; + case 2: + return [PLAN_REVIEW_GROUP_ID, CODE_REVIEW_GROUP_ID]; + case 3: + return [PLAN_REVIEW_GROUP_ID, BROWSER_VERIFICATION_GROUP_ID, CODE_REVIEW_GROUP_ID]; + case 0: + default: + return []; + } +} + +/** + * Normalize a task-create input by applying the reviewLevel preset to + * `enabledWorkflowSteps` when — and only when — the caller left + * `enabledWorkflowSteps` unset and provided a numeric `reviewLevel`. Returns the + * input unchanged when an explicit `enabledWorkflowSteps` is present (explicit + * wins, including an explicit empty array) or no `reviewLevel` is set. Never + * mutates the argument. + */ +export function applyReviewLevelPreset< + T extends { reviewLevel?: number; enabledWorkflowSteps?: string[] }, +>(input: T): T { + if (input.enabledWorkflowSteps !== undefined) return input; // explicit wins + if (typeof input.reviewLevel !== "number") return input; + return { ...input, enabledWorkflowSteps: resolveReviewLevelSteps(input.reviewLevel) }; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 382c4ea145..d11c7d60e1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1212,7 +1212,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | 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; 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | 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; 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; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { return updateTaskImpl(this, id, updates, runContext); } diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index fd26ba0a57..15b51be623 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -238,9 +238,20 @@ export const TASK_DONE_BYPASS_BLOCKER_MESSAGE = */ export function getTaskMergeBlocker( task: Pick, - options: { manual?: boolean } = {}, + options: { manual?: boolean; skipColumnIdentityCheck?: boolean } = {}, ): string | undefined { - if (task.column !== "in-review") { + /* + FNXC:WorkflowTransitionPolicy 2026-07-19-13:30 (PR #2341 review): + `skipColumnIdentityCheck` exists for callers that have ALREADY proven review-lane + identity by a stronger means than the literal column id — the KTD-5 transition + validator resolves the source column's `merge-blocker` trait flag from the workflow + IR, so a custom workflow's review lane can carry any column id. Those callers used + to spoof `{ ...task, column: "in-review" }`, which would silently misapply any + future column-dependent logic added here; the explicit option keeps the content + checks (paused / blocking status / incomplete steps / pre-merge step results) as + the sole deciders without lying about the task's actual column. + */ + if (!options.skipColumnIdentityCheck && task.column !== "in-review") { return `task is in '${task.column}', must be in 'in-review'`; } diff --git a/packages/core/src/task-store/__tests__/transition-validator.test.ts b/packages/core/src/task-store/__tests__/transition-validator.test.ts new file mode 100644 index 0000000000..a84a5f8bd6 --- /dev/null +++ b/packages/core/src/task-store/__tests__/transition-validator.test.ts @@ -0,0 +1,180 @@ +/* +FNXC:WorkflowTransitionPolicy 2026-07-18-20:05: +U1 / KTD-5 — unit coverage for the pure shared transition validator. The policy +module has no store or engine dependency, so these tests construct trait flags +directly and assert the invariant verdicts in isolation. The "identical across +movers" postcondition (U1 scenario 7) is proven here at the pure-function level: +because every mover funnels through moveTaskInternal → this one policy, identical +facts always yield the identical rejection. +*/ +import { describe, expect, it } from "vitest"; + +import type { TraitFlags } from "../../trait-types.js"; +import { + type TransitionColumnFacts, + evaluateCapacityRejection, + evaluateMergeBlockerPostcondition, + evaluateTerminalReentryPostcondition, + evaluateTransitionInvariants, + isHoldToWipBoundary, + isTerminalColumn, + isWipColumn, +} from "../../workflow-transition-policy.js"; + +const facts = (columnId: string, flags: TraitFlags): TransitionColumnFacts => ({ columnId, flags }); + +const WIP: TraitFlags = { countsTowardWip: true }; +const HOLD: TraitFlags = { hold: true }; +const COMPLETE: TraitFlags = { complete: true }; +const ARCHIVED: TraitFlags = { archived: true }; +const HUMAN_REVIEW: TraitFlags = { humanReview: true, mergeBlocker: true }; + +describe("workflow-transition-policy — merge-blocker on complete-bound entry", () => { + it("rejects entry into a complete column while a blocker is unresolved", () => { + const rejection = evaluateMergeBlockerPostcondition({ + taskId: "T1", + from: facts("in-review", HUMAN_REVIEW), + to: facts("done", COMPLETE), + mergeBlockerReason: "task is not merged", + }); + expect(rejection).not.toBeNull(); + expect(rejection?.code).toBe("merge-blocked"); + expect(rejection?.retryable).toBe(true); + expect(rejection?.detail).toBe("task is not merged"); + }); + + it("allows entry into a complete column when the blocker is clear", () => { + expect( + evaluateMergeBlockerPostcondition({ + taskId: "T1", + from: facts("in-review", HUMAN_REVIEW), + to: facts("done", COMPLETE), + mergeBlockerReason: null, + }), + ).toBeNull(); + }); + + it("does not fire for a non-complete target even with a blocker present", () => { + expect( + evaluateMergeBlockerPostcondition({ + taskId: "T1", + from: facts("in-progress", WIP), + to: facts("in-review", HUMAN_REVIEW), + mergeBlockerReason: "still working", + }), + ).toBeNull(); + }); +}); + +describe("workflow-transition-policy — terminal → wip re-entry", () => { + it("rejects moving a completed card back into a wip column", () => { + const rejection = evaluateTerminalReentryPostcondition({ + taskId: "T2", + from: facts("done", COMPLETE), + to: facts("in-progress", WIP), + mergeBlockerReason: null, + }); + expect(rejection?.code).toBe("guard-rejected"); + expect(rejection?.retryable).toBe(false); + }); + + it("rejects moving an archived card into a wip column", () => { + expect( + evaluateTerminalReentryPostcondition({ + taskId: "T2", + from: facts("archived", ARCHIVED), + to: facts("in-progress", WIP), + mergeBlockerReason: null, + })?.code, + ).toBe("guard-rejected"); + }); + + it("allows a completed card to reopen into a hold column", () => { + expect( + evaluateTerminalReentryPostcondition({ + taskId: "T2", + from: facts("done", COMPLETE), + to: facts("todo", HOLD), + mergeBlockerReason: null, + }), + ).toBeNull(); + }); + + it("does not fire when the source column is not terminal", () => { + expect( + evaluateTerminalReentryPostcondition({ + taskId: "T2", + from: facts("in-review", HUMAN_REVIEW), + to: facts("in-progress", WIP), + mergeBlockerReason: null, + }), + ).toBeNull(); + }); +}); + +describe("workflow-transition-policy — capacity decision (KTD-5/KTD-9)", () => { + it("rejects when occupants reach the finite limit", () => { + const rejection = evaluateCapacityRejection("in-progress", { limit: 2, occupants: 2 }); + expect(rejection?.code).toBe("capacity-exhausted"); + expect(rejection?.retryable).toBe(true); + expect(rejection?.detail).toContain("2/2"); + }); + + it("allows when there is a free slot", () => { + expect(evaluateCapacityRejection("in-progress", { limit: 2, occupants: 1 })).toBeNull(); + }); + + it("never gates a non-finite limit or absent capacity", () => { + expect(evaluateCapacityRejection("in-progress", { limit: Infinity, occupants: 99 })).toBeNull(); + expect(evaluateCapacityRejection("in-progress", null)).toBeNull(); + expect(evaluateCapacityRejection("in-progress", undefined)).toBeNull(); + }); +}); + +describe("workflow-transition-policy — combined invariants + classification", () => { + it("evaluates merge-blocker before terminal re-entry (first rejection wins)", () => { + // Contrived: a complete-and-wip-ish target would be rejected by trait + // validation upstream, but the ordering is asserted directly on the policy. + const decision = evaluateTransitionInvariants({ + taskId: "T3", + from: facts("done", COMPLETE), + to: facts("done", { complete: true, countsTowardWip: true }), + mergeBlockerReason: "blocked", + }); + expect(decision.allow).toBe(false); + if (!decision.allow) expect(decision.rejection.code).toBe("merge-blocked"); + }); + + it("allows a clean success-path boundary", () => { + expect( + evaluateTransitionInvariants({ + taskId: "T3", + from: facts("in-progress", WIP), + to: facts("in-review", HUMAN_REVIEW), + mergeBlockerReason: null, + }).allow, + ).toBe(true); + }); + + it("yields byte-identical rejections for identical facts (scenario 7: same verdict for every mover)", () => { + const input = { + taskId: "T4", + from: facts("in-review", HUMAN_REVIEW), + to: facts("done", COMPLETE), + mergeBlockerReason: "not merged", + }; + const a = evaluateTransitionInvariants(input); + const b = evaluateTransitionInvariants(input); + expect(a).toEqual(b); + expect(a.allow).toBe(false); + }); + + it("classifies wip / terminal columns and the hold→wip seam (KTD-2)", () => { + expect(isWipColumn(WIP)).toBe(true); + expect(isTerminalColumn(COMPLETE)).toBe(true); + expect(isTerminalColumn(ARCHIVED)).toBe(true); + expect(isHoldToWipBoundary(HOLD, WIP)).toBe(true); + expect(isHoldToWipBoundary(WIP, WIP)).toBe(false); + expect(isHoldToWipBoundary(HOLD, HUMAN_REVIEW)).toBe(false); + }); +}); diff --git a/packages/core/src/task-store/lifecycle-ops.ts b/packages/core/src/task-store/lifecycle-ops.ts index 037243d998..5b5109d9a0 100644 --- a/packages/core/src/task-store/lifecycle-ops.ts +++ b/packages/core/src/task-store/lifecycle-ops.ts @@ -7,6 +7,9 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore, storeLog, RECONCILE_ORPHAN_TASK_DIR_MAX_AGE_MS, WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX} from "../store.js"; +import {planLegacyAdoption} from "../legacy-adoption.js"; +import {sql} from "drizzle-orm"; +import {MIGRATION_BOOKKEEPING_TABLE, LEGACY_ADOPTION_DRAINED_MARKER} from "../postgres/schema-applier.js"; import {mkdir, readdir, readFile, stat, writeFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync, watch, type Dirent} from "node:fs"; @@ -70,6 +73,14 @@ export async function initImpl(store: TaskStore): Promise { error: error instanceof Error ? error.message : String(error), }); } + /* + FNXC:LegacyAdoption 2026-07-19-05:10 (U9b / R10 / KTD-8): + Store-open legacy adoption must run HERE, not only in the SQLite tail below — backend + mode returns early, and backend mode is production. Placing the call only after this + return would make it dead code exactly where pre-cutover rows actually live. Uses the + async store API (listTasks/updateTask), so it is PG-safe. + */ + await adoptLegacyTaskRowsOnOpen(store); return; } @@ -256,8 +267,149 @@ export async function initImpl(store: TaskStore): Promise { error: err instanceof Error ? err.message : String(err), }); } + /* + FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): + Adoption runs OUTSIDE the integrity-pass try block: a throw from + runWorkflowColumnsIntegrityPass/recoverStaleTransitionPending must not skip the + sweep for the boot cycle ("every pre-cutover row wakes OWNED" cannot depend on an + unrelated pass succeeding). Safe as a bare await — the sweep is internally fail-soft + and never throws. + */ + await adoptLegacyTaskRowsOnOpen(store); } +/* +FNXC:LegacyAdoption 2026-07-19-05:00 (U9b / R10 / KTD-8): +Store-open legacy adoption — the SECOND consumer of the KTD-8 adoption table, sharing +`planLegacyAdoption` with self-healing's startup sweep so the two cannot disagree about +what a legacy row means. + +Why both: the self-healing sweep only runs where the ENGINE runs. A store opened without +it (CLI commands, dashboard-only serve, embedded/test hosts) would otherwise leave +pre-cutover rows carrying a legacy `task.status` whose writer the cutover deleted — frozen +exactly as R10 forbids. Running in both places is safe because `legacyAdoptedAt` makes +adoption idempotent: whichever opens first adopts, the other skips. + +Deliberately NOT audited here. Run-audit emission at store-open would have to go through +the sync SQLite row-insert path, which is one of the masked no-op sites under PG mode; the +engine sweep is the audited path. Adoption is logged instead, and a failure is warned and +swallowed — a store must still open when adoption cannot run. + +FNXC:LegacyAdoption 2026-07-19-09:00 (PR #2335 review): +The sweep PAGINATES until the active census is drained instead of scanning only the newest +500 rows. `listTasks` orders by (created_at, id), which recency ordering means a capped +single fetch would re-read the same newest page on every open and strand older legacy rows +forever. Offset pagination is stable here: adoption patches never change created_at and +adopted rows stay in the active list, so page boundaries do not shift mid-drain. Each page +stays bounded (500) so store open never materializes the whole table at once. + +FNXC:LegacyAdoption 2026-07-19-14:30 (PR #2341 review): +Completion short-circuit. Without one, the full active-census scan runs on EVERY store open +forever — a permanent startup cost scaling with total task count, not the shrinking legacy +backlog. After a full drain in which NO row produced a mutating adoption plan, a durable +NON-NUMERIC marker row (LEGACY_ADOPTION_DRAINED_MARKER) is recorded in the +fusion_schema_migrations bookkeeping table (INSERT ... ON CONFLICT DO NOTHING) and checked +before sweeping on later opens. Non-numeric is deliberate: assertBinaryNotOlderThanDatabase +ignores unparseable version identifiers by design (coupling documented at both sites). +Safety rules: +- Any mutating plan during a sweep (including one withheld only by userPaused) withholds + the marker that cycle — rows may still be arriving from an old binary (unlikely under the + stale-binary guard, but the check is cheap), and a paused legacy row must stay adoptable + after the operator unpauses. +- A failed marker READ falls back to sweeping (fail-open toward correctness); a failed + marker WRITE is warned and swallowed (the next clean drain retries). +- SQLite (non-backend) mode has no bookkeeping table → no marker, sweep always runs. +*/ +async function hasLegacyAdoptionDrainedMarker(store: TaskStore): Promise { + const db = store.asyncLayer?.db; + if (!db) return false; + try { + const rows = (await db.execute( + sql`SELECT version FROM public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} WHERE version = ${LEGACY_ADOPTION_DRAINED_MARKER}`, + )) as unknown as unknown[]; + return rows.length > 0; + } catch (error) { + // Fail-open toward correctness: an unreadable marker means sweep. + storeLog.warn("Legacy-adoption drained-marker read failed — sweeping anyway", { + phase: "init:legacy-adoption", + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +async function writeLegacyAdoptionDrainedMarker(store: TaskStore): Promise { + const db = store.asyncLayer?.db; + if (!db) return; + try { + await db.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${LEGACY_ADOPTION_DRAINED_MARKER}) ON CONFLICT (version) DO NOTHING`, + ); + } catch (error) { + // Non-fatal: the next fully-clean drain writes it again. + storeLog.warn("Legacy-adoption drained-marker write failed", { + phase: "init:legacy-adoption", + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function adoptLegacyTaskRowsOnOpen(store: TaskStore): Promise { + try { + if (await hasLegacyAdoptionDrainedMarker(store)) return 0; + const now = new Date().toISOString(); + const pageSize = 500; + let offset = 0; + let adopted = 0; + let mutationPlanned = false; + for (;;) { + const tasks = await store.listTasks({ slim: true, includeArchived: false, limit: pageSize, offset }); + for (const task of tasks) { + const plan = planLegacyAdoption( + { + status: task.status, + reviewLevel: task.reviewLevel, + enabledWorkflowSteps: task.enabledWorkflowSteps, + legacyAdoptedAt: task.legacyAdoptedAt, + }, + now, + ); + if (plan.action === "skip" || !plan.patch) continue; + // A mutating plan — applied or userPaused-withheld — blocks the drained + // marker this cycle (see FNXC note above). + mutationPlanned = true; + if (task.userPaused === true) continue; + try { + await store.updateTask(task.id, plan.patch); + adopted += 1; + } catch (error) { + storeLog.warn("Legacy adoption failed for task during store open", { + phase: "init:legacy-adoption", + taskId: task.id, + action: plan.action, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (tasks.length < pageSize) break; + offset += tasks.length; + } + if (adopted > 0) { + storeLog.log?.(`Legacy adoption adopted ${adopted} pre-cutover row(s) at store open`); + } + if (!mutationPlanned) { + await writeLegacyAdoptionDrainedMarker(store); + } + return adopted; + } catch (error) { + storeLog.warn("Legacy adoption pass failed during store open", { + phase: "init:legacy-adoption", + error: error instanceof Error ? error.message : String(error), + }); + return 0; + } +} + export function setupActivityLogListenersImpl(store: TaskStore): void { if (store.activityListenersWired) return; store.activityListenersWired = true; diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index 868ed97599..31a17a0533 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -14,13 +14,17 @@ import type {Task, Column, ColumnId, HandoffToReviewOptions} from "../types.js"; import {VALID_TRANSITIONS, COLUMNS} from "../types.js"; import {serializeWorkflowIr} from "../workflow-ir.js"; import {resolveAllowedColumns, workflowHasColumn} from "../workflow-transitions.js"; -import {isBuiltinWorkflowId, getBuiltinWorkflow} from "../builtin-workflows.js"; -import {BUILTIN_CODING_WORKFLOW_IR} from "../builtin-coding-workflow-ir.js"; +import {isBuiltinWorkflowId, getBuiltinWorkflow, resolveDefaultWorkflowIr, DEFAULT_WORKFLOW_ID} from "../builtin-workflows.js"; import {parseWorkflowIr} from "../workflow-ir.js"; import {findWorkflowColumn, resolveColumnPluginGates} from "../plugin-gate-verdict.js"; -import {getTraitRegistry} from "../trait-registry.js"; -import {resolveColumnCapacity} from "../workflow-capacity.js"; -import {type DefaultWorkflowMoveContext, applyDefaultWorkflowMoveEffects, evaluateMergeBlockerGuard} from "../default-workflow-hooks.js"; +import {getTraitRegistry, resolveColumnFlags} from "../trait-registry.js"; +import {resolveColumnCapacity, resolveWipBudgetColumns} from "../workflow-capacity.js"; +import { + type TransitionColumnFacts, + evaluateCapacityRejection, + evaluateTransitionInvariants, +} from "../workflow-transition-policy.js"; +import {type DefaultWorkflowMoveContext, applyDefaultWorkflowMoveEffects} from "../default-workflow-hooks.js"; import {makeTransitionRejection, makeTransitionPending} from "../transition-types.js"; import {writeTransitionPending, clearTransitionPending} from "../transition-pending.js"; import {writeTransitionPendingAsync, clearTransitionPendingAsync} from "./async-transition-pending.js"; @@ -47,20 +51,84 @@ async function resolveTaskWorkflowIrForMove(store: TaskStore, id: string): Promi } const selection = await store.getTaskWorkflowSelectionAsync(id); const workflowId = selection?.workflowId; - if (!workflowId) return store.applyBuiltInPromptOverridesSync("builtin:coding", BUILTIN_CODING_WORKFLOW_IR); + /* FNXC:WorkflowBuiltins 2026-07-19-10:24: every no-selection/unresolvable fallback goes through resolveDefaultWorkflowIr() so this resolver and prepareWorkflowMovePolicyPreflightImpl agree on the default IR (see the helper's note on the "preflight is stale" drift). */ + if (!workflowId) return store.applyBuiltInPromptOverridesSync(DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr()); if (isBuiltinWorkflowId(workflowId)) { const builtin = getBuiltinWorkflow(workflowId); - return store.applyBuiltInPromptOverridesSync(workflowId, builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR); + const ir = builtin?.ir; + return store.applyBuiltInPromptOverridesSync(workflowId, ir === undefined ? resolveDefaultWorkflowIr() : typeof ir === "string" ? parseWorkflowIr(ir) : ir); } try { const def = await store.getWorkflowDefinition(workflowId); - return def ? parseWorkflowIr(def.ir) : BUILTIN_CODING_WORKFLOW_IR; + return def ? parseWorkflowIr(def.ir) : resolveDefaultWorkflowIr(); } catch { - return BUILTIN_CODING_WORKFLOW_IR; + return resolveDefaultWorkflowIr(); } } import {enqueueMergeQueueInTransaction, dequeueMergeQueueOnColumnExitInTransaction} from "../task-store/async-merge-coordination.js"; +/* +FNXC:WorkflowTransitionPolicy 2026-07-18-19:52: +Resolve a column's trait-derived facts (id + OR-merged flags) for the shared +transition validator (KTD-5). An unknown/legacy column absent from the workflow +IR resolves to empty flags — the legacy VALID_TRANSITIONS adjacency already +gates those moves, so the invariant policy simply does not fire on them. +*/ +function resolveTransitionColumnFacts(ir: WorkflowIr, columnId: string): TransitionColumnFacts { + const column = findWorkflowColumn(ir, columnId); + return { + columnId, + flags: column ? resolveColumnFlags(column) : {}, + }; +} + +/* +FNXC:WorkflowCapacity 2026-07-19-10:35: +Shared pooled-capacity enforcement (U4/KTD-9/KTD-10), extracted from the async +(backend transaction) and sync (SQLite transaction) move paths, which had the +identical resolve-budget → count-occupants → verdict → throw sequence inline. +Parameterized by a count callback so each path supplies its own in-transaction +counter. The occupant fold is maybe-async on purpose: the sync SQLite path runs +inside a synchronous `db.transactionImmediate` callback and MUST count, judge, +and throw synchronously (a returned promise there would escape the +transaction), while the backend path awaits the returned promise. Counting +stays sequential in the async case, matching the original per-column awaits +against the same transaction handle. A shared `limitSetting` pools multiple +wip columns, so occupants are summed across every column sharing the target's +budget — a task occupies exactly one column, so the sum never double-counts. +KTD-5: the ONE capacity counter feeds the ONE capacity-verdict authority +(`evaluateCapacityRejection`). +*/ +function enforcePooledColumnCapacity(args: { + workflowIr: WorkflowIr; + toColumn: string; + taskId: string; + capacity: { limit: number; countPending: boolean }; + countOccupants: (budgetColumn: string, countPending: boolean) => number | Promise; +}): void | Promise { + const { workflowIr, toColumn, taskId, capacity, countOccupants } = args; + const budgetColumns = resolveWipBudgetColumns(workflowIr, toColumn); + const judge = (occupants: number): void => { + const capacityRejection = evaluateCapacityRejection(toColumn, { + limit: capacity.limit, + occupants, + }); + if (capacityRejection) { + throw new TransitionRejectionError( + capacityRejection, + `Cannot move ${taskId} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`, + ); + } + }; + const step = (index: number, occupants: number): void | Promise => { + if (index >= budgetColumns.length) return judge(occupants); + const count = countOccupants(budgetColumns[index], capacity.countPending); + if (typeof count === "number") return step(index + 1, occupants + count); + return count.then((resolved) => step(index + 1, occupants + resolved)); + }; + return step(0, 0); +} + export async function moveTaskImpl(store: TaskStore, id: string, toColumn: ColumnId, options?: MoveTaskOptions,): Promise { // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:15: // Backend-mode moveTask: the moveTaskInternal orchestration now handles @@ -390,22 +458,59 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum ); } } - // 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards - // (engine/recovery moves, KTD-9). The default workflow's merge-blocker - // trait reads the same getTaskMergeBlocker. - if (!bypassGuards) { - const guardReason = evaluateMergeBlockerGuard(task, fromColumn, toColumn); - if (guardReason) { + // ── KTD-5: shared transition-policy invariants (single validator) ─────── + // FNXC:WorkflowTransitionPolicy 2026-07-18-19:55: + // Every mover funnels through here. The structural invariants (merge-blocker + // on complete-bound entry; terminal→wip re-entry) live in the pure + // workflow-transition-policy module so graph, scheduler, self-healing, + // operator, and dashboard moves all reject identically. Merge-blocker + // enforcement preserves the legacy bypass contract (engine/recovery moves + // that set bypassGuards/skipMergeBlocker are trusted to have proven the + // blocker clear — the finalizer's proven-merge path), so the blocker fact is + // only resolved when NOT bypassed. Terminal→wip re-entry is a new capability + // invariant that holds for all movers (builtin:coding never crosses it, so + // the characterization oracle stays byte-identical). + // + // FNXC:WorkflowTransitionPolicy 2026-07-19-10:20: + // The blocker fact is resolved only when the SOURCE column carries the + // `merge-blocker` trait flag — the trait-level generalization of the legacy + // `fromColumn === "in-review"` gate. Resolving it for every complete-bound + // move re-hardcoded the review lane: `getTaskMergeBlocker` rejects any + // source column that is not literally "in-review", which broke the + // six-column benchmark's merging → done edge and the builtin + // in-progress → done mission-validation edge that legacy allowed unchecked. + // The column-identity precondition inside `getTaskMergeBlocker` is + // skipped via the explicit `skipColumnIdentityCheck` option (PR #2341 + // review — previously this spoofed `column: "in-review"`, which would + // silently misapply any future column-dependent logic there): the + // fromFacts `mergeBlocker` flag IS the workflow's review-lane identity, + // so only the content checks (paused / blocking status / incomplete + // steps / pre-merge step results) decide. For builtin:coding this is + // byte-identical — its only merge-blocker column is literally + // "in-review". + { + const fromFacts = resolveTransitionColumnFacts(workflowIr, fromColumn); + const toFacts = resolveTransitionColumnFacts(workflowIr, toColumn); + const mergeBlockerReason = + !bypassGuards && toFacts.flags.complete && fromFacts.flags.mergeBlocker === true + ? (getTaskMergeBlocker(task, { skipColumnIdentityCheck: true }) ?? null) + : null; + const decision = evaluateTransitionInvariants({ + taskId: id, + from: fromFacts, + to: toFacts, + mergeBlockerReason, + }); + if (!decision.allow) { throw new TransitionRejectionError( - makeTransitionRejection( - "merge-blocked", - "transition.rejected.mergeBlocked", - true, - guardReason, - ), - `Cannot move ${id} to done: ${guardReason}`, + decision.rejection, + `Cannot move ${id} to '${toColumn}': ${decision.rejection.detail ?? decision.rejection.code}`, ); } + } + // 4. Sync trait guards (in-lock) — plugin gate re-check. Skipped entirely + // when bypassGuards (engine/recovery moves, KTD-9). + if (!bypassGuards) { // 4. Plugin gate verdict re-check (U8, KTD-2). For each PLUGIN gate trait // on the target column, consume the pre-evaluated verdict (recorded by // the engine's trait adapter outside the lock). A blocking gate with @@ -749,24 +854,22 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum if (useWorkflow && workflowIr && fromColumn !== toColumn) { const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { - const occupants = await store.countActiveInCapacitySlotAsync({ - tx, - targetColumn: toColumn, - workflowId: effectiveWorkflowIdForMove, - countPending: capacity.countPending, - excludeTaskId: id, + // Shared pooled-budget enforcement (see enforcePooledColumnCapacity); + // this path supplies the async in-transaction counter. + await enforcePooledColumnCapacity({ + workflowIr, + toColumn, + taskId: id, + capacity, + countOccupants: (budgetColumn, countPending) => + store.countActiveInCapacitySlotAsync({ + tx, + targetColumn: budgetColumn, + workflowId: effectiveWorkflowIdForMove, + countPending, + excludeTaskId: id, + }), }); - if (occupants >= capacity.limit) { - throw new TransitionRejectionError( - makeTransitionRejection( - "capacity-exhausted", - "transition.rejected.capacityExhausted", - true, - `Column '${toColumn}' is at capacity (${occupants}/${capacity.limit})`, - ), - `Cannot move ${id} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`, - ); - } } } @@ -888,23 +991,22 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum if (useWorkflow && workflowIr && fromColumn !== toColumn) { const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { - const occupants = store.countActiveInCapacitySlotSync({ - targetColumn: toColumn, - workflowId: effectiveWorkflowIdForMove, - countPending: capacity.countPending, - excludeTaskId: id, + // Shared pooled-budget enforcement (see enforcePooledColumnCapacity); + // the sync counter keeps count → verdict → throw fully synchronous + // inside this sync transaction callback. + enforcePooledColumnCapacity({ + workflowIr, + toColumn, + taskId: id, + capacity, + countOccupants: (budgetColumn, countPending) => + store.countActiveInCapacitySlotSync({ + targetColumn: budgetColumn, + workflowId: effectiveWorkflowIdForMove, + countPending, + excludeTaskId: id, + }), }); - if (occupants >= capacity.limit) { - throw new TransitionRejectionError( - makeTransitionRejection( - "capacity-exhausted", - "transition.rejected.capacityExhausted", - true, - `Column '${toColumn}' is at capacity (${occupants}/${capacity.limit})`, - ), - `Cannot move ${id} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`, - ); - } } } diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index f7d0fb2f09..0bc1dea8ab 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -63,6 +63,12 @@ export interface TaskRow { taskDoneRetryCount: number | null; // FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (ISO timestamp / null). bulkCompletionRefusalAt: string | null; + // FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 durable IR pin + the node entry it belongs to. + workflowIrPin: string | null; + workflowIrPinNodeId: string | null; + workflowIrPinColumnId: string | null; + // FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 one-time adoption stamp (ISO timestamp / null). + legacyAdoptedAt: string | null; worktreeSessionRetryCount: number | null; completionHandoffLimboRecoveryCount: number | null; verificationFailureCount: number | null; @@ -256,6 +262,13 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0), // FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker persisted as nullable ISO timestamp. defineTaskColumn("bulkCompletionRefusalAt", (task) => task.bulkCompletionRefusalAt ?? null), + // FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 — the pin must round-trip through persist so + // it survives the crash it exists to defend against. + defineTaskColumn("workflowIrPin", (task) => task.workflowIrPin ?? null), + defineTaskColumn("workflowIrPinNodeId", (task) => task.workflowIrPinNodeId ?? null), + defineTaskColumn("workflowIrPinColumnId", (task) => task.workflowIrPinColumnId ?? null), + // FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 one-time adoption stamp. + defineTaskColumn("legacyAdoptedAt", (task) => task.legacyAdoptedAt ?? null), defineTaskColumn("worktreeSessionRetryCount", (task) => task.worktreeSessionRetryCount ?? 0), defineTaskColumn("completionHandoffLimboRecoveryCount", (task) => task.completionHandoffLimboRecoveryCount ?? 0), defineTaskColumn("verificationFailureCount", (task) => task.verificationFailureCount ?? 0), diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index ba7e2711ae..9a4e4e7601 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -51,6 +51,11 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergerModelProvider", "mergerModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", + // FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3 + KTD-8): this projection is a SECOND + // copy of the slim column list (see getTaskSelectClauseImpl2). The IR pin, its node entry, + // and the adoption stamp must appear in BOTH or a task read through this path reads as + // unpinned/never-adopted and gets re-adopted or traversed drift-blind. + "workflowIrPin", "workflowIrPinNodeId", "workflowIrPinColumnId", "legacyAdoptedAt", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "mergerThinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index 604566b711..713ac72df1 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -859,7 +859,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string): export async function updateTaskImpl(store: TaskStore, id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | 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; 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | 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; 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; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:StateMachine 2026-07-07-12:00: diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 73295e8b04..3a8ba36b06 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -117,6 +117,12 @@ export function rowToTask(row: TaskRow): Task { taskDoneRetryCount: row.taskDoneRetryCount ?? undefined, // FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker; empty/null → undefined (no taint). bulkCompletionRefusalAt: row.bulkCompletionRefusalAt || undefined, + // FNXC:WorkflowIrPin 2026-07-19-03:10: U9b/KTD-3 — hydrate the durable pin; empty/null → undefined (unpinned). + workflowIrPin: row.workflowIrPin || undefined, + workflowIrPinNodeId: row.workflowIrPinNodeId || undefined, + workflowIrPinColumnId: row.workflowIrPinColumnId || undefined, + // FNXC:LegacyAdoption 2026-07-19-03:10: U9b/KTD-8 — empty/null → undefined (never adopted). + legacyAdoptedAt: row.legacyAdoptedAt || undefined, worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined, completionHandoffLimboRecoveryCount: row.completionHandoffLimboRecoveryCount ?? undefined, verificationFailureCount: row.verificationFailureCount ?? undefined, diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 08b4c69744..87888a594d 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -13,6 +13,7 @@ import {join} from "node:path"; import {existsSync} from "node:fs"; import type {Task, TaskCreateInput, Column, Settings} from "../types.js"; import "../builtin-traits.js"; +import {applyReviewLevelPreset} from "../review-level-preset.js"; import {normalizeTaskPriority} from "../task-priority.js"; import {sanitizeTitle, summarizeTitle} from "../ai-summarize.js"; import {extractTaskIdTokens, normalizeTitleForTaskId} from "../task-title-id-drift.js"; @@ -45,6 +46,8 @@ function ensureSqliteProposalClaimUniqueness(store: TaskStore): void { } export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise { + // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). + input = applyReviewLevelPreset(input); if (!input.description?.trim()) { throw new Error("Description is required and cannot be empty"); } @@ -449,6 +452,8 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta } export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; }): Promise { + // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). + input = applyReviewLevelPreset(input); // FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:10: // Backend-mode createTask: delegates to createTaskBackend which uses the // async DistributedTaskIdAllocator (now wired for backend mode) and the @@ -730,6 +735,8 @@ export async function createTaskImpl(store: TaskStore, input: TaskCreateInput, o } export async function createTaskWithReservedIdImpl(store: TaskStore, input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; invokeTaskCreatedHook?: boolean; },): Promise { + // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). + input = applyReviewLevelPreset(input); if (!input.description?.trim()) { throw new Error("Description is required and cannot be empty"); } diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index e338bba25b..aa4b4d11a4 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -47,6 +47,14 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", "proposalClaimId", "checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt", "allowResurrection", + // FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3): the IR pin and its node entry MUST be + // in the slim projection — restart recovery and the self-healing sweeps read tasks slim, + // and a pin absent from the projection reads as "unpinned", which is exactly the + // drift-blind traversal the pin exists to prevent. + "workflowIrPin", "workflowIrPinNodeId", "workflowIrPinColumnId", + // FNXC:LegacyAdoption 2026-07-19-03:10 (U9b / KTD-8): the startup adoption sweep lists tasks + // slim, so the idempotency stamp must be visible there or every restart re-adopts every row. + "legacyAdoptedAt", // `log` is fetched in slim mode so the server can aggregate // `timedExecutionMs` from `[timing] … in ms` entries before // returning. The log itself is stripped from the response — diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 5c85b2310d..f227a2e5d1 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -419,6 +419,34 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.bulkCompletionRefusalAt !== undefined) { task.bulkCompletionRefusalAt = updates.bulkCompletionRefusalAt; } + /* + FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3): + Node entry SETS the pin; node settle CLEARS it (null). Both directions must be writable + through updateTask or the pin either never lands or outlives its node and reports drift + against a node the task already left. + */ + if (updates.workflowIrPin === null) { + task.workflowIrPin = undefined; + } else if (updates.workflowIrPin !== undefined) { + task.workflowIrPin = updates.workflowIrPin; + } + if (updates.workflowIrPinNodeId === null) { + task.workflowIrPinNodeId = undefined; + } else if (updates.workflowIrPinNodeId !== undefined) { + task.workflowIrPinNodeId = updates.workflowIrPinNodeId; + } + if (updates.workflowIrPinColumnId === null) { + task.workflowIrPinColumnId = undefined; + } else if (updates.workflowIrPinColumnId !== undefined) { + task.workflowIrPinColumnId = updates.workflowIrPinColumnId; + } + // FNXC:LegacyAdoption 2026-07-19-03:10 (U9b / KTD-8): stamped once by adoption; null is + // reserved for tests/operator repair that need to force re-adoption. + if (updates.legacyAdoptedAt === null) { + task.legacyAdoptedAt = undefined; + } else if (updates.legacyAdoptedAt !== undefined) { + task.legacyAdoptedAt = updates.legacyAdoptedAt; + } if (updates.worktreeSessionRetryCount === null) { task.worktreeSessionRetryCount = undefined; } else if (updates.worktreeSessionRetryCount !== undefined) { diff --git a/packages/core/src/task-store/workflow-definitions.ts b/packages/core/src/task-store/workflow-definitions.ts index a3ba163bce..e8584ae853 100644 --- a/packages/core/src/task-store/workflow-definitions.ts +++ b/packages/core/src/task-store/workflow-definitions.ts @@ -12,8 +12,7 @@ import { TaskStore } from "../store.js"; import {resolveEntryColumnId} from "../workflow-reconciliation.js"; import { pruneAgentLogFiles as pruneAgentLogFileEntries, readAgentLogEntriesByTimeRange } from "../agent-log-file-store.js"; -import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; -import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, getRequiredPluginIdForBuiltinWorkflow, isBuiltinWorkflowDeprecated, isBuiltinWorkflowEnabled, isBuiltinWorkflowId, isBuiltinWorkflowPluginGated } from "../builtin-workflows.js"; +import { BUILTIN_WORKFLOWS, DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr, getBuiltinWorkflow, getRequiredPluginIdForBuiltinWorkflow, isBuiltinWorkflowDeprecated, isBuiltinWorkflowEnabled, isBuiltinWorkflowId, isBuiltinWorkflowPluginGated } from "../builtin-workflows.js"; import { CentralCore } from "../central-core.js"; import { fromJson } from "../db.js"; import { type DistributedTaskIdAllocator, createDistributedTaskIdAllocator } from "../distributed-task-id.js"; @@ -466,19 +465,25 @@ export function consumePluginGateVerdictsImpl(store: TaskStore, taskId: string, export function resolveTaskWorkflowIrSyncImpl(store: TaskStore, taskId: string): WorkflowIr { const selection = store.getTaskWorkflowSelection(taskId); const workflowId = selection?.workflowId; - if (!workflowId) return store.applyBuiltInPromptOverridesSync("builtin:coding", BUILTIN_CODING_WORKFLOW_IR); + /* FNXC:WorkflowBuiltins 2026-07-19-10:26: shares resolveDefaultWorkflowIr() with the async move resolver so sync and async paths cannot disagree on the no-selection default. */ + if (!workflowId) return store.applyBuiltInPromptOverridesSync(DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr()); if (isBuiltinWorkflowId(workflowId)) { const builtin = getBuiltinWorkflow(workflowId); - return store.applyBuiltInPromptOverridesSync(workflowId, builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR); + const ir = builtin?.ir; + return store.applyBuiltInPromptOverridesSync(workflowId, ir === undefined ? resolveDefaultWorkflowIr() : typeof ir === "string" ? parseWorkflowIr(ir) : ir); } try { const row = store.db .prepare("SELECT ir FROM workflows WHERE id = ?") .get(workflowId) as { ir: string } | undefined; - if (!row) return BUILTIN_CODING_WORKFLOW_IR; + /* FNXC:WorkflowBuiltins 2026-07-19-12:20 (PR #2341 review): the deleted-workflow and + read-error fallbacks must apply built-in prompt overrides exactly like the + no-selection branch above — otherwise a task whose custom workflow was deleted + silently loses the project's default-workflow prompt customizations. */ + if (!row) return store.applyBuiltInPromptOverridesSync(DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr()); return parseWorkflowIr(row.ir); } catch { - return BUILTIN_CODING_WORKFLOW_IR; + return store.applyBuiltInPromptOverridesSync(DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr()); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c7b7b8fbbb..33b05bb3ec 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -56,7 +56,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn, - normalizeColumn, + normalizeColumn, normalizeColumnId, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, } from "./types/board.js"; @@ -66,7 +66,7 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, - normalizeColumn, + normalizeColumn, normalizeColumnId, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, }; @@ -1852,6 +1852,31 @@ export interface Task { * Null/undefined means no active taint. */ bulkCompletionRefusalAt?: string; + /* + FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3): + The workflow IR version/content hash this task resolved when ENTERING its current node, + held until that node settles. `resolveWorkflowIrForTask` is live-per-call, so without a + durable pin a workflow edited mid-flight silently changes the graph under a running task. + On restart, recovery compares this pin against the current IR and parks with + `task:reconcile-workflow-drift` on mismatch rather than traversing a mutated graph. + */ + workflowIrPin?: string; + /** The node entry {@link workflowIrPin} was taken for. Without it a restart cannot + * distinguish a stale pin from the current node's pin and every resumed task reads as + * drifted. */ + workflowIrPinNodeId?: string; + /** The pinned node's column AT ENTRY, so drift detection flags a column deleted out + * from under the task even when the node id itself survives. */ + workflowIrPinColumnId?: string; + /* + FNXC:LegacyAdoption 2026-07-19-03:10 (U9b / R10 / KTD-8): + ISO timestamp stamped once when store-open reconcile or the self-healing startup sweep + adopts this pre-cutover row through the KTD-8 adoption table. Makes adoption idempotent + across restarts (never re-clear a status a human has since re-set, never re-park a row an + operator un-parked) and makes "zero frozen rows" provable: an un-stamped legacy row is by + definition one adoption never reached. + */ + legacyAdoptedAt?: string; /** Number of times self-healing auto-requeued an `in-review` task that failed * at session start with an unusable-worktree error. Bounded by * `MAX_WORKTREE_SESSION_RETRIES`; when exhausted the task remains parked in diff --git a/packages/core/src/types/board.ts b/packages/core/src/types/board.ts index 828614f29c..e0b2153f37 100644 --- a/packages/core/src/types/board.ts +++ b/packages/core/src/types/board.ts @@ -62,6 +62,24 @@ export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUM return isColumn(value) ? value : fallback; } +/* +FNXC:WorkflowColumns 2026-07-19-2b:00 (U12 / R2 / R11): +The workflow-aware counterpart to `normalizeColumn`, and the one client code should use when +sanitizing a column id off the wire. + +`normalizeColumn` answers "is this one of the SIX legacy ids", so it silently rewrites every +workflow-defined id to `triage`. That is correct only for the closed default-workflow set; applied +to a real board it teleports cards. A custom `merging` column's cards rendered in Triage because +the dashboard ran every task through the legacy coercion on ingest. + +The right invariant at a deserialization boundary is narrower: reject only what is structurally +unusable (non-string / empty), and pass every real id through untouched. Membership is not this +function's business — the task's resolved workflow decides that, via `workflowHasColumn`. +*/ +export function normalizeColumnId(value: unknown, fallback: ColumnId = DEFAULT_COLUMN): ColumnId { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + /** Ordered task-priority levels for the core task domain contract. */ export const TASK_PRIORITIES = ["low", "normal", "high", "urgent"] as const; export type TaskPriority = (typeof TASK_PRIORITIES)[number]; diff --git a/packages/core/src/types/workflow-steps.ts b/packages/core/src/types/workflow-steps.ts index 1854f727b2..936137d1bf 100644 --- a/packages/core/src/types/workflow-steps.ts +++ b/packages/core/src/types/workflow-steps.ts @@ -252,6 +252,17 @@ export interface WorkflowStepResult { startedAt?: string; /** ISO-8601 timestamp when the step completed */ completedAt?: string; + /* + * FNXC:PlanReviewLease 2026-07-18-23:20: + * U3 / KTD-4 — a `pending` review-gate result is a LEASE. `leaseOwner` records + * the session/run id that claimed the gate; `startedAt` is the lease clock. The + * graph's plan-review dedup honors a live lease (adopt/skip re-dispatch) and + * reclaims only past the staleness floor via compare-and-set, so a crash/restart + * mid-review can never dispatch a second reviewer (the FN-1315-shaped duplicate + * "Starting workflow step: Plan Review" race). Absent on non-leased results and + * on every terminal (passed/failed/…) record — a lease only exists while pending. + */ + leaseOwner?: string; /* * FNXC:ReviewLaneBypass 2026-07-09-00:00: * A privileged operator can bypass a `status:"failed"` pre-merge review step diff --git a/packages/core/src/workflow-capacity.ts b/packages/core/src/workflow-capacity.ts index 439ab7c4c4..4b2549c933 100644 --- a/packages/core/src/workflow-capacity.ts +++ b/packages/core/src/workflow-capacity.ts @@ -128,3 +128,59 @@ export function resolveColumnCapacity( return { hasCapacity: true, limit, countPending }; } + +/* +FNXC:WorkflowCapacity 2026-07-19-02:20 (U4/KTD-9): +Multiple `wip` columns SHARE one budget when they resolve their limit the same +way — via a shared `limitSetting` (e.g. maxConcurrent) or the default-workflow +in-progress read-through. The scheduler's single counter must count occupants +across ALL columns sharing the target's budget, so operator-visible concurrency +does not silently multiply when a workflow has two wip columns. A column with an +explicit numeric `limit` is INDEPENDENT — its budget is itself alone. This pure +helper resolves that column set; both enforcement points (the in-txn check in +moves.ts and the hold/release sweep) sum their live counts across it, keeping one +budget authority (KTD-5). +*/ + +/** The budget "key" a wip column resolves its limit through. Two columns share a + * budget iff their keys are equal. `undefined` = not a capacity column. */ +function resolveColumnBudgetKey(ir: WorkflowIr, columnId: string): string | undefined { + const column = findColumn(ir, columnId); + if (!column) return undefined; + const flags = getTraitRegistry().resolveColumnFlags(column); + if (!flags.countsTowardWip) return undefined; + for (const ct of column.traits) { + const def = getTraitRegistry().getTrait(ct.trait); + if (!def?.flags.countsTowardWip) continue; + const cfg = ct.config ?? {}; + // An explicit numeric limit is an independent per-column budget. + if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) return `col:${columnId}`; + // A shared setting (maxConcurrent, …) pools every column that names it. + if (typeof cfg.limitSetting === "string") return `setting:${cfg.limitSetting}`; + break; + } + // Default-workflow in-progress read-through pools with the maxConcurrent setting. + if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) return "setting:maxConcurrent"; + // Capacity trait but no resolvable shared source → independent (self only). + return `col:${columnId}`; +} + +/** + * Resolve the set of column ids whose live WIP occupancy shares ONE budget with + * `targetColumn` (KTD-9). Returns `[targetColumn]` for an explicit-`limit` or + * otherwise-independent column, every column sharing a `limitSetting`/default + * read-through for a pooled budget, and `[]` when the target is not a capacity + * column. Deterministic (declared column order). + */ +export function resolveWipBudgetColumns(ir: WorkflowIr, targetColumn: string): string[] { + const targetKey = resolveColumnBudgetKey(ir, targetColumn); + if (!targetKey) return []; + // A truthy targetKey proves findColumn located targetColumn, which proves + // `columns` is an array — and the loop necessarily re-collects targetColumn + // itself, so the set is never empty. No defensive fallbacks needed. + const set: string[] = []; + for (const c of (ir as WorkflowIrV2).columns) { + if (resolveColumnBudgetKey(ir, c.id) === targetKey) set.push(c.id); + } + return set; +} diff --git a/packages/core/src/workflow-ir-resolver.ts b/packages/core/src/workflow-ir-resolver.ts index 2f79d9d2e2..12489477b6 100644 --- a/packages/core/src/workflow-ir-resolver.ts +++ b/packages/core/src/workflow-ir-resolver.ts @@ -14,20 +14,87 @@ * stays separate by design. */ -import { getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js"; -import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; -import { parseWorkflowIr } from "./workflow-ir.js"; +import { getBuiltinWorkflow, isBuiltinWorkflowId, resolveDefaultWorkflowIr } from "./builtin-workflows.js"; +import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js"; import { applyPromptOverridesToIr } from "./workflow-prompt-overrides.js"; import type { WorkflowIr } from "./workflow-ir-types.js"; +/* +FNXC:WorkflowIrPin 2026-07-18-20:20: +KTD-3 — a task pins its resolved workflow IR when it ENTERS a node, and holds +that resolution until the node settles. The pin is a durable seam: the field +lives on the workflow run state (schema wiring lands in U9; this is the in-code +seam U1 adds now). Restart recovery compares the stored pin against the CURRENT +IR and takes the drift-park path on mismatch — the pin survives crashes. + +`resolveWorkflowIrForTask` is otherwise live-per-call, so a mid-flight editor +edit that changes the graph under a running task is a determinism hole; the pin +plus `detectWorkflowDrift` closes it. If an edit deleted the pinned node or its +column, the task parks with `task:reconcile-workflow-drift` instead of traversing +a mutated graph. +*/ + +/** A durable per-node-entry IR pin. `irHash` is a content hash of the resolved + * IR at entry time; `columnId` is the pinned node's column at entry (so drift + * detection can flag a deleted column even when the node id survives). */ +export interface WorkflowIrPin { + nodeId: string; + irHash: string; + columnId?: string; +} + +/** Stable, cheap content hash of a resolved IR (djb2 over the canonical + * serialization). Not cryptographic — only used to detect that the graph a + * running task pinned differs from the graph now resolved. */ +export function hashWorkflowIr(ir: WorkflowIr): string { + const serialized = serializeWorkflowIr(ir); + let hash = 5381; + for (let i = 0; i < serialized.length; i++) { + hash = ((hash << 5) + hash + serialized.charCodeAt(i)) | 0; + } + // Unsigned hex + length so two different-length graphs never collide trivially. + return `${(hash >>> 0).toString(16)}:${serialized.length}`; +} + +/** Compute the per-node-entry pin for a node against a resolved IR. */ +export function computeWorkflowIrPin(ir: WorkflowIr, nodeId: string): WorkflowIrPin { + const node = ir.nodes.find((n) => n.id === nodeId); + return { nodeId, irHash: hashWorkflowIr(ir), columnId: node?.column }; +} + +/** The reason a pinned run is considered drifted, or null when the pin still + * resolves cleanly against the current IR. */ +export type WorkflowDriftReason = "node-deleted" | "column-deleted"; + +/** + * KTD-3 drift detection. A pin is drifted when, against the CURRENT resolved IR: + * - the pinned node id no longer exists (`node-deleted`), or + * - the pinned node's column no longer exists (`column-deleted`). + * A matching `irHash` short-circuits to "no drift" (the graph is byte-identical). + * Returns null when the pin is still safely resolvable. + */ +export function detectWorkflowDrift(ir: WorkflowIr, pin: WorkflowIrPin): WorkflowDriftReason | null { + if (pin.irHash === hashWorkflowIr(ir)) return null; + const node = ir.nodes.find((n) => n.id === pin.nodeId); + if (!node) return "node-deleted"; + const columnId = node.column ?? pin.columnId; + if (columnId !== undefined) { + const columns = "columns" in ir ? ir.columns : undefined; + if (columns && !columns.some((c) => c.id === columnId)) return "column-deleted"; + } + return null; +} + function defaultCodingWorkflowIr(): WorkflowIr { /* * FNXC:WorkflowBuiltins 2026-06-29-02:18: * `builtin:coding` is the operator-facing default workflow id, not the legacy monolithic IR export. Resolve the catalog entry first so no-selection tasks follow the new stepwise default; keep the old IR only as a missing-catalog safety fallback. + * + * FNXC:WorkflowBuiltins 2026-07-19-10:28: delegated to the shared + * resolveDefaultWorkflowIr() authority so the move-path resolvers and this + * one cannot drift (that drift produced "preflight is stale" on no-selection moves). */ - const builtin = getBuiltinWorkflow("builtin:coding"); - const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR; - return typeof ir === "string" ? parseWorkflowIr(ir) : ir; + return resolveDefaultWorkflowIr(); } /** Minimal store surface the resolver needs (public APIs only). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index f59e56aafb..bae3735833 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -18,6 +18,11 @@ import type { import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; import { THINKING_LEVELS } from "./types.js"; +import { resolveColumnFlags } from "./trait-registry.js"; +// Side-effect import: registers the built-in traits so `resolveColumnFlags` +// resolves the built-in `merge-blocker`/`intake` flags during save-time +// validation (U2). Custom/plugin traits that set the same flags resolve too. +import "./builtin-traits.js"; export class WorkflowIrError extends Error { constructor(message: string) { @@ -323,6 +328,93 @@ const INTERPRETER_ENTRY_NODE_KINDS: ReadonlySet = new Set([ "pr-merge", ]); +/* +FNXC:WorkflowValidation 2026-07-18-22:10: +U2 — a `merge-blocker` column blocks entry to complete-bound columns until a +merge-class node has completed. If a workflow declares merge-blocker but the +graph contains no reachable merge-class node, the gate can NEVER clear and the +card is stranded forever. Save-time validation rejects that shape. Mirrors the +engine's MERGE_REGION_KINDS plus the PR merge node (a PR-based workflow clears +its merge-blocker via `pr-merge`). +*/ +const MERGE_CLASS_NODE_KINDS: ReadonlySet = new Set([ + "merge-gate", + "merge-attempt", + "manual-merge-hold", + "retry-backoff", + "recovery-router", + "branch-group-member-integration", + "branch-group-promotion", + "pr-merge", +]); + +/** Collect the set of node ids reachable from `startId` over the given outgoing + * edge map (top-level graph reachability). */ +function collectReachableNodeIds( + startId: string, + outgoing: Map, +): Set { + const reachable = new Set([startId]); + const stack = [startId]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const edge of outgoing.get(current) ?? []) { + if (!reachable.has(edge.to)) { + reachable.add(edge.to); + stack.push(edge.to); + } + } + } + return reachable; +} + +/** + * U2 save-time hard error: a workflow declaring a `merge-blocker` column MUST + * contain a merge-class node reachable from `start`. Without one the merge gate + * never clears. Fails open (no rejection) when no column resolves the + * merge-blocker flag — a merge-less docs-only workflow is unaffected. + */ +function validateMergeBlockerReachability( + ir: WorkflowIrV2, + outgoing: Map, +): void { + const blockerColumn = ir.columns.find((c) => resolveColumnFlags(c).mergeBlocker === true); + if (!blockerColumn) return; + + const startNode = ir.nodes.find((n) => n.kind === "start"); + // A start-less graph is rejected elsewhere; treat all nodes as candidates here + // so this check never masks that more fundamental error. + const reachable = startNode + ? collectReachableNodeIds(startNode.id, outgoing) + : new Set(ir.nodes.map((n) => n.id)); + + // A merge-class node is a merge-region node KIND, or the legacy/linear merge + // seam expressed as a prompt node with `config.seam === "merge"` (linear + // built-ins and custom seam workflows use the latter). + const isMergeClassNode = (n: WorkflowIrNode): boolean => + MERGE_CLASS_NODE_KINDS.has(n.kind) || n.config?.seam === "merge"; + const hasReachableMerge = ir.nodes.some((n) => isMergeClassNode(n) && reachable.has(n.id)); + if (!hasReachableMerge) { + throw new WorkflowIrError( + `Workflow column '${blockerColumn.id}' declares the merge-blocker trait but the graph has ` + + `no reachable merge-class node (merge-gate/merge-attempt/manual-merge-hold/retry-backoff/` + + `recovery-router/branch-group-*/pr-merge). The merge-blocker gate can never clear without one.`, + ); + } +} + +/** + * Resolve a workflow's creation column — where new cards land (U2/R11). The + * intake-flagged column if present; otherwise the first column is the documented + * default. Returns undefined for a v1-style / empty-columns IR (no column model). + */ +export function resolveCreationColumn(ir: WorkflowIr): WorkflowIrColumn | undefined { + const columns = ir.version === "v2" ? ir.columns : undefined; + if (!columns || columns.length === 0) return undefined; + const intake = columns.find((c) => resolveColumnFlags(c).intake === true); + return intake ?? columns[0]; +} + function validateRequiredTopLevelReachability( nodes: WorkflowIrNode[], outgoing: Map, @@ -1512,6 +1604,10 @@ function validateV2(ir: WorkflowIrV2): void { const outgoing = buildOutgoing(ir.edges); validateParallelism(ir.nodes, outgoing, nodesById); + // U2: a merge-blocker column requires a reachable merge-class node, or its gate + // can never clear. Runs after edge validity + outgoing map are established. + validateMergeBlockerReachability(ir, outgoing); + // Step-inversion (U1) — additive validation. Order matters: validate node // configs first, then structural rules. const topLevelIds = new Set(ir.nodes.map((n) => n.id)); diff --git a/packages/core/src/workflow-lifecycle-traits.ts b/packages/core/src/workflow-lifecycle-traits.ts new file mode 100644 index 0000000000..0f723c31e4 --- /dev/null +++ b/packages/core/src/workflow-lifecycle-traits.ts @@ -0,0 +1,86 @@ +/* +FNXC:WorkflowLifecycleTraits 2026-07-19-06:10 (U6 / KTD-10): +Pure, per-IR trait→column primitives shared by the self-healing recovery sweeps. +Two concerns, both keyed on trait flags (never literal column ids) so a custom or +renamed workflow behaves correctly while builtin:coding stays byte-identical +(KTD-7: the builtin column ids ARE the legacy enum, so every predicate below +resolves to the same columns the old literals named): + + - `columnsWithFlag(ir, flag)` — the trait→columnIds expansion. A sweep resolves + the workflow IR ONCE, expands each trait it enumerates by (wip / merge- + orchestration / complete / archived / hold / intake) to the set of column ids + that carry it, then filters its task snapshot by that set — no per-task IR + resolution, no new store API (U6 architecture). + + - `resolveReboundTarget(ir)` — KTD-10 rebound target ordering: the workflow's + `hold` column, else its `intake` column, else its first column. Self-healing's + "requeue to backlog" rebounds target this instead of the literal "todo" so a + custom workflow lacking a `todo` column still lands its recovered cards somewhere + valid. For builtin:coding this resolves to `todo` (its hold column) — identical. +*/ + +import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js"; +import type { TraitFlags } from "./trait-types.js"; +import { getTraitRegistry } from "./trait-registry.js"; + +/** The v2 column list, or [] for a v1/column-less IR. */ +function columnsOf(ir: WorkflowIr): WorkflowIrColumn[] { + return ir.version === "v2" ? ir.columns : []; +} + +/** + * The set of column ids whose resolved (OR-merged) trait flags set `flag` — the + * trait→columnIds expansion. Deterministic (declared column order). Empty for a + * column-less IR or when no column carries the flag. + */ +export function columnsWithFlag(ir: WorkflowIr, flag: keyof TraitFlags): string[] { + const registry = getTraitRegistry(); + return columnsOf(ir) + .filter((c) => registry.resolveColumnFlags(c)[flag] === true) + .map((c) => c.id); +} + +/** Convenience predicate: does `columnId` carry `flag` in this IR? */ +export function columnHasFlag(ir: WorkflowIr, columnId: string, flag: keyof TraitFlags): boolean { + const column = columnsOf(ir).find((c) => c.id === columnId); + if (!column) return false; + return getTraitRegistry().resolveColumnFlags(column)[flag] === true; +} + +/** + * U7 — the workflow's COMPLETE (terminal-success) column: the first column + * carrying the `complete` trait. Finalization moves a confirmed-merged card here + * instead of the literal "done"; builtin:coding resolves to `done`. Returns + * undefined when no column is complete (caller keeps its literal fallback). + */ +export function resolveCompleteColumn(ir: WorkflowIr): string | undefined { + return columnsWithFlag(ir, "complete")[0]; +} + +/** + * U7 — the workflow's MERGE-ORCHESTRATION column: the first column carrying the + * `mergeOrchestration` trait (where the merge-gate node lives). Merge-failure + * rebounds that stay in the merge lane and `human-review` manual holds park here + * instead of the literal "in-review"; builtin:coding resolves to `in-review`. + * Returns undefined when no column orchestrates merge. + */ +export function resolveMergeOrchestrationColumn(ir: WorkflowIr): string | undefined { + return columnsWithFlag(ir, "mergeOrchestration")[0]; +} + +/** + * KTD-10 rebound target: where a self-healing sweep requeues a recovered card. + * Preference order — the workflow's `hold` column, else its `intake` column, else + * its first column. Returns undefined only for a column-less (v1) IR, where the + * caller keeps the legacy literal fallback. For builtin:coding this is `todo`. + */ +export function resolveReboundTarget(ir: WorkflowIr): string | undefined { + const columns = columnsOf(ir); + if (columns.length === 0) return undefined; + const registry = getTraitRegistry(); + const hold = columns.find((c) => registry.resolveColumnFlags(c).hold === true); + if (hold) return hold.id; + const intake = columns.find((c) => registry.resolveColumnFlags(c).intake === true); + if (intake) return intake.id; + return columns[0].id; +} diff --git a/packages/core/src/workflow-step-results.ts b/packages/core/src/workflow-step-results.ts index 4898924802..64111a19b8 100644 --- a/packages/core/src/workflow-step-results.ts +++ b/packages/core/src/workflow-step-results.ts @@ -97,3 +97,99 @@ export function upsertWorkflowStepResult( next[idx] = replacement; return next; } + +/* +FNXC:PlanReviewLease 2026-07-18-23:25: +U3 / KTD-4 — pending review-gate results are LEASES. A `pending` result whose +`leaseOwner` is set and whose `startedAt` is within the staleness floor is a LIVE +lease: a re-entering graph run must adopt it (skip re-dispatch), never launch a +second reviewer. Only past the staleness floor may another run RECLAIM the gate +by compare-and-set (write its own owner). This is the FN-6736 stale-lease pattern +applied to the plan-review dedup site, and it is what makes the FN-1315 duplicate +"Starting workflow step: Plan Review" interleaving impossible by construction. + +These helpers are PURE (no store, no clock beyond the injected `now`) so the +graph executor and unit tests share one lease implementation. +*/ + +/** Default staleness floor for a review-gate lease (ms). A lease older than this + * with no terminal result is presumed crashed and may be reclaimed. Mirrors the + * FN-6736 staleness-floor standard for durable single-owner leases. */ +export const PLAN_REVIEW_LEASE_STALENESS_MS = 15 * 60 * 1000; + +/** Classification of a review-gate's current lease state for a re-entering run. */ +export type ReviewLeaseDisposition = + /** No prior result — this run should claim the lease and dispatch the reviewer. */ + | { kind: "claim" } + /** A terminal result already exists (passed/failed/…): satisfied, do not dispatch. */ + | { kind: "settled"; result: WorkflowStepResult } + /** A LIVE lease owned by another run within the staleness floor: adopt, do NOT dispatch. */ + | { kind: "adopt"; owner: string } + /** A stale lease (past the floor, or ownerless): this run may reclaim by CAS and dispatch. */ + | { kind: "reclaim"; priorOwner?: string }; + +/** Terminal statuses a leased pending result can settle into. */ +const TERMINAL_STEP_STATUSES: ReadonlySet = new Set([ + "passed", + "failed", + "advisory_failure", + "skipped", +]); + +/** Is a stored result a terminal (settled) record rather than a live/stale lease? */ +export function isTerminalStepResult(result: WorkflowStepResult): boolean { + return TERMINAL_STEP_STATUSES.has(result.status); +} + +/** + * Decide what a re-entering run should do about a review gate, given the current + * results for the gate's step id. Pure and clock-injected. The staleness floor + * (not owner identity) governs honor-vs-reclaim, so a crash/restart that re-enters + * with the SAME deterministic run id still honors a live lease within the floor + * (never double-dispatches) and only reclaims once the lease is presumed dead. + * + * - No existing result → `claim` (dispatch the reviewer, writing a lease). + * - Existing terminal result → `settled` (dedup: do not re-dispatch). + * - Existing `pending` lease within the staleness floor → `adopt` (do NOT dispatch). + * - Existing `pending` lease past the floor (or ownerless/undated) → `reclaim`. + */ +export function classifyReviewLease( + results: readonly WorkflowStepResult[] | undefined, + stepId: string, + now: number, + stalenessMs: number = PLAN_REVIEW_LEASE_STALENESS_MS, +): ReviewLeaseDisposition { + const existing = results?.find((r) => r.workflowStepId === stepId); + if (!existing) return { kind: "claim" }; + if (isTerminalStepResult(existing)) return { kind: "settled", result: existing }; + // existing.status === "pending": it is a lease. + const startedMs = existing.startedAt ? Date.parse(existing.startedAt) : Number.NaN; + const ageMs = Number.isFinite(startedMs) ? now - startedMs : Number.POSITIVE_INFINITY; + const stale = !existing.leaseOwner || !Number.isFinite(startedMs) || ageMs >= stalenessMs; + if (stale) return { kind: "reclaim", priorOwner: existing.leaseOwner }; + // Not stale ⇒ `leaseOwner` is guaranteed set (the stale check requires it). + return { kind: "adopt", owner: existing.leaseOwner as string }; +} + +/** + * Build the `pending` lease record a run writes when it claims/reclaims a review + * gate. `startedAt` is the lease clock; `leaseOwner` is this run's identity. + */ +export function makeReviewLeaseRecord(args: { + stepId: string; + stepName: string; + owner: string; + startedAt: string; + phase?: WorkflowStepResult["phase"]; + source?: WorkflowStepResult["source"]; +}): WorkflowStepResult { + return { + workflowStepId: args.stepId, + workflowStepName: args.stepName, + ...(args.phase ? { phase: args.phase } : {}), + ...(args.source ? { source: args.source } : {}), + status: "pending", + startedAt: args.startedAt, + leaseOwner: args.owner, + }; +} diff --git a/packages/core/src/workflow-transition-policy.ts b/packages/core/src/workflow-transition-policy.ts new file mode 100644 index 0000000000..084fd6de39 --- /dev/null +++ b/packages/core/src/workflow-transition-policy.ts @@ -0,0 +1,181 @@ +/* +FNXC:WorkflowTransitionPolicy 2026-07-18-19:40: +U1 / KTD-5 — the single shared transition validator. This module hosts the PURE +trait-invariant logic for a column transition; it has no store, no DB handle, and +no engine import, so it is unit-testable in isolation (transition-validator.test.ts). + +The lifecycle cutover has one in-lock enforcement point — `task-store/moves.ts` +`moveTaskInternalImpl` — that EVERY mover funnels through (graph traversal, +scheduler hold→wip release, self-healing rebound, heartbeat progression, operator +drag, dashboard routes). That single call site is the sole caller of this policy. +No other module may call it directly (branch-local trait checks are +defense-in-depth only). Enforcing an invariant at one branch of one gate loop is +how invariants stop drifting apart between movers — see +docs/solutions/logic-errors/repo-root-task-worktree-requeue-loop.md. + +The invariants live here as RETURN-GUARD POSTCONDITIONS: a move is only permitted +when every applicable invariant returns `allow`, so a would-be caller cannot skip +one by taking a different branch. The two structural invariants are: + + 1. merge-blocker on complete-bound entry — a card may not enter a `complete` + column while it carries an unresolved merge blocker (generalized FN-5147 in + trait terms; the builtin realization is in-review→done with a live blocker). + 2. terminal → wip re-entry — a card in a `complete`/`archived` column may not be + moved into a WIP column. Completed/archived work is not resurrected straight + into active capacity; reopens route through a `hold`/`intake` column instead. + +Capacity for direct WIP entry (KTD-5) is a pure DECISION here — the caller counts +live occupants via the one `workflow-capacity` counter and hands (limit, +occupants) to `evaluateCapacityRejection`, so there is exactly one capacity- +counting authority and one capacity-verdict authority. A move into a saturated +WIP column is rejected and the task parks ready at the boundary. +*/ + +import { type TraitFlags } from "./trait-types.js"; +import { type TransitionRejection, makeTransitionRejection } from "./transition-types.js"; + +/** The trait-derived facts about a column, resolved by the caller from the IR. + * Kept as plain flags so the policy never touches the trait registry or IR. */ +export interface TransitionColumnFacts { + columnId: string; + /** Effective (OR-merged) trait flags for the column. */ + flags: TraitFlags; +} + +/** Capacity facts for a real column change into a WIP column. `limit` is the + * effective finite limit; `occupants` is the live count in the target capacity + * slot with the moving task EXCLUDED. A non-finite limit means "no gate". */ +export interface CapacityFacts { + limit: number; + occupants: number; +} + +/** Input to the shared invariant policy. `mergeBlockerReason` is the caller's + * already-resolved blocker string (or null when clear / not enforced for this + * move); the policy never re-derives it (that needs the task, which is the + * caller's concern). */ +export interface TransitionInvariantInput { + taskId: string; + from: TransitionColumnFacts; + to: TransitionColumnFacts; + mergeBlockerReason: string | null; +} + +/** Discriminated decision. `allow:false` carries a JSON-safe {@link TransitionRejection}. */ +export type TransitionPolicyDecision = + | { allow: true } + | { allow: false; rejection: TransitionRejection }; + +const ALLOW: TransitionPolicyDecision = { allow: true }; + +// ── Trait classification (pure, flag-derived) ──────────────────────────────── + +/** A WIP column: cards here count against a capacity/WIP budget. */ +export function isWipColumn(flags: TraitFlags): boolean { + return flags.countsTowardWip === true; +} + +/** A terminal column: success-complete or archived. */ +export function isTerminalColumn(flags: TraitFlags): boolean { + return flags.complete === true || flags.archived === true; +} + +/** A completion (terminal-success) column. */ +export function isCompleteColumn(flags: TraitFlags): boolean { + return flags.complete === true; +} + +/** A passive dwell/hold column (release-condition gated). */ +export function isHoldColumn(flags: TraitFlags): boolean { + return flags.hold === true; +} + +/** + * KTD-2 classification: is this boundary a hold→wip crossing? The graph must NOT + * move on this boundary — it parks the card at the ready-for-release seam and the + * scheduler's capacity sweep is the sole actor that performs the hold→wip move. + * Two movers at the busiest seam means double-dispatch or deadlock. + */ +export function isHoldToWipBoundary(from: TraitFlags, to: TraitFlags): boolean { + return isHoldColumn(from) && isWipColumn(to); +} + +// ── Invariant postconditions ───────────────────────────────────────────────── + +/** + * Invariant 1: a card may not enter a `complete` column while carrying an + * unresolved merge blocker. Returns a rejection when `to` is a complete column + * and `mergeBlockerReason` is non-null; otherwise null. + */ +export function evaluateMergeBlockerPostcondition( + input: TransitionInvariantInput, +): TransitionRejection | null { + if (!isCompleteColumn(input.to.flags)) return null; + if (!input.mergeBlockerReason) return null; + return makeTransitionRejection( + "merge-blocked", + "transition.rejected.mergeBlocked", + true, + input.mergeBlockerReason, + ); +} + +/** + * Invariant 2: a card in a `complete`/`archived` column may not be moved into a + * WIP column (terminal work is not resurrected into active capacity). Returns a + * rejection when `from` is terminal and `to` is WIP; otherwise null. + */ +export function evaluateTerminalReentryPostcondition( + input: TransitionInvariantInput, +): TransitionRejection | null { + if (!isTerminalColumn(input.from.flags)) return null; + if (!isWipColumn(input.to.flags)) return null; + return makeTransitionRejection( + "guard-rejected", + "transition.rejected.terminalReentry", + false, + `Column '${input.from.columnId}' is terminal; a completed/archived card cannot re-enter the WIP column '${input.to.columnId}'`, + ); +} + +/** + * Capacity decision for a real column change into a capacity-bearing column + * (KTD-5/KTD-9). Pure: the caller supplies the effective `limit` and the live + * `occupants` count (moving task excluded, taken with the ONE workflow-capacity + * counter). A finite limit at or below the occupant count rejects; a non-finite + * limit never gates. + */ +export function evaluateCapacityRejection( + toColumnId: string, + capacity: CapacityFacts | null | undefined, +): TransitionRejection | null { + if (!capacity) return null; + const { limit, occupants } = capacity; + if (!Number.isFinite(limit)) return null; + if (occupants < limit) return null; + return makeTransitionRejection( + "capacity-exhausted", + "transition.rejected.capacityExhausted", + true, + `Column '${toColumnId}' is at capacity (${occupants}/${limit})`, + ); +} + +/** + * Evaluate the structural transition invariants (merge-blocker on complete entry, + * terminal→wip re-entry) as a single ordered return-guard. First rejection wins; + * otherwise `allow`. Capacity is NOT evaluated here because it needs an in-txn + * occupant count — the caller invokes {@link evaluateCapacityRejection} inside the + * move transaction after this passes. + */ +export function evaluateTransitionInvariants( + input: TransitionInvariantInput, +): TransitionPolicyDecision { + const mergeBlocked = evaluateMergeBlockerPostcondition(input); + if (mergeBlocked) return { allow: false, rejection: mergeBlocked }; + + const terminalReentry = evaluateTerminalReentryPostcondition(input); + if (terminalReentry) return { allow: false, rejection: terminalReentry }; + + return ALLOW; +}