Files
fusion/packages/engine/src/runtime-primitives.ts
gsxdsm 9366bc8382 fix(workflow): the review handoff killed the walk on a renamed review lane (#2900)
The sharpest lane defect left in the backlog, and the one I have been
deferring since the first sweep.

```ts
if (seam === "review-handoff") {
  const result = await primitives.transitionTask(primitiveCtx, context.task, {
    column: "in-review",   // ← post-U12 this is a rejected destination on a renamed board
```

Post-U12 `moveTask` **rejects** a destination the workflow does not
declare. So on any board with a renamed review lane, the handoff threw
`TransitionRejectionError` and **killed the workflow walk mid-run**. Not
a silent wrong answer for once — a hard failure in the middle of a task,
which is why it outranked everything else once it became reachable.

**Why it was deferred:** every fix threads a resolver out of
`executor.ts`, and #2820 was editing that file. It merged at 22:08, so
this was finally free of the conflict.

## The role travels, not the column

Seam handlers in `workflow-node-handlers.ts` are pure functions over an
IR node and a task — no store, no task id to resolve from — so a handler
can only ever name a literal. The runtime primitive in `executor.ts`
**does** hold the store, so the seam now asks for `columnRole: "review"`
and the primitive resolves it against the task's **own** selection.

One authority, deliberately. Answering one question with two reads is
what took #2843 five review rounds, and I would rather not relearn it
here.

Compatibility is preserved in both directions:

- `column` still wins when both are supplied — an explicit destination
is an explicit destination;
- an unresolvable role falls back to the legacy `in-review` rather than
failing the transition, which is exactly the behaviour every caller had
before.

## The test asserts the literal is *gone*, not merely accompanied

`column` takes precedence over `columnRole` downstream, so a diff that
added the role while leaving the literal would look converted and be
completely inert. That is the exact shape this program keeps finding — a
documented fallback in front of a literal that still decides everything
— so the assertion is:

```ts
expect(input.columnRole).toBe("review");
expect(input.column).toBeUndefined();   // ← the half that matters
```

**Revert proof, measured:** restore `column: "in-review"` in the seam
and it fails with `expected undefined to be 'review'`.

## Verification

- `pnpm test:gate` — 161 / 487 / 13 / 71 passed
- `pnpm lint` — clean
- `tsc --noEmit` (`@fusion/engine`) — clean
- new `review-handoff-lane.test.ts` plus the two neighbouring seam
suites — 41 passed

Carries the one-line SQL-baseline re-record (`team-analytics.ts: 6 → 3`)
that #2864 left behind, same as my other open branches — main is red on
it, and identical changes to that line merge without conflict.

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

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

250 lines
7.7 KiB
TypeScript

import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { PrMergeCallResult } from "./pr-nodes.js";
import type { RunTaskStepResult, ResetStepResult } from "./step-runner.js";
import type { WorkflowNodeOutcome } from "./workflow-graph-executor.js";
export type RuntimePrimitiveName =
| "prepare-worktree"
| "read-artifact"
| "write-artifact"
| "planning-session"
| "coding-session"
| "step-session"
| "reset-step"
| "review"
| "verification"
| "transition"
| "merge"
| "abort"
| "audit";
export interface WorkflowRuntimeRunContext {
runId: string;
taskId: string;
workflowId: string;
/** True after any primitive with task/git/session side effects starts. */
sideEffectsStarted?: boolean;
recoveryEventId?: string;
}
export interface WorkflowRuntimeNodeContext {
node: Pick<WorkflowIrNode, "id" | "kind" | "column" | "config">;
effectivePrincipalId?: string;
attempt?: number;
context?: Record<string, unknown>;
}
export interface WorkflowPrimitiveContext {
run: WorkflowRuntimeRunContext;
node: WorkflowRuntimeNodeContext;
/*
FNXC:WorkflowCancellation 2026-07-15-10:42:
Graph cancellation must reach long-running primitives, not just node handlers. Before this existed, a hard-cancel (user cancel, engine restart, pause/resume) aborted the graph controller but the in-flight `merge` primitive never saw it: it raced the merge only against its own 30-minute timeout, so the walk sat inside the merge node for the full timeout before discovering it had been cancelled half an hour earlier. The timeout's abort then killed the still-running AI merge mid-flight ("Manual-merge failed: Request was aborted"), which could land between merger-ai's `worktree: null` write and `mergeConfirmed`, stranding the card as `no-worktree-no-merge-confirmed`.
Mirrors `WorkflowNodeExecutionContext.signal` (workflow-graph-executor.ts) and is threaded by `primitiveContextForNode`. Undefined on the sequential/uncancellable path. A primitive that can block on I/O for more than a few seconds MUST honor it — link it into any local timeout controller via `AbortSignal.any` rather than replacing it, so both cancellation and the timeout stay live.
*/
signal?: AbortSignal;
}
export interface RuntimePrimitiveResult<TValue = unknown> {
outcome: WorkflowNodeOutcome;
value?: string;
data?: TValue;
contextPatch?: Record<string, unknown>;
}
export interface PreparedWorktree {
worktreePath: string;
branchName?: string;
baseCommitSha?: string;
modifiedFiles?: string[];
}
export interface PlanningSessionResult {
approved: boolean;
artifactKeys: string[];
createdTaskIds?: string[];
feedback?: string;
}
export interface CodingSessionResult {
taskDone: boolean;
modifiedFiles: string[];
summary?: string;
}
export interface ReviewPrimitiveResult {
verdict: "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
review?: string;
summary?: string;
}
export interface VerificationPrimitiveResult {
verdict: "approve" | "revise" | "failed" | "advisory-failed" | "skipped";
feedback?: string;
stepName?: string;
}
// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `runWorkflowStep`
// primitive and its `WorkflowStepPrimitiveInput`/`WorkflowStepPrimitiveResult`
// shapes were removed. Workflow quality gates run as the graph's own
// optional-group / gate nodes which record into `task.workflowStepResults` (U2);
// there is no dedicated workflow-step runtime primitive.
export interface TransitionPrimitiveInput {
column?: string;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-01:05:
Ask for the LANE BY ROLE when the caller cannot resolve it.
Seam handlers in `workflow-node-handlers.ts` are pure functions over an IR node and a task; they
hold no store, so a handler that wants "move this card to review" could only name `in-review`. Post
U12 `moveTask` REJECTS an undeclared destination, so on a board that renamed its review lane the
review-handoff seam threw `TransitionRejectionError` and killed the workflow walk mid-run.
The runtime primitive holds the store, so it resolves the role to a column. `column` still wins when
both are given — an explicit destination is an explicit destination — and a role that cannot be
resolved falls back to the legacy id rather than failing the transition, which is the behaviour
every caller had before.
*/
columnRole?: "review";
status?: string | null;
reason: string;
preserveProgress?: boolean;
}
export interface MergePrimitiveInput {
expectedHeadOid?: string;
manualAllowed?: boolean;
}
export type MergePrimitiveResult =
| { status: "merged"; noOp?: boolean }
| { status: "manual-required"; reason?: string }
| { status: "failed"; reason: string }
| { status: "timeout" }
| PrMergeCallResult;
export interface AbortPrimitiveInput {
reason: string;
hardCancel?: boolean;
}
export interface AuditPrimitiveInput {
type: string;
message: string;
metadata?: Record<string, unknown>;
}
export interface WorkflowRuntimePrimitives {
prepareWorktree(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
): Promise<RuntimePrimitiveResult<PreparedWorktree>>;
readArtifact(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
key: string,
): Promise<string | undefined>;
writeArtifact(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
key: string,
content: string,
): Promise<RuntimePrimitiveResult<{ key: string }>>;
runPlanningSession(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
): Promise<RuntimePrimitiveResult<PlanningSessionResult>>;
runCodingSession(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
prepared: PreparedWorktree,
): Promise<RuntimePrimitiveResult<CodingSessionResult>>;
runTaskStep(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
stepIndex: number,
): Promise<RunTaskStepResult>;
resetTaskStep(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
stepIndex: number,
baselineSha?: string,
checkpointId?: string,
): Promise<ResetStepResult>;
runReview(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: { type: "plan" | "code"; stepIndex?: number; baselineSha?: string },
): Promise<RuntimePrimitiveResult<ReviewPrimitiveResult>>;
runVerification(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
prepared: PreparedWorktree,
): Promise<RuntimePrimitiveResult<VerificationPrimitiveResult>>;
updateSteps(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
steps: TaskStep[],
): Promise<RuntimePrimitiveResult<{ count: number }>>;
transitionTask(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: TransitionPrimitiveInput,
): Promise<RuntimePrimitiveResult>;
requestMerge(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input?: MergePrimitiveInput,
): Promise<RuntimePrimitiveResult<MergePrimitiveResult>>;
abortRun(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: AbortPrimitiveInput,
): Promise<RuntimePrimitiveResult>;
audit(ctx: WorkflowPrimitiveContext, input: AuditPrimitiveInput): Promise<void> | void;
}
export function markSideEffectsStarted(ctx: WorkflowPrimitiveContext): WorkflowPrimitiveContext {
return {
...ctx,
run: {
...ctx.run,
sideEffectsStarted: true,
},
};
}
export function primitiveNodeContext(
run: WorkflowRuntimeRunContext,
node: WorkflowRuntimeNodeContext["node"],
extras: Omit<WorkflowRuntimeNodeContext, "node"> = {},
/** FNXC:WorkflowCancellation 2026-07-15-10:42: graph cancellation signal — see {@link WorkflowPrimitiveContext.signal}. */
signal?: AbortSignal,
): WorkflowPrimitiveContext {
return {
run,
node: {
...extras,
node,
},
signal,
};
}