refactor(cutover 1/3): core — IR-driven lifecycle foundation (#2341)

Part **1 of 3** of the IR-driven lifecycle cutover (split from #2335 to
fit review-tool file limits; plan:
docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md,
included here).

**Scope (48 files, packages/core + docs/plans):** shared transition
policy + validator (KTD-5), IR validation hardening incl. the benchmark
capability floor, CAS review leases (KTD-4), pooled WIP capacity budgets
(KTD-9), lifecycle-trait helpers, durable IR pin/drift detection
(KTD-3), review-level creation-time preset, legacy adoption module +
census + migration 0026 + stale-binary guard (KTD-8), core-side builtin
workflow fixes (single default-IR authority, no-merge complete-column
support).

Note: `workflow-cutover.ts` (interpreter parity scaffolding) stays alive
in this PR — its last consumer dies in part 2/3, which retires it.

**Merge order:** this PR → #TBD-2 (engine) → #2335 (dashboard/top).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added workflow-trait-driven task lifecycle transitions, including WIP
capacity pooling and workflow-aware recovery (IR pinning + drift
detection).
* Added legacy adoption/backfill for pre-cutover task states, with
unmappable rows safely parked.
* Added create-time `reviewLevel` presets to automatically configure
enabled workflow steps.
* **Bug Fixes**
  * Fixed workflow moves when no workflow selection exists.
* Improved merge-blocker validation to be keyed to the workflow’s actual
review-lane identity, preventing invalid moves and misclassified
terminal states.
* **Tests**
* Added end-to-end and unit/integration coverage for workflow
validation, legacy adoption, migrations/schema guards, leases, review
presets, and transition rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-19 11:50:52 -07:00
committed by GitHub
parent c0eafc2fce
commit 05a02e8061
47 changed files with 4967 additions and 835 deletions

View File

@@ -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`.

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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 `?? <prev>`.
### 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`.

View File

@@ -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.
**`<this commit>` — 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.

View File

@@ -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 |
| `<this>` | 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.

View File

@@ -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<void> {
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/,
);
});
});

View File

@@ -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" ...)`, `<taskExpr>.status = "X"`, and
* `{ status: "X" ... } as ...Partial<Task>` — 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<string> {
const found = new Set<string>();
/*
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 `<nonTask>.status = "X"` write
can be a task row, and every real task write today goes through
updateTask/createTask/`as Partial<Task>` 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,
// <taskExpr>.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<Task
/\{\s*status:\s*"([a-z][a-z-]*)"[\s\S]{0,200}?\}\s*as\s*(?:unknown\s*as\s*)?Partial<Task/g,
];
for (const src of sources) {
for (const re of patterns) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) found.add(m[1]);
}
}
return found;
}
describe("KTD-8 adoption table — write-site census completeness (build-failing)", () => {
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<Partial<Task> & { 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<Task>) => {
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<Partial<Task> & { 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();
});
});

View File

@@ -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");
});
});

View File

@@ -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,
]);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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<Record<string, LegacyAdoptionAction>> = {
// ── 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<LegacyAdoptionPlan["patch"]> = { 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<T extends { stepIndex?: number; status?: string }>(
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 };
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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" },

File diff suppressed because it is too large Load Diff

View File

@@ -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),

View File

@@ -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) };
}

View File

@@ -1212,7 +1212,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
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<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; 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<string, unknown> | 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<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; 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<string, unknown> | 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<Task> {
return updateTaskImpl(this, id, updates, runContext);
}

View File

@@ -238,9 +238,20 @@ export const TASK_DONE_BYPASS_BLOCKER_MESSAGE =
*/
export function getTaskMergeBlocker(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
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'`;
}

View File

@@ -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);
});
});

View File

@@ -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<void> {
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<void> {
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<boolean> {
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<void> {
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<number> {
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;

View File

@@ -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<number>;
}): void | Promise<void> {
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<void> => {
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<Task> {
// 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})`,
);
}
}
}

View File

@@ -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),

View File

@@ -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",

View File

@@ -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<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; 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<string, unknown> | 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<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; 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<string, unknown> | 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<Task> {
/*
FNXC:StateMachine 2026-07-07-12:00:

View File

@@ -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,

View File

@@ -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<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> {
// 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<string | null>; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; }): Promise<Task> {
// 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<Task> {
// 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");
}

View File

@@ -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 <N>ms` entries before
// returning. The log itself is stripped from the response —

View File

@@ -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) {

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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];

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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). */

View File

@@ -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<WorkflowIrNodeKind> = 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<WorkflowIrNodeKind> = 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<string, WorkflowIrEdge[]>,
): Set<string> {
const reachable = new Set<string>([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<string, WorkflowIrEdge[]>,
): 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<string, WorkflowIrEdge[]>,
@@ -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));

View File

@@ -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;
}

View File

@@ -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<WorkflowStepResult["status"]> = 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,
};
}

View File

@@ -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;
}