FN-5792: fix GitHub auto-close reconciliation for soft-deleted tasks

Ensure GitHub issue auto-close reconciliation still processes soft-deleted and archived tracked tasks after restart.

- Add pagination-aware reconcile scanning in core store with combined deleted+archived coverage and hasMore metadata.
- Update dashboard GitHub tracking reconciler and route handling to consume paged reconcile batches safely.
- Expand reconcile tests (core + dashboard) with restart/periodic sweep and source-issue scenarios to lock behavior.
- Add release changeset and dashboard guide note for the reconciliation sweep behavior.

Files changed:
 .changeset/fn-5792-github-tracking-sweep.md        |   5 +
 docs/dashboard-guide.md                            |   1 +
 .../store-github-tracking-reconcile.test.ts        |  45 ++++++++-
 packages/core/src/store.ts                         |  32 ++++--
 .../github-source-issue-reconciler.test.ts         |  22 ++--
 ...ithub-tracking-periodic-reconcile-sweep.test.ts |  90 +++++++++++++++++
 .../__tests__/github-tracking-reconciler.test.ts   | 111 +++++----------------
 .../dashboard/src/github-tracking-reconciler.ts    |  19 ++--
 .../dashboard/src/routes/register-git-github.ts    |  57 +++++++++--
 9 files changed, 257 insertions(+), 125 deletions(-)

Fusion-Task-Id: FN-5792

Fusion-Task-Lineage: 259f07cf-3470-47c7-b47a-86d212d44d52
This commit is contained in:
gsxdsm
2026-05-31 17:23:26 -07:00
parent 716f3964c5
commit ffadb0c77f
9 changed files with 257 additions and 125 deletions

View File

@@ -64,7 +64,7 @@ describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
const softDeletedWithoutTracking = await store.createTask({ description: "soft deleted no tracking" });
await store.deleteTask(softDeletedWithoutTracking.id);
const tasks = await store.listTasksForGithubTrackingReconcile();
const { tasks, hasMore } = await store.listTasksForGithubTrackingReconcile();
const byId = new Map(tasks.map((task) => [task.id, task]));
expect(byId.has(softDeleted.id)).toBe(true);
@@ -76,13 +76,52 @@ describe("TaskStore.listTasksForGithubTrackingReconcile", () => {
expect(byId.has(activeTracked.id)).toBe(false);
expect(byId.has(softDeletedWithoutTracking.id)).toBe(false);
expect(hasMore).toBe(false);
});
it("returns empty results when nothing matches", async () => {
const task = await store.createTask({ description: "no tracking" });
await store.moveTask(task.id, "todo");
const tasks = await store.listTasksForGithubTrackingReconcile();
expect(tasks).toEqual([]);
const result = await store.listTasksForGithubTrackingReconcile();
expect(result).toEqual({ tasks: [], hasMore: false });
});
it("paginates across soft-deleted and archived tracked entries", async () => {
for (let i = 0; i < 3; i += 1) {
const task = await store.createTask({ description: `deleted ${i}` });
await store.updateGithubTracking(task.id, { enabled: true });
await store.deleteTask(task.id);
}
for (let i = 0; i < 3; i += 1) {
const task = await store.createTask({ description: `archived ${i}` });
await store.updateGithubTracking(task.id, { enabled: true });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
}
const page1 = await store.listTasksForGithubTrackingReconcile({ offset: 0, limit: 2 });
const page2 = await store.listTasksForGithubTrackingReconcile({ offset: 2, limit: 2 });
const page3 = await store.listTasksForGithubTrackingReconcile({ offset: 4, limit: 2 });
expect(page1.tasks).toHaveLength(2);
expect(page2.tasks).toHaveLength(2);
expect(page3.tasks).toHaveLength(2);
const seen = new Set([...page1.tasks, ...page2.tasks, ...page3.tasks].map((task) => task.id));
expect(seen.size).toBe(6);
expect(page1.hasMore).toBe(true);
expect(page2.hasMore).toBe(true);
expect(page3.hasMore).toBe(false);
const activeTracked = await store.createTask({ description: "active tracked no reconcile" });
await store.updateGithubTracking(activeTracked.id, { enabled: true });
const finalPage = await store.listTasksForGithubTrackingReconcile({ offset: 0, limit: 20 });
expect(finalPage.tasks.some((task) => task.id === activeTracked.id)).toBe(false);
});
});

View File

@@ -4653,15 +4653,23 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit));
}
async listTasksForGithubTrackingReconcile(): Promise<Task[]> {
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
const reconcileScanLimit = 200;
const offset = Math.max(0, options?.offset ?? 0);
const limit = Math.max(0, options?.limit ?? reconcileScanLimit);
const selectClause = this.getTaskSelectClause(true);
// FN-5577: GitHub tracking reconciliation must inspect soft-deleted rows,
// so this query intentionally bypasses ACTIVE_TASKS_WHERE.
const deletedTotal = this.db.prepare(
"SELECT COUNT(*) as count FROM tasks WHERE \"deletedAt\" IS NOT NULL AND \"githubTracking\" IS NOT NULL",
).get() as { count: number } | undefined;
const deletedCount = Number(deletedTotal?.count ?? 0);
const deletedOffset = Math.min(offset, deletedCount);
const deletedRows = this.db.prepare(
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ?`,
).all(reconcileScanLimit) as unknown as TaskRow[];
`SELECT ${selectClause} FROM tasks WHERE "deletedAt" IS NOT NULL AND "githubTracking" IS NOT NULL ORDER BY updatedAt ASC LIMIT ? OFFSET ?`,
).all(limit, deletedOffset) as unknown as TaskRow[];
const deletedTasks = deletedRows.map((row) => {
const task = this.rowToTask(row);
@@ -4671,17 +4679,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
let archivedTasks: Task[] = [];
let archivedCount = 0;
try {
archivedTasks = this.archiveDb
const archivedCandidates = this.archiveDb
.list()
.map((entry) => this.archiveEntryToTask(entry, true))
.filter((task) => Boolean(task.githubTracking))
.slice(0, reconcileScanLimit);
.filter((task) => Boolean(task.githubTracking));
archivedCount = archivedCandidates.length;
const archivedOffset = Math.max(0, offset - deletedCount);
const remainingLimit = Math.max(0, limit - deletedTasks.length);
archivedTasks = remainingLimit > 0
? archivedCandidates.slice(archivedOffset, archivedOffset + remainingLimit)
: [];
} catch {
archivedTasks = [];
archivedCount = 0;
}
return [...deletedTasks, ...archivedTasks].slice(0, reconcileScanLimit);
const totalCount = deletedCount + archivedCount;
const hasMore = offset + limit < totalCount;
return { tasks: [...deletedTasks, ...archivedTasks], hasMore };
}
async listStrandedRefinements(options?: {