Files
fusion/packages/engine/src/task-agent-sync.ts
gsxdsm 02b0f4f860 Phase B slice B2: U5 small movers — 12 literal sites converted, plus a negative result on hold-release (#2471)
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)
2026-07-27 14:28:53 -07:00

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);
};
}