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>
476 lines
22 KiB
TypeScript
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;
|
|
}
|