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 = {
|
const RENAMED_IR = {
|
||||||
version: "v2", id: "custom:renamed", nodes: [], edges: [],
|
version: "v2", id: "custom:renamed", nodes: [], edges: [],
|
||||||
columns: [
|
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: "building", name: "building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||||
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
|
{ id: "shipped", name: "shipped", traits: [{ trait: "complete" }] },
|
||||||
],
|
],
|
||||||
@@ -223,7 +226,13 @@ describe("FN-4296: self-healing agent link drift", () => {
|
|||||||
const store = {
|
const store = {
|
||||||
getTask: vi.fn(async (taskId: string) => tasks[taskId] ?? null),
|
getTask: vi.fn(async (taskId: string) => tasks[taskId] ?? null),
|
||||||
recordRunAuditEvent: vi.fn(async () => {}),
|
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;
|
} as any;
|
||||||
const agentStore = {
|
const agentStore = {
|
||||||
listAgents: vi.fn(async (filter?: { includeEphemeral?: boolean }) =>
|
listAgents: vi.fn(async (filter?: { includeEphemeral?: boolean }) =>
|
||||||
@@ -251,6 +260,38 @@ describe("FN-4296: self-healing agent link drift", () => {
|
|||||||
manager.stop();
|
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 () => {
|
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. */
|
/* The sweep must narrow, not widen: an agent on live work keeps its link. */
|
||||||
const agents = [makeAgent("agent-1", "FN-8")];
|
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}`;
|
reason = `linked task assigned to ${linkedTask.assignedAgentId}`;
|
||||||
} else if (await this.isPreWipColumn(linkedTask)) {
|
} else if (await this.isPreWipColumn(linkedTask)) {
|
||||||
const activeRun = await agentStore.getActiveHeartbeatRun(agent.id);
|
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({
|
const proof = evaluateParkedAgentTaskLink({
|
||||||
agent,
|
agent,
|
||||||
linkedTask,
|
linkedTask,
|
||||||
activeRun,
|
activeRun,
|
||||||
hasActiveAgentExecution: this.options.hasActiveAgentExecution,
|
hasActiveAgentExecution: this.options.hasActiveAgentExecution,
|
||||||
now,
|
now,
|
||||||
|
parkedColumns: [...driftedParkedColumns],
|
||||||
});
|
});
|
||||||
hadFreshRun = proof.hasFreshRun;
|
hadFreshRun = proof.hasFreshRun;
|
||||||
hadActiveExecution = proof.hasActiveExecution;
|
hadActiveExecution = proof.hasActiveExecution;
|
||||||
|
|||||||
Reference in New Issue
Block a user