Files
fusion/packages/core/src/default-workflow-hooks.ts
gsxdsm 31e49b684a TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)
Batched conversion of every lifecycle-column guard I hold, plus the
three the census could not see. **Six files to zero, repo-wide 60 → 49
by a comment-stripped unanchored sweep.** Each conversion has an
isolated revert proof and a paired negative case, and the one code move
is a separate commit from the behavior changes.

## Per-file before → after

Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']`
sweep over `packages/*/src` + `plugins/*/src`, excluding tests.

| file | before | after | note |
|---|---:|---:|---|
| `core/default-workflow-hooks.ts` | 4 | **0** | |
| `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror
converted; the flag-OFF inline block is the parity reference and stays |
| `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** —
see below |
| `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer
deliberately unchanged |
| `engine/replan-target.ts` | 2 | **0** | both were comment prose, not
guards |
| `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column
guards |
| `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons |
| `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column
guard |
| `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE |
| `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE |
| `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** |
ROLE |
| `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE |
| `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias
table, marked `DELIBERATE-LITERAL` with its reason |

## The census errs in BOTH directions

This is the finding I would most like carried into the remaining work.

- It **flagged 10 sites that were never column guards.** `role ===
"triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The
planner *lane* is named `triage` and keeps that name — U11 removed the
*column*. Worse than noise: the obvious "finish the migration" edit is
to rename the role, and that silently empties the planner's prompt
template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names
it, so the two vocabularies are distinguishable by grep and a rename
fails loudly (revert proof: 4 tests, two of them pre-existing).
- It **missed 3 real guards in `executor.ts`**, because the pattern
matches `column`/`toColumn`/`fromColumn` and those locals are named
`from` and `originColumn`. A census keyed on variable names will keep
missing guards wherever a local was named for its role in the function.

## Two real defects, not tidying

**1. A renamed board could merge with its re-review never run.**
`default-workflow-hooks.ts` is named for the default workflow, but the
store runs it on the flag-ON path for *every* workflow — the trait
registry resolves hooks by trait id, not by workflow. Its reopen
predicates listed the default lineage's column names, so on a renamed
board **no reopen effect fired at all**. One of them clears
`workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced
out of review carried its old `passed` result back in, and that
satisfies the merge gate. Same regression the graph-owned-crossing
carve-out exists to prevent, arriving through the other door. (Two
smaller ones rode along: failure state never cleared on a renamed
reopen, and an operator dragging a card back to the queue never parked
it, so the scheduler re-dispatched what they had just pulled back.)

**I forgot the carve-out on my first pass, and that was worse than not
converting.** A role-resolved clear plus a *name*-matched exemption
means a renamed board takes the clear and never the exemption,
destroying the remediation input the graph had just written. My own
paired negative test caught it.

**2. The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** In `recoverCompletedTask`,
`promotedFromPlannerColumn` was false on a renamed board, so finished
work resting in the planning lane was never promoted — the code fell
through to `handoffTaskToReview` straight from the planning column, and
role adjacency has no planning → review edge, so the handoff was
rejected and the card stayed stuck with its work complete. I converted
the promotion **target** too: resolving the lane and then moving to a
literal `in-progress` is the half-conversion I have already been burned
by twice this program, where the guard starts admitting cards and the
move then sends them to a column the board does not declare.

## E2E evidence

`renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and
a real `moveTask` on a workflow whose columns carry the standard traits
under non-default names. The unit tests cannot show this: if `moves.ts`
passed `undefined`, every unit case still passes via the no-basis
fallback while the real board keeps the old behavior. **Proof it is
load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of
3.** The executor suite covers both the split-role and the MERGED
post-U11 shape.

## Revert proofs, isolated per site

