From c027a72d23876a096f7a3e59aa5db45cc41db86d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 10:49:57 -0700 Subject: [PATCH] fix(engine): a live agent lost its task link on a renamed hold lane (found while testing, not converting) (#3208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. --- .changeset/drifted-agent-parked-columns.md | 7 +++ .../self-healing-agent-link-drift.test.ts | 43 ++++++++++++++++++- packages/engine/src/self-healing.ts | 14 ++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .changeset/drifted-agent-parked-columns.md diff --git a/.changeset/drifted-agent-parked-columns.md b/.changeset/drifted-agent-parked-columns.md new file mode 100644 index 0000000000..57759ec9ea --- /dev/null +++ b/.changeset/drifted-agent-parked-columns.md @@ -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. diff --git a/packages/engine/src/__tests__/self-healing-agent-link-drift.test.ts b/packages/engine/src/__tests__/self-healing-agent-link-drift.test.ts index 3980b3d880..c2b6aee711 100644 --- a/packages/engine/src/__tests__/self-healing-agent-link-drift.test.ts +++ b/packages/engine/src/__tests__/self-healing-agent-link-drift.test.ts @@ -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")]; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 98fc6044a5..46d3ab7b9f 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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;