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:
7
.changeset/drifted-agent-parked-columns.md
Normal file
7
.changeset/drifted-agent-parked-columns.md
Normal 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.
|
||||
@@ -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")];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user