| change reverted | result |
|---|---|
| reopen predicate → literal names | 4 of 10 fail |
| reopen field clears → literal names | 2 of 10 fail |
| `userPaused` hold lane → literal `todo` | 1 of 10 fail |
| graph carve-out → literal names | 1 of 10 fail |
| store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| two-hop condition → `=== "triage"` | 1 of 7 fails |
| promotion target → `"in-progress"` | 3 of 7 fail |
| `isPlannerColumnFor` → literals | 1 of 7 fails |
| live-agent-count: one arm dropped | 2 of 11 fail |
| DocumentsView: trait branch removed | 3 of 7 fail |
| planner role renamed to `"planner"` | 4 fail (2 pre-existing) |

Every conversion is paired with a negative case (a forward move, a
not-a-planner-lane card, a default-lineage card, a renamed column with
no traits), so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Deliberately NOT converted, with reasons

- **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy
path, kept verbatim so the two can be parity-checked. Converting it
erases the reference implementation.
- **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is
nothing to resolve from — `enrich…FromFlags` exists for callers with
board flags rather than an IR, so a column missing from that map is the
renamed case. "Not intake" is as much a guess as "todo is intake", and
Running/Waiting are complements, so a card matching neither arm is
reported as neither and the footer's queued total under-reports it. The
real fix is at the caller; four new cases pin that flags override the
legacy answer **in both directions**. What did change is the
duplication: two hand-written copies of one rule now call one named
function.
- **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column
*names* where `triage` sits beside `signal` and `backlog`. Command
Center aggregates across projects, so there is no single workflow to
resolve traits from — the honest conversion is a data change, not a
predicate change.
- **`DocumentsView` with no traits.** Same no-basis rule; the documents
list is full of historical columns absent from the current board. A case
asserts a renamed column with no traits still reads as "working",
documenting the gap rather than hiding it.

## Fixture findings

Each cost a red run that looked like the code under test:

- a `merge-blocker` column needs a reachable merge-class node, or
`parseWorkflowIr` rejects the workflow;
- a back-edge must be `kind: "rework"`, and a rework edge is legal only
**into** a node with `config.reworkRegion: true`;
- a workflow gets role-level transitions only when it declares wip +
review + complete + **archived** plus a planning lane — without the
archived column, adjacency falls back to order-derived neighbours and
`checking -> queued` is not a legal move at all;
- `recoverCompletedTask` only *reaches* the promotion seam when nothing
is left to gate; without passed `plan-review`/`code-review` rows it
re-enters the workflow graph and returns first, so a naive fixture
silently tests the wrong branch and every assertion reads "no moves
happened" for an unrelated reason.

## Verification

- `pnpm test:gate` **71/71**
- new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real
PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4
planner-role-is-not-a-column
- neighbours: 132 + 10 + 482 (gate shards), 350/351 engine
planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector,
11/11 live-agent-count, 11/11 dashboard hook/log suites
- the single engine failure (`executor-fast-mode-workflows.test.ts` ›
"raw fast mode still invokes non-executable review seam nodes")
**reproduces with my changes stashed** — pre-existing on `origin/main`
- typechecks clean for core, engine, and dashboard-app
(`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`);
`pnpm lint` clean

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:39:14 -07:00

476 lines
22 KiB
TypeScript

