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:
@@ -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`.
|
||||
122
docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md
Normal file
122
docs/plans/2026-07-19-001-u5e-executecore-lift-handoff.md
Normal 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).
|
||||
129
docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md
Normal file
129
docs/plans/2026-07-19-002-u5e-remaining-deletions-handoff.md
Normal 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).
|
||||
148
docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md
Normal file
148
docs/plans/2026-07-19-003-u5f-workflow-aware-flip-handoff.md
Normal 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).
|
||||
111
docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md
Normal file
111
docs/plans/2026-07-19-004-u5g-flip-residual-handoff.md
Normal 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`.
|
||||
125
docs/plans/2026-07-19-005-u10-flip-residual-handoff.md
Normal file
125
docs/plans/2026-07-19-005-u10-flip-residual-handoff.md
Normal 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.
|
||||
129
docs/plans/2026-07-19-006-u10b-post-flip-handoff.md
Normal file
129
docs/plans/2026-07-19-006-u10b-post-flip-handoff.md
Normal 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.
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
454
packages/core/src/__tests__/legacy-adoption.test.ts
Normal file
454
packages/core/src/__tests__/legacy-adoption.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
75
packages/core/src/__tests__/review-level-preset.test.ts
Normal file
75
packages/core/src/__tests__/review-level-preset.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
299
packages/core/src/__tests__/workflow-ir-validation.test.ts
Normal file
299
packages/core/src/__tests__/workflow-ir-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
packages/core/src/__tests__/workflow-lifecycle-traits.test.ts
Normal file
120
packages/core/src/__tests__/workflow-lifecycle-traits.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
261
packages/core/src/legacy-adoption.ts
Normal file
261
packages/core/src/legacy-adoption.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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),
|
||||
|
||||
57
packages/core/src/review-level-preset.ts
Normal file
57
packages/core/src/review-level-preset.ts
Normal 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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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));
|
||||
|
||||
86
packages/core/src/workflow-lifecycle-traits.ts
Normal file
86
packages/core/src/workflow-lifecycle-traits.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
181
packages/core/src/workflow-transition-policy.ts
Normal file
181
packages/core/src/workflow-transition-policy.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user