fix(engine): a live agent lost its task link on a renamed hold lane (found while testing, not converting) (#3208)

**A defect, not a coverage gap** — found while trying to pin
`agentParkedColumns` from the #3115 map.

`recoverDriftedAgentTaskLinks` enters its preservation branch on a
**resolved** question (`isPreWipColumn`) and then decided it on a
**literal** one: `evaluateParkedAgentTaskLink` was called without
`parkedColumns`, so parked-ness fell back to `todo`/`triage`. The
sibling sweep passes the resolved set; this call site did not.

On a renamed board: the card is pre-wip, `isParkedTaskColumn` says no,
`shouldPreserveParkedLink` is false, and **an agent with a fresh
heartbeat run has its task link cleared — while it is working.**

`task-agent-sync.ts` predicted this in writing when the parameter was
introduced:

> *"turning a stale-link bug into a dropped-link bug, since the card
would be treated as unparked and its live agent link cleared"*

That is what an unpassed optional lane parameter costs — the same
missed-pair shape as #2956, #2963 and #3186.

## The test needed two fixture corrections, both caught by failing

- the renamed IR had **no hold column**, so no card could be pre-wip at
all;
- the **per-task selection readers** were missing, so `isPreWipColumn`
resolved the default IR and the branch was never entered.

Either alone made the case pass while exercising nothing. Third time
today a fixture passed for a reason unrelated to the resolver — a
pattern, not an anecdote.

## Measured

15 pass; removing `parkedColumns` from the call fails exactly this case.

## Note on how it was found

I had discarded a probe at this sweep earlier for failing to
discriminate. Coming back with the obstacle understood — the fixture
must reach the branch the resolver gates — turned a coverage miss into a
defect find. The five discards this session were not wasted; three of
them named the obstacle that made a later attempt work.

## Verification

`self-healing-agent-link-drift` **15 passed** · `pnpm test:gate` 161 +
13 + 499 + 71 · lint · lane-wiring — green.
This commit is contained in:
gsxdsm
2026-07-31 10:49:57 -07:00
committed by GitHub
parent 8661b739ff
commit c027a72d23
3 changed files with 63 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: A working agent no longer loses its task link when the card waits in a renamed planning column.
category: fix
dev: `recoverDriftedAgentTaskLinks` now passes resolved `parkedColumns` into `evaluateParkedAgentTaskLink`; the sibling sweep already did.

View File

@@ -214,6 +214,9 @@ describe("FN-4296: self-healing agent link drift", () => {
const RENAMED_IR = {
version: "v2", id: "custom:renamed", nodes: [], edges: [],
columns: [
/* carries HOLD so a card can be pre-wip on this board — the preservation branch
below is only reachable for a parked card, and without this the case is vacuous. */
{ id: "drafting", name: "drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
],
@@ -223,7 +226,13 @@ describe("FN-4296: self-healing agent link drift", () => {
const store = {
getTask: vi.fn(async (taskId: string) => tasks[taskId] ?? null),
recordRunAuditEvent: vi.fn(async () => {}),
listWorkflowDefinitions: vi.fn(async () => [{ ir: RENAMED_IR }]),
listWorkflowDefinitions: vi.fn(async () => [{ id: "custom:renamed", ir: RENAMED_IR }]),
/* `isPreWipColumn` resolves the card's OWN workflow, so the per-task selection readers are
required — with only the project list it resolves the default IR, the preservation branch is
never entered, and a case asserting on that branch passes for the wrong reason. */
getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "custom:renamed", stepIds: [] })),
getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "custom:renamed", stepIds: [] })),
getWorkflowDefinition: vi.fn(async () => ({ ir: RENAMED_IR })),
} as any;
const agentStore = {
listAgents: vi.fn(async (filter?: { includeEphemeral?: boolean }) =>
@@ -251,6 +260,38 @@ describe("FN-4296: self-healing agent link drift", () => {
manager.stop();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-17:40:
THE PRESERVATION BRANCH WAS DECIDED ON LEGACY IDS while its GATE was already resolved.
`recoverDriftedAgentTaskLinks` enters this branch via `isPreWipColumn` (resolved) and then called
`evaluateParkedAgentTaskLink` WITHOUT `parkedColumns`, so parked-ness fell back to todo/triage. On a
renamed board the card is pre-wip, `isParkedTaskColumn` says no, `shouldPreserveParkedLink` is
false, and a live agent WITH a fresh heartbeat run has its task link cleared.
The sibling sweep passes the resolved set; this call site did not. One wired, one not — the missed
pair this whole effort keeps finding, and `task-agent-sync.ts` predicted it in writing when the
parameter was introduced.
*/
it("preserves a live agent's link on a card parked in a RENAMED hold lane", async () => {
const agents = [makeAgent("agent-live", "FN-PARKED")];
const manager = buildRenamedManager(agents, {
"FN-PARKED": { id: "FN-PARKED", column: "drafting" } as Task,
});
/* A FRESH run is live execution proof: the link must survive precisely because of it. */
const agentStore = (manager as unknown as { options: { agentStore: { getActiveHeartbeatRun: unknown } } }).options.agentStore;
(agentStore as { getActiveHeartbeatRun: unknown }).getActiveHeartbeatRun = vi.fn(async () => ({
id: "run-live",
agentId: "agent-live",
startedAt: new Date(Date.now() - 1_000).toISOString(),
}));
await manager.recoverDriftedAgentTaskLinks();
expect(agents[0].taskId).toBe("FN-PARKED");
manager.stop();
});
it("leaves a durable agent linked to a task still in a RENAMED wip lane", async () => {
/* The sweep must narrow, not widen: an agent on live work keeps its link. */
const agents = [makeAgent("agent-1", "FN-8")];

View File

@@ -13244,12 +13244,26 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
reason = `linked task assigned to ${linkedTask.assignedAgentId}`;
} else if (await this.isPreWipColumn(linkedTask)) {
const activeRun = await agentStore.getActiveHeartbeatRun(agent.id);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-17:40 (the missed half of a pair):
`parkedColumns` was NOT passed here while the sibling sweep passes it, so this call fell back
to LEGACY_PARKED_COLUMNS. The branch is entered on a RESOLVED question (`isPreWipColumn`) and
then decided on a literal one, so the two disagreed on a renamed board: the card is pre-wip,
`isParkedTaskColumn` says no, `shouldPreserveParkedLink` is false, and a live agent WITH a
fresh heartbeat run has its task link cleared.
`task-agent-sync.ts` predicted exactly this when the parameter was added: "turning a stale-link
bug into a dropped-link bug, since the card would be treated as unparked and its live agent
link cleared." That is what an unpassed optional lane parameter costs.
*/
const driftedParkedColumns = await resolveProjectColumnsForRoles(this.store, ["hold", "intake"]);
const proof = evaluateParkedAgentTaskLink({
agent,
linkedTask,
activeRun,
hasActiveAgentExecution: this.options.hasActiveAgentExecution,
now,
parkedColumns: [...driftedParkedColumns],
});
hadFreshRun = proof.hasFreshRun;
hadActiveExecution = proof.hasActiveExecution;