From 8c055a6695f53e35f9fa93a07d6701eba8b40ff6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 10 Aug 2026 15:19:08 -1000 Subject: [PATCH] fix: keep self-healing sweeps alive across stale task links (#3417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Supersedes #3405 — the fork head is not writable from maintainers, so this branch carries the same fix rebased/merged onto current `main`. ## Conflict resolution - Main already landed the equivalent fail-open path as **FN-8919** (`readLinkedTaskOrUndefined` + per-agent try/catch). - Kept the additional `recoverAgentsRunningOnInactiveTasks` regression that covers task-gone races plus transient lookup isolation. - Dropped the duplicate changeset (main already has `fn-8919-agent-link-sweep-fail-open`). ## Test plan - [x] `git merge-tree` clean against `main` - [ ] CI green Closes context from #3405. ## Summary by CodeRabbit * **Bug Fixes** * Improved recovery handling when task lookups fail. * Agents linked to deleted or missing tasks are now unlinked, while agents affected by temporary errors remain preserved. * Recovery continues for other eligible agents instead of stopping after an individual lookup failure. * **Tests** * Added regression coverage for deleted, missing, and temporarily unavailable tasks. Co-authored-by: Codex --- .../engine/src/__tests__/self-healing.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index e699b5ebe1..e6653e839d 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -1952,6 +1952,43 @@ describe("SelfHealingManager", () => { managerWithAgents.stop(); }); + it("continues after task-gone lookup races and recovers later agents", async () => { + const agents: Agent[] = [ + { id: "agent-deleted", state: "running", taskId: "FN-DELETED", updatedAt: new Date(Date.now() - 120_000).toISOString() } as Agent, + { id: "agent-missing", state: "running", taskId: "FN-MISSING", updatedAt: new Date(Date.now() - 120_000).toISOString() } as Agent, + { id: "agent-error", state: "running", taskId: "FN-ERROR", updatedAt: new Date(Date.now() - 120_000).toISOString() } as Agent, + { id: "agent-later", state: "running", taskId: "FN-LATER", updatedAt: new Date(Date.now() - 120_000).toISOString() } as Agent, + ]; + const getTask = vi.fn(async (taskId: string) => { + if (taskId === "FN-DELETED") throw new TaskDeletedError(taskId, new Date().toISOString()); + if (taskId === "FN-MISSING") throw new TaskNotFoundError(taskId); + if (taskId === "FN-ERROR") throw new Error("database unavailable"); + return { id: taskId, column: "todo" } as Task; + }); + const agentStore = { + listAgents: vi.fn(async () => agents), + getActiveHeartbeatRun: vi.fn(async () => null), + updateAgentState: vi.fn(async (agentId: string, state: Agent["state"]) => { + const agent = agents.find((candidate) => candidate.id === agentId); + if (agent) agent.state = state; + }), + syncExecutionTaskLink: vi.fn(async (agentId: string, taskId?: string) => { + const agent = agents.find((candidate) => candidate.id === agentId); + if (agent) agent.taskId = taskId; + }), + } as unknown as AgentStore; + const managerWithAgents = new SelfHealingManager( + createMockStore({ getTask }), + { rootDir: "/tmp/test-project", agentStore }, + ); + + const recovered = await managerWithAgents.recoverAgentsRunningOnInactiveTasks(); + + expect(recovered).toBe(3); + expect(agentStore.syncExecutionTaskLink).toHaveBeenCalledWith("agent-later", undefined); + managerWithAgents.stop(); + }); + /* FNXC:WorkflowResolvedColumns 2026-07-31-23:55: `agentLinkTerminalColumns` was UNCOVERED on the #3115 map. The case above uses `todo` and