fix: keep self-healing sweeps alive across stale task links (#3417)

## 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
gsxdsm
2026-08-10 15:19:08 -10:00
committed by GitHub
parent d749a11a5b
commit 8c055a6695

View File

@@ -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