diff --git a/packages/engine/src/__tests__/auto-claim-snapshot.test.ts b/packages/engine/src/__tests__/auto-claim-snapshot.test.ts index 7bd0dfe595..82b5e135af 100644 --- a/packages/engine/src/__tests__/auto-claim-snapshot.test.ts +++ b/packages/engine/src/__tests__/auto-claim-snapshot.test.ts @@ -26,9 +26,10 @@ function makeTask(overrides: Partial & Pick): Task { describe("AutoClaimSnapshotManager", () => { it("uses the shared predicate for unchanged runnability filter cases", () => { - const runnable = makeTask({ id: "FN-1", dependencies: ["FN-done", "FN-archived"] }); + const firstRunnable = makeTask({ id: "FN-1", dependencies: ["FN-done", "FN-archived"] }); + const secondRunnable = makeTask({ id: "FN-2" }); const tasks = [ - runnable, + firstRunnable, makeTask({ id: "FN-paused", paused: true }), makeTask({ id: "FN-assigned", assignedAgentId: "agent-1" }), makeTask({ id: "FN-checked", checkedOutBy: "agent-2" }), @@ -38,10 +39,12 @@ describe("AutoClaimSnapshotManager", () => { makeTask({ id: "FN-done", column: "done" }), makeTask({ id: "FN-archived", column: "archived" }), makeTask({ id: "FN-open", column: "in-progress" }), + makeTask({ id: "FN-review", column: "in-review" }), + secondRunnable, ]; const tasksById = new Map(tasks.map((task) => [task.id, task])); - expect(tasks.filter((task) => isRunnableAutoClaimCandidate(task, tasksById)).map((task) => task.id)).toEqual(["FN-1"]); + expect(tasks.filter((task) => isRunnableAutoClaimCandidate(task, tasksById)).map((task) => task.id)).toEqual(["FN-1", "FN-2"]); }); it("shares one listTasks call across concurrent getSnapshot calls", async () => { @@ -137,6 +140,49 @@ describe("AutoClaimSnapshotManager", () => { }); }); + it("drops archived-while-cached candidates but keeps runnable siblings with canonical fields", async () => { + const initialTasks = [ + makeTask({ id: "FN-6872", title: "Re-ratchet line-count baseline", description: "archived later", createdAt: "2026-01-01T00:00:00.000Z" }), + makeTask({ id: "FN-TODO", title: "Old sibling title", description: "old sibling desc", createdAt: "2026-01-02T00:00:00.000Z" }), + ]; + const canonicalTasks = [ + makeTask({ id: "FN-6872", title: "Re-ratchet line-count baseline", description: "now archived", column: "archived", createdAt: "2026-01-01T00:00:00.000Z" }), + makeTask({ id: "FN-TODO", title: "Canonical sibling title", description: "canonical first line\nsecond", createdAt: "2026-01-02T00:00:00.000Z" }), + ]; + const listTasks = vi.fn() + .mockResolvedValueOnce(initialTasks) + .mockResolvedValueOnce(canonicalTasks); + const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks }, now: () => Date.parse("2026-01-12T00:00:00.000Z") }); + + const snapshot = await manager.getSnapshot(); + expect(snapshot.tasks.map((candidate) => candidate.id)).toEqual(["FN-6872", "FN-TODO"]); + + const resolved = await resolveFreshAutoClaimCandidates({ listTasks }, snapshot.tasks, () => Date.parse("2026-01-12T00:00:00.000Z")); + + expect(resolved.map((candidate) => candidate.id)).toEqual(["FN-TODO"]); + expect(resolved[0]).toMatchObject({ + title: "Canonical sibling title", + description: "canonical first line\nsecond", + descriptionFirstLine: "canonical first line", + column: "todo", + }); + }); + + it("treats archived dependencies as satisfied without making archived tasks candidates", async () => { + const dependent = makeTask({ id: "FN-dependent", dependencies: ["FN-archived-dependency"] }); + const archivedDependency = makeTask({ id: "FN-archived-dependency", column: "archived" }); + const tasks = [dependent, archivedDependency]; + const tasksById = new Map(tasks.map((task) => [task.id, task])); + + expect(isRunnableAutoClaimCandidate(dependent, tasksById)).toBe(true); + expect(isRunnableAutoClaimCandidate(archivedDependency, tasksById)).toBe(false); + + const manager = new AutoClaimSnapshotManager({ taskStore: { listTasks: vi.fn(async () => tasks) } }); + const snapshot = await manager.getSnapshot(); + + expect(snapshot.tasks.map((candidate) => candidate.id)).toEqual(["FN-dependent"]); + }); + it("sorts by columnMovedAt then createdAt ascending", async () => { const tasks = [ makeTask({ id: "FN-3", createdAt: "2026-01-03T00:00:00.000Z" }), diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 628c665eec..a4a2150851 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -1277,6 +1277,69 @@ describe("executeHeartbeat", () => { expect(executionPrompt).toContain("- FN-RENAMED: Updated canonical backlog title"); }); + it.each([ + { name: "executor display", role: "executor" as const, soul: "Re-ratchet line-count baseline specialist", runtimeConfig: undefined, expectedStatus: "auto-claim relevant tasks: enabled" }, + { name: "engineer role fallback", role: "engineer" as const, soul: "Re-ratchet line-count baseline specialist", runtimeConfig: { engineerBacklogAutoClaim: false }, expectedStatus: "auto-claim relevant tasks: enabled (compatible backlog blocked; engineerBacklogAutoClaim disabled)" }, + ])("drops archived-while-cached candidates from heartbeat prompt and claim path for $name", async (scenario) => { + const archivedCachedTask = makeAutoClaimTask({ + id: "FN-6872", + title: "Re-ratchet line-count baseline archived cached title", + description: "Re-ratchet line-count baseline work that matched this agent before archive", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const siblingCachedTask = makeAutoClaimTask({ + id: "FN-TODO", + title: "Old neutral queue title", + description: "neutral queue work", + createdAt: "2026-01-02T00:00:00.000Z", + }); + const archivedCanonicalTask = makeAutoClaimTask({ + id: "FN-6872", + title: "Re-ratchet line-count baseline archived canonical title", + description: "archived within the snapshot TTL", + column: "archived", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const siblingCanonicalTask = makeAutoClaimTask({ + id: "FN-TODO", + title: "Canonical neutral queue title", + description: "canonical neutral work", + createdAt: "2026-01-02T00:00:00.000Z", + }); + const listTasks = vi.fn() + .mockResolvedValueOnce([archivedCachedTask, siblingCachedTask]) + .mockResolvedValue([archivedCanonicalTask, siblingCanonicalTask]); + const store = createStoreWithAgentForExec({ + taskId: undefined, + role: scenario.role, + soul: scenario.soul, + runtimeConfig: scenario.runtimeConfig, + }); + const mockSession = createMockAgentSession(); + mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); + mockTaskStore = createMockTaskStore({ + listTasks, + getTask: vi.fn().mockResolvedValue(siblingCanonicalTask), + }); + + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" }); + + expect(store.claimTaskForAgent).not.toHaveBeenCalledWith("agent-001", "FN-6872", expect.anything()); + if (scenario.role === "executor") { + expect(store.claimTaskForAgent).toHaveBeenCalledWith("agent-001", "FN-TODO", expect.anything()); + } else { + expect(store.claimTaskForAgent).not.toHaveBeenCalled(); + } + const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string; + expect(executionPrompt).toContain(scenario.expectedStatus); + expect(executionPrompt).toContain("Open Task Candidates (auto-claim scan):"); + expect(executionPrompt).not.toContain("FN-6872"); + expect(executionPrompt).not.toContain("Re-ratchet line-count baseline archived cached title"); + expect(executionPrompt).not.toContain("Re-ratchet line-count baseline archived canonical title"); + expect(executionPrompt).toContain("- FN-TODO: Canonical neutral queue title"); + }); + it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => { const listTasks = vi.fn().mockResolvedValue([ { diff --git a/packages/engine/src/auto-claim-snapshot.ts b/packages/engine/src/auto-claim-snapshot.ts index 4d855b6cd4..8f6a7cfcba 100644 --- a/packages/engine/src/auto-claim-snapshot.ts +++ b/packages/engine/src/auto-claim-snapshot.ts @@ -33,6 +33,9 @@ const autoClaimSnapshotLog = createLogger("auto-claim-snapshot"); /* FNXC:AutoClaim 2026-06-21-10:35: Auto-claim runnability must have one source of truth so the snapshot rebuild and canonical freshness gate exclude the same stale, assigned, checked-out, deleted, paused, and dependency-blocked tasks. + +FNXC:AutoClaim 2026-06-21-16:09: +FN-6873 pins `column === "todo"` as the candidate gate after FN-6872 appeared in a heartbeat prompt while archived from a stale cache. Archived, done, triage, in-progress, in-review, soft-deleted, paused, assigned, checked-out, and dependency-blocked rows can satisfy dependencies where allowed, but must never be surfaced or claimed as auto-claim candidates. */ export function isRunnableAutoClaimCandidate(task: Task, tasksById: ReadonlyMap): boolean { return task.column === "todo" @@ -68,6 +71,9 @@ export function toAutoClaimCandidate(task: Task, now: number): AutoClaimCandidat FNXC:AutoClaim 2026-06-21-10:35: FN-6850 requires a canonical re-resolution gate before cached candidates are displayed or claimed, because FN-6812 showed a superseded triage task could remain in the 30s cache with an old runnable title. Use one fresh slim task list for the bounded candidate subset and rebuild survivors from current rows instead of fanning out per-candidate getTask calls. + +FNXC:AutoClaim 2026-06-21-16:09: +The fresh slim list intentionally includes archived rows by default so the shared predicate, not storage filtering, proves archived-while-cached rows are dropped before heartbeat prompt rendering or winner selection. */ export async function resolveFreshAutoClaimCandidates( taskStore: Pick,