Phase A (Foundation) of
`docs/plans/2026-07-26-001-refactor-workflow-owned-lifecycle-plan.md`.
Three units, one commit each. No operator-visible behavior change.
## U1 — Lifecycle-column resolution seam
`resolveLifecycleColumns(ir)` returns `{ intake, hold, wip, review,
complete, archived }` — the first column carrying each trait,
`undefined` for a role no column carries.
`resolveTaskLifecycleColumns(store, taskId, cache?)` is the store-aware
form; the cache is caller-owned so a sweep reads one IR per workflow
rather than one per card.
A v1/column-less IR resolves to `undefined` for the **whole struct**
rather than a struct of undefined roles. A caller must be able to
distinguish "this workflow declares no hold column" (a real shape to
honor) from "no column vocabulary at all" (skip and log) — only the
second licenses conservative fallback.
Nothing consumes the seam yet; Phases B–D convert the ~207 hardcoded
column literals onto it.
## U2 — Delete the pre-cutover parity machinery (delete-only)
**`workflow-columns-settings.ts`** — `isWorkflowColumnsEnabled` had the
body `return true`. Six live call sites branched on it, so every
flag-OFF arm was dead code that read as a supported configuration.
Deleted; surviving side inlined at self-healing's transitionPending
sweep, the scheduler's per-column capacity diagnostic, merge-trait's
policy resolver, the board-workflows payload, two task-workflow routes,
and the CLI TUI's column enrichment.
**`workflow-parity.ts`** — asserted the default workflow's adjacency
*equals* the legacy `VALID_TRANSITIONS`. U11 deliberately breaks that
equality by merging Todo into Planning, so this is not a stale assertion
to update; it is a contract against the target state. Its emitter
(`workflow-parity-observer.ts`) is already a tombstone, so
`getWorkflowParitySummary` and `computeWorkflowColumnsGraduationReport`
aggregated run-audit rows nothing writes and had no caller outside
`TaskStore`. Both store methods go with it.
`flagEnabled` stays on the board-workflows **wire** as a constant `true`
— shipped dashboard clients still branch on it, and changing the
response shape is not a deletion. U10 retires the field once no client
reads it.
The `legacy-tombstones` ratchet is extended to both files plus seven
symbols, each with the reason it is gone.
### ⚠️ Finding: the third listed deletion was NOT dead
The plan also lists "the flag-off inline move path" in
`task-store/moves.ts`. It is **not** deleted, per U2's execution note
("any behavior change found while removing a branch means the branch was
not dead").
That path is gated on `isWorkflowColumnsCompatibilityFlagEnabled`
(`store.ts:38`) — a **different** function from the always-true public
helper. It reads the raw `experimentalFeatures.workflowColumns` setting,
which nothing in production sets (`settings-schema.ts:396` — "no default
flags are emitted"; zero non-test writers; the operator's own
`~/.fusion/settings.json` has no such key). So `useWorkflow` is false
for effectively every real project: the flag-OFF inline side effects are
the **live** default move path and the flag-ON `default-workflow-hooks`
path is the dead one. The code says so itself at `moves.ts:638`.
Deleting that branch would swap every project onto an untravelled code
path — a behavior change, not a deletion.
**Carry this into Phases B and C, stated plainly so the plan's error is
not repeated:**
> **The inline move path in `moves.ts` is LIVE.
`default-workflow-hooks.ts` (the trait-hook path) is DEAD.** KTD-6
asserted the inverse. Until the convergence unit lands, **nothing may
assume trait hooks run** — a guard, sweep, or subscriber written against
`applyDefaultWorkflowMoveEffects` would never fire in production and
would still pass its tests.
Convergence is **not** attempted here. It is its own unit (Phase A2)
with a proper equivalence proof, per operator decision.
### U3's emit point is on the LIVE path — the seam is not born dead
Worth stating explicitly because it is the failure mode that would make
every later subscriber silently never fire: the `TaskTransitioned` emit
is **not** inside the `if (useWorkflow)` branch. That block closes at
`moves.ts:1212`; the emit sits at `:1214`, beside the existing
`store.emit("task:moved", …)`, on the unconditional post-commit path. It
therefore fires on **both** the live inline path and the dead hooks
path, and the convergence unit inherits the obligation to keep it firing
on whichever path survives — same events, same order, same payloads.
The graph-side emitters (`NodeEntered`, `RunSuspended`) carry the same
risk from a different direction: the bus refuses an invalid payload
*silently* by design, so an emitter regression would stop the event with
no test failure. They are asserted end-to-end through the real bus —
"did a subscriber actually receive it", not "was emit called" — because
a spy passes on a refused payload. The `moveTaskInternalImpl` emit does
**not** yet have that end-to-end assertion against a real store move;
that proof belongs to the convergence unit, which has to build the
both-paths fixture anyway.
## U3 — Post-commit event seam with a transactional outbox
**The bus is not a queue, not a transaction participant, and not a
delivery guarantee.** Durable follow-on work uses the transactional
outbox — a `workflow_work_items` row written *inside* the transition
transaction (the shape `createCompletionHandoffWorkflowWork` already
uses). "Emit after commit, let a subscriber enqueue the work" has a
crash window where a process dies between commit and subscriber, leaving
no event *and* no work-item row, so required work is skipped permanently
with nothing to recover from. Post-commit subscribers therefore carry
only losable reactions.
Emission is consequently lossy and isolated by design: a throwing or
rejecting subscriber is caught and logged, cannot roll back the
transition, and cannot stop the others. Deliveries append to one serial
chain, so two transitions on a task deliver in commit order.
The ids/outcomes-only rule is **mechanised, not documented** —
run-audit's equivalent lives only in prose and has been violated
repeatedly. A payload carrying an object body or a prose string is
refused at the emit boundary and never reaches a subscriber or log sink.
It degrades rather than throws: the emitter is post-commit, so a shape
bug must not become a lifecycle failure.
Emit points: `TaskTransitioned` from the single post-commit point in
`moveTaskInternalImpl`; `NodeEntered` and `RunSuspended` from the graph
column boundary, the latter *after* the durable continuation is
persisted so an observed suspension implies a resumable run.
`registerWorkflowEventSubscribers` (engine) is empty on purpose —
U7/U8/U10 move real reactions onto it, each with the characterization
test proving the reaction was non-authoritative first.
## Verification
- `pnpm test:gate` — green (2/10, 16/299, 1/71).
- `pnpm lint`, `pnpm build`, `tsc --noEmit` on core and engine — green.
- U1: 20 tests in `workflow-lifecycle-traits.test.ts`, including the
fully-renamed-workflow case (fails if the resolver falls back to a
literal) and a shared-cache read-count assertion.
- U2: `legacy-tombstones.test.ts` green with the extended ratchet;
`board-workflows`, `merge-trait`, `workflow-graph-executor-parity`, and
move-hook suites green with no expectation edits.
- U3: 20 bus-invariant unit tests (isolation, ordering, the allowed-key
and required-key halves of the ids-only rule, lossiness) plus 3
end-to-end emitter-delivery tests; 5 outbox tests against a **real
PostgreSQL** work-item table (crash survival, rollback, at-least-once
redelivery on lease expiry, idempotent handler → one effect,
dropped-subscriber vs. durable work). A hand-written fake of the lease
predicate would only prove the fake redelivers.
**Not verified:** the `moveTaskInternalImpl` emit is confirmed on the
unconditional post-commit path by structure and by the surrounding
tests, but is *not* yet asserted end-to-end against a real store move on
both flag settings — that is Phase A2's fixture. The engine subscriber
registry ships empty by design, so no production subscriber exercises
the bus end-to-end yet. `settings-defaults.test.ts` has one pre-existing
failure on `main` (a logger-prefix mismatch in the
`mergeIntegrationWorktree=cwd-main` warning) — confirmed present on a
clean tree, unrelated to this branch.
🤖 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**
* Workflow lifecycle columns are now derived from workflow definitions,
supporting renamed and custom workflows.
* Added post-commit lifecycle events for task transitions, node entry,
and run suspend/resume with validated payloads.
* Follow-on processing for lifecycle emissions is now more robust
(rollback-safe, at-least-once delivery, idempotent handling).
* **Bug Fixes**
* Workflow board responses, task enrichment, and promotion no longer
depend on workflow-columns feature-flag gating.
* Subscriber failures no longer impact committed workflow transitions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/*
|
|
FNXC:WorkflowEvents 2026-07-27-11:20 (U3 / R5, R6 — workflow-owned lifecycle):
|
|
THE post-commit lifecycle bus: one place transitions are announced, one registry
|
|
of subscribers. Later units move imperative cross-service calls behind it —
|
|
today `executor.ts` is 21k lines largely because it is the junction box every
|
|
lane routes its reactions through.
|
|
|
|
WHAT THIS BUS IS NOT. It is not a queue, not a transaction participant, and not
|
|
a delivery guarantee. Durable follow-on work uses the TRANSACTIONAL OUTBOX —
|
|
a `workflow_work_items` row written INSIDE the transition transaction (the shape
|
|
`createCompletionHandoffWorkflowWork` already uses). "Emit after commit, let a
|
|
subscriber enqueue the work" has a crash window: a process that dies between the
|
|
commit and the subscriber leaves no event AND no work-item row, so required work
|
|
is skipped permanently with nothing to recover from. Writing the item in the
|
|
transaction closes that window — a crash between commit and emit then costs at
|
|
most a notification.
|
|
|
|
Consequently EMISSION IS DELIBERATELY LOSSY AND ISOLATED:
|
|
- a throwing subscriber is caught, logged, and cannot roll back the
|
|
transition or stop the other subscribers;
|
|
- a rejected async subscriber is caught the same way;
|
|
- emission never blocks the caller's return, but events are DELIVERED IN
|
|
COMMIT ORDER (see the serial chain below).
|
|
|
|
ORDERING. Subscribers may be async, so a naive `for (…) void fn(e)` would let a
|
|
slow subscriber on transition #1 interleave with transition #2. Every emit is
|
|
appended to a single promise chain, so subscriber invocations for two transitions
|
|
on one task run in the order the transitions committed. That property is what
|
|
lets a subscriber maintain derived state (a board projection, a counter) without
|
|
its own sequencing.
|
|
|
|
TESTABILITY. `emit` is fire-and-forget; `drain()` awaits the current chain so a
|
|
test can assert on delivery without polling. Production code must not await
|
|
`drain()` on a lifecycle path — doing so would make a subscriber able to slow a
|
|
transition, which is the coupling the bus exists to remove.
|
|
*/
|
|
|
|
import { createLogger } from "./logger.js";
|
|
import {
|
|
findWorkflowEventShapeViolations,
|
|
type WorkflowLifecycleEvent,
|
|
} from "./types/workflow-events.js";
|
|
|
|
const eventLog = createLogger("workflow-events");
|
|
|
|
/** A post-commit reaction. Must not perform a lifecycle transition (R5). */
|
|
export type WorkflowEventSubscriber = (event: WorkflowLifecycleEvent) => void | Promise<void>;
|
|
|
|
export interface WorkflowEventSubscription {
|
|
/** Diagnostic name used in isolation warnings; defaults to "anonymous". */
|
|
name?: string;
|
|
}
|
|
|
|
export interface WorkflowEventBus {
|
|
/** Register a subscriber. Returns an unsubscribe function. */
|
|
subscribe(subscriber: WorkflowEventSubscriber, options?: WorkflowEventSubscription): () => void;
|
|
/** Announce a COMMITTED seam. Fire-and-forget; never throws. */
|
|
emit(event: WorkflowLifecycleEvent): void;
|
|
/** Await delivery of everything emitted so far. Test/shutdown seam only. */
|
|
drain(): Promise<void>;
|
|
/** Drop every subscriber. */
|
|
clear(): void;
|
|
subscriberCount(): number;
|
|
}
|
|
|
|
export function createWorkflowEventBus(): WorkflowEventBus {
|
|
const subscribers = new Map<symbol, { fn: WorkflowEventSubscriber; name: string }>();
|
|
// The serial delivery chain — see ORDERING above.
|
|
let chain: Promise<void> = Promise.resolve();
|
|
|
|
const deliver = async (event: WorkflowLifecycleEvent): Promise<void> => {
|
|
// Snapshot: a subscriber that unsubscribes (or one registered) mid-delivery
|
|
// must not mutate the iteration for the event already in flight.
|
|
for (const { fn, name } of [...subscribers.values()]) {
|
|
try {
|
|
await fn(event);
|
|
} catch (err) {
|
|
/*
|
|
FNXC:WorkflowEvents 2026-07-27-11:25 (U3 / R5):
|
|
ISOLATION. Logged and swallowed — never rethrown. A subscriber is a
|
|
reaction; letting one fail the emit would let a plugin's bug become a
|
|
lifecycle fault, and the transition it is reacting to has ALREADY
|
|
committed, so there is nothing left to roll back even if we wanted to.
|
|
*/
|
|
eventLog.warn("workflow event subscriber threw (isolated)", {
|
|
phase: "workflow-events:deliver",
|
|
subscriber: name,
|
|
eventType: event.type,
|
|
taskId: event.taskId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
subscribe(subscriber, options) {
|
|
const key = Symbol("workflow-event-subscriber");
|
|
subscribers.set(key, { fn: subscriber, name: options?.name ?? "anonymous" });
|
|
return () => {
|
|
subscribers.delete(key);
|
|
};
|
|
},
|
|
|
|
emit(event) {
|
|
/*
|
|
FNXC:WorkflowEvents 2026-07-27-11:30 (U3):
|
|
The ids-only rule is enforced at the EMIT boundary, not at the subscriber,
|
|
so a violating payload never reaches a plugin or a log sink. It degrades
|
|
rather than throws: the emitter is on a post-commit path where throwing
|
|
would surface a shape bug as a lifecycle failure. The test suite asserts
|
|
the violation is DETECTED; production merely refuses to forward it.
|
|
*/
|
|
const violations = findWorkflowEventShapeViolations(event);
|
|
if (violations.length > 0) {
|
|
eventLog.warn("workflow event dropped — payload is not ids/outcomes-only", {
|
|
phase: "workflow-events:emit",
|
|
eventType: (event as { type?: string })?.type,
|
|
violations: violations.map((v) => `${v.path}:${v.reason}`),
|
|
});
|
|
return;
|
|
}
|
|
if (subscribers.size === 0) return;
|
|
chain = chain.then(() => deliver(event));
|
|
},
|
|
|
|
drain() {
|
|
return chain;
|
|
},
|
|
|
|
clear() {
|
|
subscribers.clear();
|
|
},
|
|
|
|
subscriberCount() {
|
|
return subscribers.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowEvents 2026-07-27-11:35 (U3):
|
|
Process-global default bus. The emitters (`moveTaskInternalImpl`, the graph's
|
|
column-boundary controller) are deep inside call paths with no place to thread a
|
|
bus handle, and the subscribers (engine lane services, plugins) register at
|
|
process start — the same shape as the existing `store.on/off` seam this bus
|
|
generalises. A per-store bus would need plumbing through every one of those call
|
|
sites for no isolation benefit: subscribers are already isolated from each other
|
|
and from the transition.
|
|
|
|
`resetWorkflowEventBusForTesting` exists so a suite cannot leak subscribers into
|
|
the next one; production must never call it.
|
|
*/
|
|
let globalBus: WorkflowEventBus | undefined;
|
|
|
|
export function getWorkflowEventBus(): WorkflowEventBus {
|
|
globalBus ??= createWorkflowEventBus();
|
|
return globalBus;
|
|
}
|
|
|
|
/** Emit onto the global bus. The single call the emitters use. */
|
|
export function emitWorkflowLifecycleEvent(event: WorkflowLifecycleEvent): void {
|
|
getWorkflowEventBus().emit(event);
|
|
}
|
|
|
|
/** @internal test-only — drop all subscribers and reset the delivery chain. */
|
|
export function resetWorkflowEventBusForTesting(): void {
|
|
globalBus = createWorkflowEventBus();
|
|
}
|