Stacked on #2470 (Phase B slice B1). Base is `feature/workflow-vocabulary-conversion` — do not merge before it. ## What this is Phase B slice B2 — the U5 small movers. **12 literal sites converted, plus one negative result.** The plan estimated 36 sites. A survey found 12 genuinely-convertible ones, and separately found that the plan's headline hold-release scenario **was already fixed**. Both are reported below rather than padded into a bigger-looking diff. ## The negative result (commit 1) The plan named hold-release.ts as a target on the scenario *"release readiness must hold and release identically for a RENAMED hold column."* I wrote that test first, to prove it broken. **It is not broken.** All five assertions passed against unmodified `hold-release.ts`. U6/KTD-5 had already converted the module — `isHeldTask`, `resolveReleaseTarget`, and `dependencySatisfied` each resolve the task's IR. **`hold-release.ts` has no production change in this PR.** The tests are kept as a regression floor: the invariant rests on three independent trait resolutions any of which could be "simplified" back to a literal, and nothing else covered a renamed vocabulary end-to-end through the sweep. **I verified the tests can actually fail.** Mutating `isHeldTask` back to `task.column === "todo"` kills all five. Without that check, a green run against unmodified code is indistinguishable from a test asserting something trivially true. Two drafting notes kept in the file: the renamed ids deliberately avoid colliding with any legacy literal, and the first draft's two dependency tests used a `capacity` hold — which never consults dependencies at all, so one passed **vacuously**. Both now use a `dependency` hold. ## The 12 conversions Each was red-green: the renamed-workflow test written first and **observed failing**, then made to pass. | Site | Was | Now | |---|---|---| | `task-agent-sync` CLEAR_COLUMNS | `{done,archived,todo,triage}` | resolved complete+archived+hold+intake | | `task-agent-sync` isParkedTaskColumn | `{todo,triage}` | `parkedColumns` param (hold+intake) | | `task-agent-sync` handler branch | `to === "todo" \|\| "triage"` | resolved parked set | | `mesh-lease` parked guard | `task.column !== "todo"` | resolved rebound column | | `mesh-lease` rebound move | `moveTask(id,"todo")` | resolved rebound column | | `mesh-lease` audit decisionPath | `=== "todo" ? … : …` | same resolved column | | `mesh-lease` audit newColumn | `… : "todo"` | same resolved column | | `merger-ai` already-finalized | `=== "done" \|\| "archived"` | resolved complete+archived | | `merger-ai` ×4 rebounds | `moveTask(id,"todo")` | shared `resolveFinalizeReboundColumn` | Rebound targets all use KTD-10 `resolveReboundTarget` (hold → intake → first column), the helper `self-healing.ts:714` already uses — reused, not invented. ## Three findings worth reading **1. The mesh-lease bug was in the AUDIT, not the move.** The guard and the audit were *independent* `=== "todo"` comparisons, so `newColumn` asserted the card landed in `todo` regardless of what the move did. For a workflow with no `todo` column that produced a lease-recovery trail naming a nonexistent column — and run-audit is the only post-hoc record of a lease recovery. Now resolved once and threaded to both, so they are structurally incapable of disagreeing. **2. The merger-ai failure mode was not what I predicted.** I expected the already-finalized guard to fail open and re-merge a finished card. The red run showed it actually throws `Cannot merge FN-1: task is in 'shipped', must be in 'in-review'` — a hard error blaming the column, on a task whose real state is "already done". The thing preventing the re-merge is *itself* a literal in core's `getTaskMergeBlocker`, outside this slice. Two bugs coinciding, not a design. **3. A fourth site had to move that wasn't on the list.** `evaluateParkedAgentTaskLink` calls `isParkedTaskColumn` internally. Converting only the handler would have left the preservation branch on legacy ids after the caller resolved a renamed workflow — trading a stale-link bug for a **worse** dropped-link bug (a live agent's link cleared mid-run). ## Deliberately NOT converted Both keep their literals with the reason recorded at the site under a greppable `DELIBERATE-LITERAL` tag: - **`hold-release.ts:326` `legacyDependencySatisfied`** — the FN-5719 dual-accept half. Converting makes both halves compute the same answer, deleting the compatibility signal *and* its divergence detector while looking like a cleanup. - **`replan-target.ts` final fallback** — its value is precisely that it is *not* trait-resolved; resolving it against the workflow is the stranded-card bug it was written to fix. ⚠️ **The U12 literal ratchet does not exist in the tree yet.** The brief assumed an allowlist to add entries to; there is none. `grep -rn DELIBERATE-LITERAL packages/*/src` enumerates the sites it must admit. ## What I could NOT verify - **One of the four merger-ai rebound sites is untested.** The `landWorkspaceTask` rebound is verified by inspection and the shared resolver's unit tests only — `landWorkspaceTask` is only ever *mocked* (project-engine.test.ts), never executed. Covering it needs a multi-repo git fixture and a full land run. The **other three are genuinely exercised** by pre-existing merger-ai.test.ts (lines 676/716/777/895 assert `moveTask("FN-1","todo",…)` through a real git repo) and pass unchanged — real wiring proof for those. - **3 of the 9 task-agent-sync tests passed before the conversion too**, vacuously — the literal handler early-returned and cleared nothing. They assert nothing about the old code; they are guardrails against the conversion over-clearing. - **No renamed workflow was run against a live engine.** All evidence is unit-level. ## Call sites outside this slice — NOT converted, byte-identical They keep the legacy defaults: `scheduler.ts:1273`, `agent-heartbeat.ts:1169/3642`, `self-healing.ts:11600/11665` (all `evaluateParkedAgentTaskLink`), and `merger.ts:6585` (the sibling terminal guard). Each is its own Phase C/D surface. ## Behavior changes (not a pure refactor) For a **renamed** workflow: agent links now actually get cleared on terminal moves (they never were); lease rebounds land in the resolved hold column; finalize-blocked rebounds land in the resolved hold column and their operator-facing task-log lines name the real column; already-finalized cards short-circuit cleanly instead of throwing. For **builtin:coding** and any unresolvable workflow: byte-identical. Every new parameter defaults to the legacy set, and both merger-ai resolvers fail *soft* to legacy ids in opposite directions — the terminal guard keeps `done`/`archived` (losing it sends a finished card into the merge path), the rebound keeps `todo` (abandoning it strands the card in the merge lane with no owner). ## Verification - Merge gate **green**: 299 + 10 + 71 tests - Slice suites **green**: 100 tests across 8 files (new + all pre-existing neighbours) - Existing merger suites **green**: 82 tests across 5 files, unchanged - `tsc --noEmit` clean, `pnpm lint` clean No changeset: `@fusion/engine` is private. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
import { resolveTaskLifecycleColumns } from "@fusion/core";
|
|
import type { Agent, AgentHeartbeatRun, AgentStore, Task, TaskStore, WorkflowIr } from "@fusion/core";
|
|
|
|
export const PARKED_AGENT_LINK_FRESH_RUN_MS = 5 * 60_000;
|
|
|
|
export interface AgentTaskLinkExecutionProof {
|
|
hasFreshRun: boolean;
|
|
hasActiveExecution: boolean;
|
|
shouldPreserveParkedLink: boolean;
|
|
runAgeMs: number;
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-27-22:55 (Phase B / U5):
|
|
The roles at which an agent's task link is CLEARED: terminal (`complete`,
|
|
`archived`) plus parked (`hold`, `intake`). Legacy default = the ids the builtin
|
|
coding workflow gives those four roles, used when the workflow cannot be
|
|
resolved — the conservative choice, since it preserves today's behavior exactly
|
|
rather than guessing a role for an unknown column.
|
|
*/
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-27-22:55 (Phase B / U5):
|
|
The legacy PARKED ids — the builtin coding workflow's `hold` and `intake`
|
|
columns. Exported because `isParkedTaskColumn` defaults to it for callers that
|
|
cannot resolve a workflow.
|
|
*/
|
|
export const LEGACY_PARKED_COLUMNS: readonly string[] = ["todo", "triage"];
|
|
|
|
/* Terminal (`complete`, `archived`) plus parked. Derived from the parked list
|
|
rather than restated so the two legacy sets cannot drift apart. */
|
|
const LEGACY_CLEAR_COLUMNS: readonly string[] = ["done", "archived", ...LEGACY_PARKED_COLUMNS];
|
|
|
|
interface LinkSyncColumnRoles {
|
|
/** Columns whose arrival clears the link (terminal + parked). */
|
|
clear: readonly string[];
|
|
/** The subset that is merely parked, where live execution proof preserves it. */
|
|
parked: readonly string[];
|
|
}
|
|
|
|
const LEGACY_COLUMN_ROLES: LinkSyncColumnRoles = {
|
|
clear: LEGACY_CLEAR_COLUMNS,
|
|
parked: LEGACY_PARKED_COLUMNS,
|
|
};
|
|
|
|
/**
|
|
* Resolve the clearing/parked column roles for a task's own workflow, falling
|
|
* back to the legacy literal sets when the workflow has no column vocabulary.
|
|
*
|
|
* Fail-soft on purpose: this handler runs off a `task:moved` event and its only
|
|
* job is link hygiene. A resolution failure must not throw into the emitter, and
|
|
* degrading to the legacy sets keeps the builtin workflow correct while leaving
|
|
* a renamed workflow no worse off than before this conversion.
|
|
*/
|
|
async function resolveLinkSyncColumnRoles(
|
|
store: TaskStore,
|
|
taskId: string,
|
|
cache?: Map<string, WorkflowIr>,
|
|
): Promise<LinkSyncColumnRoles> {
|
|
const lifecycle = await resolveTaskLifecycleColumns(store, taskId, cache);
|
|
if (!lifecycle) return LEGACY_COLUMN_ROLES;
|
|
|
|
const parked = [lifecycle.hold, lifecycle.intake].filter((c): c is string => typeof c === "string");
|
|
const terminal = [lifecycle.complete, lifecycle.archived].filter((c): c is string => typeof c === "string");
|
|
const clear = [...terminal, ...parked];
|
|
|
|
// A v2 workflow declaring none of the four roles yields an empty clear set,
|
|
// which would silently disable link hygiene entirely. Prefer the legacy sets.
|
|
if (clear.length === 0) return LEGACY_COLUMN_ROLES;
|
|
return { clear, parked };
|
|
}
|
|
|
|
export function hasFreshActiveHeartbeatRun(
|
|
activeRun: AgentHeartbeatRun | null | undefined,
|
|
now = Date.now(),
|
|
freshRunMs = PARKED_AGENT_LINK_FRESH_RUN_MS,
|
|
): { hasFreshRun: boolean; runAgeMs: number } {
|
|
const runStartedAt = activeRun?.startedAt;
|
|
const runAgeMs = runStartedAt ? now - Date.parse(runStartedAt) : Number.POSITIVE_INFINITY;
|
|
return {
|
|
hasFreshRun: Boolean(activeRun) && Number.isFinite(runAgeMs) && runAgeMs <= freshRunMs,
|
|
runAgeMs,
|
|
};
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-27-22:55 (Phase B / U5):
|
|
"Parked" is the HOLD and INTAKE roles — a card resting before or between work,
|
|
not a card at the literal ids `todo`/`triage` (those are merely what the builtin
|
|
coding workflow calls those two columns). Under a renamed workflow the literal
|
|
check silently returned false for every card, which disabled the parked-link
|
|
preservation branch below rather than erroring.
|
|
|
|
`parkedColumns` defaults to the legacy pair so every caller that cannot resolve
|
|
a workflow is byte-identical (R11 keeps `todo`/`triage` legal column ids).
|
|
Callers that can resolve pass the task's `hold` and `intake` roles.
|
|
*/
|
|
export function isParkedTaskColumn(
|
|
task: Pick<Task, "column"> | null | undefined,
|
|
parkedColumns: readonly string[] = LEGACY_PARKED_COLUMNS,
|
|
): boolean {
|
|
if (!task?.column) return false;
|
|
return parkedColumns.includes(task.column);
|
|
}
|
|
|
|
export function evaluateParkedAgentTaskLink(options: {
|
|
agent: Pick<Agent, "id" | "taskId">;
|
|
linkedTask: Pick<Task, "column"> | null | undefined;
|
|
activeRun?: AgentHeartbeatRun | null;
|
|
hasActiveAgentExecution?: (agentId: string) => boolean;
|
|
now?: number;
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-27-22:55 (Phase B / U5):
|
|
The task's resolved parked (`hold` + `intake`) columns. Defaults to the legacy
|
|
pair so existing callers are byte-identical. Without this the preservation
|
|
branch consulted the legacy ids even when the CALLER had already resolved a
|
|
renamed workflow — turning a stale-link bug into a dropped-link bug, since the
|
|
card would be treated as unparked and its live agent link cleared.
|
|
*/
|
|
parkedColumns?: readonly string[];
|
|
}): AgentTaskLinkExecutionProof {
|
|
const { hasFreshRun, runAgeMs } = hasFreshActiveHeartbeatRun(options.activeRun, options.now);
|
|
const hasActiveExecution = options.hasActiveAgentExecution?.(options.agent.id) === true;
|
|
/*
|
|
FNXC:AgentTaskStateDrift 2026-06-23-08:33:
|
|
Agent.taskId is a running assignment for parked todo/triage tasks only when the agent has live execution proof: a fresh active heartbeat run or an executor-active signal. File-scope overlapBlockedBy keeps the task queued but never proves the blocked task itself is executing.
|
|
*/
|
|
return {
|
|
hasFreshRun,
|
|
hasActiveExecution,
|
|
shouldPreserveParkedLink:
|
|
isParkedTaskColumn(options.linkedTask, options.parkedColumns ?? LEGACY_PARKED_COLUMNS) &&
|
|
(hasFreshRun || hasActiveExecution),
|
|
runAgeMs,
|
|
};
|
|
}
|
|
|
|
type LoggerLike = { log: (msg: string) => void; warn: (msg: string) => void };
|
|
|
|
export interface AttachAgentLinkSyncOptions {
|
|
store: TaskStore;
|
|
agentStore: AgentStore;
|
|
hasActiveAgentExecution?: (agentId: string) => boolean;
|
|
logger?: LoggerLike;
|
|
}
|
|
|
|
export function attachAgentLinkSync(opts: AttachAgentLinkSyncOptions): () => void {
|
|
const logger: LoggerLike = opts.logger ?? console;
|
|
|
|
const handler = async ({ task, from, to }: { task: { id: string }; from: string; to: string }) => {
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-27-22:55 (Phase B / U5):
|
|
Resolve the roles from the moved task's OWN workflow rather than matching
|
|
`to` against a fixed id set. Previously a move into a renamed terminal
|
|
column matched nothing and this handler returned early — so the agent kept a
|
|
`taskId` pointing at a finished card and stayed `running`, with no error and
|
|
no failing test. The IR read happens before the agent listing so an
|
|
unresolvable workflow still degrades to the legacy sets rather than throwing.
|
|
*/
|
|
let roles: LinkSyncColumnRoles;
|
|
try {
|
|
roles = await resolveLinkSyncColumnRoles(opts.store, task.id);
|
|
} catch {
|
|
roles = LEGACY_COLUMN_ROLES;
|
|
}
|
|
|
|
if (!roles.clear.includes(to)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const agents = await opts.agentStore.listAgents({ includeEphemeral: false });
|
|
const linkedAgents = agents.filter((agent) => agent.taskId === task.id);
|
|
|
|
for (const agent of linkedAgents) {
|
|
if (roles.parked.includes(to)) {
|
|
const activeRun = await opts.agentStore.getActiveHeartbeatRun?.(agent.id);
|
|
const proof = evaluateParkedAgentTaskLink({
|
|
agent,
|
|
linkedTask: { column: to } as Pick<Task, "column">,
|
|
activeRun,
|
|
hasActiveAgentExecution: opts.hasActiveAgentExecution,
|
|
parkedColumns: roles.parked,
|
|
});
|
|
if (proof.shouldPreserveParkedLink) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (agent.state === "running") {
|
|
await opts.agentStore.updateAgentState(agent.id, "active");
|
|
}
|
|
await opts.agentStore.syncExecutionTaskLink(agent.id, undefined);
|
|
logger.log(`taskAgentLinkSync: cleared agent ${agent.id} taskId from ${task.id} after move ${from} → ${to}`);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`taskAgentLinkSync: failed to sync agents for task ${task.id} after move ${from} → ${to}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
opts.store.on("task:moved", handler);
|
|
return () => {
|
|
opts.store.off("task:moved", handler);
|
|
};
|
|
}
|