**Nine operator-visible defects in a class the census cannot see, plus
the audit method that found them.**
The census scans for lifecycle-column **comparisons**. This PR is about
guards that have no literal to find: a helper takes an optional
*resolved* lane set, its own test passes it, the census entry is gone —
and the callers pass nothing. **A resolved seam nobody wired is
indistinguishable from no seam at all.**
## What was broken
| defect | operator sees |
| --- | --- |
| `getTaskMergeBlocker` unwired in `mergeTaskImpl` | `Cannot merge FN-1:
task is in 'checking', must be in 'in-review'` — **a reviewed card
cannot merge** |
| …and in the completion move | `Cannot move FN-1 to done: …` — **and
cannot complete** |
| `isParkedTaskColumn` unwired ×2 (`agent-heartbeat`) | a durable agent
keeps claiming a parked card; **Health Check renders it RUNNING** |
| `resolveLinkSyncColumnRoles` first-per-role | link hygiene skips a
**second hold lane** entirely |
| `executor` active-task predicate first-per-role | a card in a **second
wip lane reads as INACTIVE**; its prompt file becomes reclaimable |
| `isPlanningContinuationTaskDispatchable` partially threaded | a board
declaring `done` as *non-terminal* stalls its cards — **stalled by a
lane name** |
| `default-workflow-hooks:72`, `executor:2404` | resolved gate admits
the move, unresolved blocker refuses it |
## The recurring shape, which is sharper than "a caller forgot an
argument"
Four sites resolve the lane and then re-ask with the literal, **a few
lines apart in the same function**:
- `task-artifacts-ops` resolves `completeColumn`, then asks the blocker
with the literal.
- `default-workflow-hooks:72` gates on `lifecycleColumns?.review`, then
the literal.
- `executor:2404` compares `resolveResumeLanes(…).review`, then the
literal.
- `resolvePlanningContinuationCandidate` applies the caller's terminal
set, then delegates without it.
**Grep for the helper, not the literal.** The literal is one function
away, correctly annotated as a fallback — which is exactly why the
census is blind to all of it.
## The arity trap, named and measured (six occurrences, one caught by
review here)
`resolveLifecycleColumns` answers *"which column is **the** hold
lane?"*. A `.includes()`/`.has()` test asks *"is this **any** hold
lane?"*. Nothing distinguishes them — same types, no literal.
**A default-vs-renamed differential cannot catch it**, because the
default board declares one column per role and therefore cannot express
the failing shape. It needs a *structurally* different fixture. That is
a sharper rule than "test both vocabularies", and it would have caught
all six.
Scanned: 12 candidate sites. **4 fixed · 3 blocked (2 on the inert sync
IR reader; `triage:833` also query-shaped) · 1 needs a hook-contract
change · 3 not defects (a returned tuple; an ordering-sensitive
precedence list) · 1 false positive of my own scan.**
A sweep over all twelve would have broken the ordering-sensitive pair,
delivered nothing at the sync-blocked ones, and "fixed" a site that was
already correct.
## Two traps in fixing this class — I hit both here
1. **The legacy id is a FALLBACK, not a member.** Pre-seeding
`"in-review"` admits a board that *declares* `in-review` as its WIP
column — a card mid-implementation merges prematurely. A real resolved
answer must **replace** the default. (Caught by review; it is the same
unscoped-legacy-acceptance the glasses plugin's review caught earlier,
which I had read and reintroduced.)
2. **Two guards, one assertion.** `toContain("must be in")` passed with
`mergeTaskImpl` reverted, because the *completion* guard caught the card
instead. The assertion now names the site (`Cannot merge` vs `Cannot
move … to done`) so the two fail independently.
## Corrections I made to my own work, recorded rather than quietly fixed
- My first PG test was **vacuous three ways**:
`saveWorkflowDefinition?.()`/`setTaskWorkflowSelection?.()` do not exist
(the `?.` swallowed both, so the task kept the builtin workflow),
`updateTask({column})` does not move a card, and a two-node IR made
every setup move illegal. Premise is now **asserted**, not assumed.
- My doc claimed the audit was complete. It enumerated **helpers**, not
every **caller** — `getTaskMergeBlocker` alone has 13 call sites.
Corrected in place, with the still-unwired ones listed by file and line
and a note to distrust any "audit complete" claim including mine.
- A severity correction to another worker's E2E:
`selectActionablePlanningContinuations` has **no production caller**, so
its stated consequence is latent, not live.
## Verification
- `pnpm test:gate` — 161 + 487 + 13 + 71
- `tsc` on core and engine; `pnpm lint`; `check:changesets`; census
`--strict` — all clean, each run explicitly
- Every fix revert-measured; each has a non-vacuous companion. The
two-hold-lane and repurposed-`in-review` cases exist because the default
board cannot express those shapes.
## Deliberately not done, with reasons in
`resolved-seams-nobody-wired.md`
`isTaskReadyForMerge` (dead in production — wiring it would be the
anti-pattern itself); `getTaskHardMergeBlocker` (3 of 4 callers are
query-gated sweeps); `getInReviewStallReason` (needs a **batch
prefetch**, not a per-task resolve — its callers decorate every task on
every list read; the in-review stall badge is wrong on renamed boards
until then); `default-workflow-hooks` planning/live-work sets (needs
`DefaultWorkflowMoveContext` to carry the IR — a shared contract
change).
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import { resolveWorkflowIrForTask, columnsWithFlag } 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> {
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-30-15:40 (the arity trap, sixth site):
|
|
MEMBERSHIP, not first-per-role. These two sets are consumed by `roles.parked.includes(to)` and
|
|
`roles.clear.includes(to)` — membership tests — but were built from `resolveTaskLifecycleColumns`,
|
|
which returns the FIRST column carrying each trait. A workflow declaring two hold lanes, or an
|
|
archive lane plus a second terminal one, had link hygiene applied to only one of them.
|
|
|
|
`resolveLifecycleColumns` answers "which column is THE hold lane?"; a membership test asks "is this
|
|
column ANY hold lane". Nothing in the types distinguishes them, which is why this program has now hit
|
|
it six times. `columnsWithFlag` returns every column carrying the trait.
|
|
*/
|
|
const ir = await resolveWorkflowIrForTask(store, taskId, cache).catch(() => undefined);
|
|
if (!ir) return LEGACY_COLUMN_ROLES;
|
|
|
|
const parked = [...new Set([...columnsWithFlag(ir, "hold"), ...columnsWithFlag(ir, "intake")])];
|
|
const terminal = [...new Set([...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")])];
|
|
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 };
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-28-14:20 (PR #2514 review):
|
|
THE COMPLETION SIGNAL for an otherwise unobservable listener.
|
|
|
|
`task:moved` is a plain EventEmitter event and this handler is `async`, so
|
|
`store.emit` returns the moment the handler hits its first `await` and NOTHING
|
|
awaits the rest. That is fine in production — link hygiene is best-effort — but it
|
|
means the handler has no completion signal at all, and a caller cannot distinguish
|
|
"the handler ran and correctly did nothing" from "the handler has not run yet".
|
|
|
|
That distinction is not merely a testing inconvenience. The negative case of this
|
|
handler — a move that must NOT release the agent — has no observable effect by
|
|
construction, so without this hook the only way to check it is to wait a while and
|
|
look, which cannot fail: a handler that never ran looks exactly like a handler that
|
|
correctly declined. An unobservable async listener in an event-driven lifecycle is
|
|
a design gap, and this closes it with the narrowest thing that does: one optional
|
|
callback, fired on EVERY exit path including the early return and the error path.
|
|
|
|
Production passes nothing and is byte-identical.
|
|
*/
|
|
export interface AgentLinkSyncOutcome {
|
|
taskId: string;
|
|
from: string;
|
|
to: string;
|
|
/** False when `to` is not one of the workflow's clear columns — the handler
|
|
* intentionally did nothing. This is what makes "declined" observable. */
|
|
matchedClearColumn: boolean;
|
|
/** Agents whose task link was cleared by this move. */
|
|
clearedAgentIds: string[];
|
|
/** Linked agents deliberately left alone by the parked-link preservation proof. */
|
|
preservedAgentIds: string[];
|
|
/** Set when the agent listing/mutation threw; the handler swallows it as before. */
|
|
error?: string;
|
|
}
|
|
|
|
export interface AttachAgentLinkSyncOptions {
|
|
store: TaskStore;
|
|
agentStore: AgentStore;
|
|
hasActiveAgentExecution?: (agentId: string) => boolean;
|
|
logger?: LoggerLike;
|
|
/** Fired after the handler settles for one move, on every exit path. Optional;
|
|
* absent in production. Must never throw into the handler. */
|
|
onHandled?: (outcome: AgentLinkSyncOutcome) => void;
|
|
}
|
|
|
|
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 }) => {
|
|
const outcome: AgentLinkSyncOutcome = {
|
|
taskId: task.id,
|
|
from,
|
|
to,
|
|
matchedClearColumn: false,
|
|
clearedAgentIds: [],
|
|
preservedAgentIds: [],
|
|
};
|
|
const settle = (): void => {
|
|
try {
|
|
opts.onHandled?.(outcome);
|
|
} catch {
|
|
// A diagnostics hook must never affect link hygiene.
|
|
}
|
|
};
|
|
/*
|
|
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)) {
|
|
settle();
|
|
return;
|
|
}
|
|
outcome.matchedClearColumn = true;
|
|
|
|
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) {
|
|
outcome.preservedAgentIds.push(agent.id);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (agent.state === "running") {
|
|
await opts.agentStore.updateAgentState(agent.id, "active");
|
|
}
|
|
await opts.agentStore.syncExecutionTaskLink(agent.id, undefined);
|
|
outcome.clearedAgentIds.push(agent.id);
|
|
logger.log(`taskAgentLinkSync: cleared agent ${agent.id} taskId from ${task.id} after move ${from} → ${to}`);
|
|
}
|
|
} catch (error) {
|
|
outcome.error = error instanceof Error ? error.message : String(error);
|
|
logger.warn(
|
|
`taskAgentLinkSync: failed to sync agents for task ${task.id} after move ${from} → ${to}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
} finally {
|
|
settle();
|
|
}
|
|
};
|
|
|
|
opts.store.on("task:moved", handler);
|
|
return () => {
|
|
opts.store.off("task:moved", handler);
|
|
};
|
|
}
|