/**
* Default-workflow trait hook implementations (U4).
*
* The legacy per-column side effects of `moveTaskInternal` — timing /
* `cumulativeActiveMs` accounting, reopen field/step resets, in-review
* auto-merge handoff preparation + merge-queue enqueue, and abort-on-exit
* (hard-cancel incl. `userPaused` only for user-source moves) — become the
* default workflow's trait hook
* implementations, registered through U2's DI seam (`registerTraitHookImpl`).
*
* IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in
* `store.ts` is NOT deleted — it IS the flag-off path. The implementations here
* are a deliberate parallel of that inline logic so the two paths can be parity-
* checked against each other; "moved, not duplicated" applies to the flag-ON
* path only.
*
* Hook classes (KTD-2):
* - guard (sync, in-lock): merge-blocker, human-review. Implemented as the
* `evaluateDefaultWorkflowGuards` reader; pure DB-free reads off the task.
* - onEnter / onExit (mutating, applied in-lock to the in-memory task before
* the commit for field effects; queue effects run in-txn): timing,
* reset-on-entry, abort-on-exit, merge.
*
* Worktree allocation is explicitly NOT a hook (it stays a substrate capability
* invoked before the move; see store.ts) — there is no `allocateWorktree` hook
* here by design.
*
* The hooks are registered into the shared trait registry on `init` via
* `registerDefaultWorkflowHooks()` (idempotent). They are resolved through
* `getTraitRegistry().resolveTraitHook(...)` so a missing registration degrades
* to a no-op + audit warning rather than crashing.
*/
import { getTraitRegistry } from "./trait-registry.js";
import type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
import type { TraitAuditWarning } from "./trait-registry.js";
import { getTaskMergeBlocker } from "./task-merge.js";
import type { Settings, Task } from "./types.js";
// ── Guard evaluation (sync, in-lock) ─────────────────────────────────────────
/** A guard verdict: undefined = allow; a string reason = reject. */
export type GuardVerdict = string | undefined;
/**
* Evaluate the default workflow's sync guards for a move. Reproduces the legacy
* `getTaskMergeBlocker` gate on `in-review → done`. (The default workflow does
* not carry the human-review trait — see the Trait Vocabulary note — so there
* is no human-review guard on this workflow.)
*
* `bypassGuards` (engine-sourced moves, KTD-9) skips guards entirely — the
* caller is responsible for honoring that; this function still computes the
* verdict so callers can choose. The store only consults it when not bypassing.
*/
export function evaluateMergeBlockerGuard(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
fromColumn: string,
toColumn: string,
): GuardVerdict {
if (fromColumn === "in-review" && toColumn === "done") {
return getTaskMergeBlocker(task);
}
return undefined;
}
// ── Move-effect context ───────────────────────────────────────────────────────
/** Side-effect callbacks the store provides so the hooks stay engine-free and
* DB-handle-free; the store wires these to its in-txn / post-commit machinery. */
export interface DefaultWorkflowMoveContext {
task: Task;
fromColumn: string;
toColumn: string;
moveSource: "user" | "engine" | "scheduler";
/*
FNXC:WorkflowReviewGates 2026-07-26-14:20:
Provenance of the move, distinct from `moveSource` (which only says user/engine/scheduler).
`"workflow-graph"` is set at exactly one call site — the graph column boundary in
`executor.buildColumnBoundaryHooks` — so it uniquely identifies a graph-owned lifecycle crossing
as opposed to an operator reopen, a merge bounce, or a self-healing rebound. Needed because the
pre-merge review gates now live in `in-review`, making graph-owned `in-review -> in-progress`
routine; see `applyReopenFieldClears`.
*/
workflowMoveSource?: string;
/** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */
bypassGuards: boolean;
movedAt: string;
/**
* Settings snapshot available to move effects that need it. Review entry must
* not copy global `autoMerge` onto the task; an undefined task value follows
* the live global setting at processing time.
*/
settings: Pick<Settings, "autoMerge"> | undefined;
/** Move options that influence reopen/timing semantics. */
options: {
preserveStatus?: boolean;
preserveResumeState?: boolean;
preserveProgress?: boolean;
preserveWorktree?: boolean;
preservePause?: boolean;
};
/**
* FNXC:WorkflowLifecycleColumns 2026-07-30-08:05 (Phase C convergence):
* The moving task's OWN lifecycle columns, resolved by trait from its workflow IR by
* the store (which already holds the IR on this path) and passed in because these
* hooks are sync and in-lock — they cannot resolve anything themselves.
*
* WHY THIS FILE NEEDED IT AT ALL. Its name says "default workflow", but the store
* runs these hooks on the flag-ON path for EVERY workflow — the trait registry
* resolves the hook by trait id, not by workflow. So the column names hard-coded
* here were the DEFAULT lineage's names being applied to a renamed board, where the
* reopen effects simply never fired. See `applyResetOnEntryEffects`.
*
* `undefined` means the workflow has no column vocabulary at all (v1 IR), which is
* NOT the same as "declares no hold column" — the hooks keep the legacy literals
* only in that no-basis case, never as a substitute for an absent role.
*/
lifecycleColumns?: LifecycleColumns | undefined;
/** Reset all steps to pending + currentStep 0 (store owns the impl). */
resetSteps: () => void;
}
// ── Field-mutation effects (applied in-lock, before commit) ───────────────────
//
// These mirror the inline flag-off mutations in store.ts exactly. They run as
// the resolved onEnter/onExit hook bodies for the default workflow's traits.
/** `timing` trait (in-progress): accumulate active ms on exit, stamp timing on
* entry.
*
* FNXC:WorkflowReviewGates 2026-07-26-16:20:
* SCOPE: `cumulativeActiveMs` measures time in WIP columns only — it is a sum of `in-progress`
* segments, closed on each exit. Since the pre-merge review gates moved into `in-review`, gate
* runtime is NOT included: the segment closes when the card crosses into review, and no new
* segment opens until remediation re-enters `in-progress`. Read it as "implementation time",
* not "wall clock from start to merge" — consumers that want the latter must use
* `executionStartedAt`/`executionCompletedAt`, which still span the whole run and therefore
* legitimately diverge from this sum.
* Deliberately NOT fixed by adding the `timing` trait to `in-review`: that column also holds the
* arbitrary human merge-wait, so counting it would overstate active time by hours of idle
* latency — a worse distortion than omitting the gate's own minutes. Attributing gate runtime
* properly needs node-scoped timing (a separate field), not a column trait.
* Consumers of this scope: `packages/core/src/productivity-analytics.ts`,
* `packages/core/src/task-timing.ts`, and the dashboard duration displays.
*/
export function applyTimingEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn } = ctx;
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? ctx.movedAt);
const segmentEndMs = Date.parse(task.columnMovedAt ?? ctx.movedAt);
const segmentDeltaMs =
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
}
if (toColumn === "in-progress") {
task.cumulativeActiveMs ??= 0;
if (!task.firstExecutionAt) task.firstExecutionAt = task.columnMovedAt;
if (!task.executionStartedAt) task.executionStartedAt = task.columnMovedAt;
task.userPaused = undefined;
}
}
/** Stamp `executionCompletedAt` on entry to a completion column. */
export function applyCompletionTimingEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, toColumn } = ctx;
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:05 (Phase C convergence — reopen semantics):
WHAT A "REOPEN" IS, stated once. A card leaving live work (wip / review / complete) for a
PLANNING lane (intake or hold). The three predicates below were each written as a list of
the default lineage's column names, which meant every reopen effect — status/error clear,
step reset, `workflowStepResults` clear, branch clear — was a no-op on any workflow that
renamed its columns.
THE CONSEQUENCE WAS NOT COSMETIC. `getTaskMergeBlocker` reads `workflowStepResults`; the
executor's documented bounce invariant is "moveTask(in-review -> planning) clears ALL
results". On a renamed board that clear never happened, so a card bounced out of review
and back in carried its OLD review results — and a `passed` result satisfies the merge
gate. A renamed workflow could merge with its re-review never run. That is the same
safety regression the graph-owned-crossing carve-out above was written to prevent,
arriving through the other door.
LEGACY IDS ARE A NO-BASIS FALLBACK, NOT A ROLE. When the struct is undefined (a v1 IR
with no column vocabulary) there is nothing to reason from and the legacy names are all
we have. When the struct EXISTS but a role is absent, the workflow genuinely has no such
lane and no substitution is made — that is the distinction `resolveLifecycleColumns`
returns `undefined`-for-the-whole-struct to preserve.
*/
const LEGACY_PLANNING_COLUMNS = ["todo", "triage"] as const;
const LEGACY_LIVE_WORK_COLUMNS = ["in-progress", "done", "in-review"] as const;
/** The planning lanes of THIS workflow: intake and hold. */
function planningColumnsOf(lifecycle: LifecycleColumns | undefined): readonly string[] {
if (!lifecycle) return LEGACY_PLANNING_COLUMNS;
return [lifecycle.intake, lifecycle.hold].filter((c): c is string => typeof c === "string");
}
/** The lanes a card is reopened OUT of: wip, review, complete. */
function liveWorkColumnsOf(lifecycle: LifecycleColumns | undefined): readonly string[] {
if (!lifecycle) return LEGACY_LIVE_WORK_COLUMNS;
return [lifecycle.wip, lifecycle.review, lifecycle.complete].filter(
(c): c is string => typeof c === "string",
);
}
/** `reset-on-entry` trait (reopen into a planning lane) + `abort-on-exit` userPaused
* semantics. Reproduces the legacy reopen block, by role rather than by name. */
export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn, moveSource, options } = ctx;
if (!isReopenIntoPlanning(ctx.lifecycleColumns, fromColumn, toColumn)) return;
/*
FNXC:WorkflowLifecycle 2026-07-12-09:05:
Pause-bounce loop (observed on FN-7851, 2026-07-12): a user pause of an in-progress task hard-cancels the session and the executor teardown re-queues the row to todo. This reopen block unconditionally wiped `paused`/`pausedByAgentId`/`pausedReason`, so the pause NEVER survived its own teardown — the graph-failure classifier then saw an unpaused row, misread the abort as engine-internal, and auto-continued the session (and after the retry budget, the scheduler re-dispatched the unpaused todo row). `preservePause` lets the pause-caused teardown move keep the park; the scheduler skips paused/userPaused todo rows until an explicit unpause.
`userPaused` promotion for user-source moves is unchanged; preservePause only prevents CLEARING an existing park, never sets one.
*/
if (!options.preserveStatus) {
task.status = undefined;
task.error = undefined;
if (!options.preservePause) {
task.pausedReason = undefined;
}
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
if (!options.preservePause) {
task.paused = undefined;
task.pausedByAgentId = undefined;
}
/*
abort-on-exit userPaused: only for user-source moves to the HOLD lane (KTD-9).
FNXC:WorkflowLifecycleColumns 2026-07-30-08:05: `todo` was the hold lane's name on the
pre-U11 default lineage and is still its id post-U11 (#2515 merged Todo into Planning
keeping `todo`), so this reads as hold-then-intake. The role matters, not the name: an
operator dragging a card back to the queue is parking it, and on a renamed board that
park silently stopped happening — the scheduler then re-dispatched the card the
operator had just pulled back.
*/
const holdLane = ctx.lifecycleColumns
? ctx.lifecycleColumns.hold ?? ctx.lifecycleColumns.intake
: "todo";
if (moveSource === "user" && toColumn === holdLane) {
task.userPaused = true;
} else if (!options.preservePause) {
task.userPaused = undefined;
}
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options.preserveResumeState || (options.preserveProgress === true && hasNonPendingStepProgress);
if (!options.preserveWorktree) {
task.worktree = undefined;
}
if (!options.preserveResumeState) {
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
} else {
task.executionCompletedAt = undefined;
}
if (!preserveStepProgress) {
ctx.resetSteps();
// Prompt-checkbox reset is a filesystem effect; the store performs it
// post-hook (it owns the task dir). Not modeled here.
}
}
/**
* Is this move a reopen — live work (wip/review/complete) back into a planning lane
* (intake/hold)?
*
* FNXC:WorkflowLifecycleColumns 2026-07-30-08:05: EXPORTED so the store's flag-ON
* `preserveStepProgress` mirror asks the same question. Those two predicates were
* separately hand-written copies of the same column list ("Parity mirror of the gate in
* applyReopenFieldClears"), and a hand-copied predicate is a divergence waiting for
* whichever copy the next edit misses. One function cannot disagree with itself.
*/
export function isReopenIntoPlanning(
lifecycle: LifecycleColumns | undefined,
fromColumn: string,
toColumn: string,
): boolean {
return liveWorkColumnsOf(lifecycle).includes(fromColumn)
&& planningColumnsOf(lifecycle).includes(toColumn);
}
/** `merge` trait onEnter (in-review): scheduler-state clearing while
* preserving explicit per-task autoMerge overrides. The queue enqueue itself is
* in-txn and store-owned (handoff path); the field effects mirror the legacy
* in-review block. Keep this flag-ON path in sync with the flag-OFF inline
* block in store.ts. */
export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, toColumn } = ctx;
if (toColumn !== "in-review") return;
// Do not snapshot the global autoMerge setting here. Undefined means "follow
// the live global setting"; only an explicit task value should stay sticky.
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
if (task.status === "queued") {
task.status = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
}
/** Reopen-from-review/done field clears (branch/summary/workflowStepResults). */
export function applyReopenFieldClears(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn } = ctx;
/*
FNXC:WorkflowReviewGates 2026-07-26-14:25:
The GRAPH's own in-review -> in-progress crossing must NOT wipe `workflowStepResults`.
Since the pre-merge review gates moved into `in-review`, entering the paired remediation node
(in-progress) is a routine graph-owned crossing that happens immediately after the gate wrote its
`failed` result — so the ungated clear destroyed the remediation input. Three concrete breakages:
- `routeRetryableRemediationGraphFailureToPreMergeFix` and `recoverFailedPreMergeWorkflowStep`
select via `latestFailedPreMergeWorkflowStep` and silently no-op on an empty array, so the
auto-recovery for a parked remediation failure never fires.
- `getTaskMergeBlocker` reads pending/failed results; an empty array makes both branches
vacuously false, so a card can return to `in-review` and be MERGEABLE with its gate never
re-run. That is a safety regression, not just lost history.
- FN-7727 `priorAttempts` history restarts at attempt zero every remediation cycle.
Scoped deliberately to the graph-owned in-progress crossing: operator board drags, the in-review
comment re-engagement, merge bounces, and every `-> todo`/`-> triage` rebound still clear, so the
executor's documented bounce invariant ("moveTask(in-review->todo) already clears ALL results")
survives unchanged.
*/
const lifecycle = ctx.lifecycleColumns;
const planning = planningColumnsOf(lifecycle);
const reviewLane = lifecycle ? lifecycle.review : "in-review";
const wipLane = lifecycle ? lifecycle.wip : "in-progress";
const completeLane = lifecycle ? lifecycle.complete : "done";
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-08:30 (Phase C convergence):
THE CARVE-OUT MUST BE RESOLVED TOO, and forgetting it was worse than leaving the whole
function alone. A role-resolved clear plus a NAME-matched exemption means the renamed
board takes the clear and never the exemption — so the graph's own remediation crossing
destroyed the `failed` result it had just written, which is precisely the three breakages
the note above enumerates. My own paired negative test caught this; a conversion that
moves the rule and leaves its exception behind inverts the exception.
*/
const graphOwnedReviewToWip = ctx.workflowMoveSource === "workflow-graph"
&& fromColumn === reviewLane
&& toColumn === wipLane;
const leftReviewForPlanningOrWip =
fromColumn === reviewLane && (planning.includes(toColumn) || toColumn === wipLane);
const leftCompleteForPlanning = fromColumn === completeLane && planning.includes(toColumn);
if (!graphOwnedReviewToWip && (leftReviewForPlanningOrWip || leftCompleteForPlanning)) {
task.workflowStepResults = undefined;
}
if (fromColumn === reviewLane && planning.includes(toColumn)) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
}
/**
* Apply ALL default-workflow field-mutation move effects (the parallel of the
* legacy inline block) in the legacy order. Pure in-memory mutation of
* `ctx.task`; queue/filesystem/post-commit effects remain store-owned.
*
* This is the entry point the flag-ON store path calls. It resolves each
* trait's hook through the registry first (so a missing registration degrades to
* a no-op + audit warning, satisfying the "invokes through the registry"
* contract and the degraded-hook path); resolution warnings are collected and
* returned for the store to forward to audit.
*/
export function applyDefaultWorkflowMoveEffects(
ctx: DefaultWorkflowMoveContext,
): { warnings: TraitAuditWarning[] } {
const registry = getTraitRegistry();
const warnings: TraitAuditWarning[] = [];
// Resolve the hooks through the registry. The resolved impls are the closures
// registered by registerDefaultWorkflowHooks(); resolution surfaces a warning
// (and a no-op) if a registration is missing.
const toRun: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = [
{ traitId: "timing", hookKind: "onExit" },
{ traitId: "timing", hookKind: "onEnter" },
{ traitId: "reset-on-entry", hookKind: "onEnter" },
{ traitId: "abort-on-exit", hookKind: "onExit" },
{ traitId: "merge", hookKind: "onEnter" },
];
for (const { traitId, hookKind } of toRun) {
const { impl, warning } = registry.resolveTraitHook(traitId, hookKind);
if (warning) warnings.push(warning);
if (impl) impl(ctx);
}
return { warnings };
}
// ── Registration into the trait registry (DI seam) ───────────────────────────
let registered = false;
/**
* Register the default-workflow hook implementations into the shared trait
* registry. Idempotent. Called at store init (the store is the engine-adjacent
* owner of the move lifecycle). Each registration is a thin adapter that runs
* the corresponding field-effect function over the move context.
*
* The legacy effects map onto traits as:
* timing.onExit / timing.onEnter → applyTimingEffects + completion stamp
* reset-on-entry.onEnter → applyResetOnEntryEffects + reopen clears
* abort-on-exit.onExit → (userPaused handled in reset-on-entry;
* session abort is an engine effect U6/U7)
* merge.onEnter → applyInReviewEnterEffects
*/
export function registerDefaultWorkflowHooks(): void {
if (registered) return;
const registry = getTraitRegistry();
const cast = (fn: (ctx: DefaultWorkflowMoveContext) => void) =>
((...args: unknown[]) => fn(args[0] as DefaultWorkflowMoveContext)) as (
...args: unknown[]
) => unknown;
registry.registerTraitHookImpl(
"timing",
"onExit",
cast((ctx) => {
applyTimingEffects(ctx);
}),
);
registry.registerTraitHookImpl(
"timing",
"onEnter",
cast((ctx) => {
applyCompletionTimingEffects(ctx);
}),
);
registry.registerTraitHookImpl(
"reset-on-entry",
"onEnter",
cast((ctx) => {
applyResetOnEntryEffects(ctx);
applyReopenFieldClears(ctx);
}),
);
registry.registerTraitHookImpl(
"abort-on-exit",
"onExit",
cast(() => {
// userPaused is set in applyResetOnEntryEffects (the legacy ordering keeps
// it with the reopen block). Session-abort wiring is an engine effect that
// lands with U6/U7; here it is intentionally a no-op so the resolved hook
// exists (not a missing-impl warning) while carrying no field mutation.
}),
);
registry.registerTraitHookImpl(
"merge",
"onEnter",
cast((ctx) => {
applyInReviewEnterEffects(ctx);
}),
);
registered = true;
}
/** Test-only: allow re-registration after a registry reset. */
export function __resetDefaultWorkflowHooksForTests(): void {
registered = false;
}