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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user