fix(core): polling loop falsely emits task:deleted for archived tasks

TaskStore.checkForChanges detects deletions by comparing the in-memory
taskCache against the tasks table. But archiveTask also DELETEs the row
from `tasks` (after copying to archive_db), so any TaskStore instance
polling the same DB sees the archived task vanish and emits
`task:deleted`. The activity-log listener records that as a deletion,
producing entries like "Task FN-NNNN deleted" for tasks that are alive
and well in the archive.

Reproduced live: 2048 task:deleted entries in a single ~1ms burst, all
of them present in archive.db. Two TaskStores (CLI/engine and dashboard
server) on the same DB → CLI archives, dashboard polls and false-flags.

Fix: in checkForChanges, batch-query the archive for all missing IDs.
For ids that exist in archived_tasks, emit `task:moved` (to:archived) —
matching what archiveTask emits in-process — so the activity log
records the correct event. For ids not in archive, emit task:deleted as
before (real deletion).

Adds ArchiveDatabase.filterArchived(ids) helper that returns the subset
in archived_tasks via a single SELECT IN query (chunked at 500 to stay
under SQLite's parameter limit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 17:46:05 -07:00
parent b3b0ce7cb0
commit dba2299137
2 changed files with 47 additions and 4 deletions

View File

@@ -124,6 +124,29 @@ export class ArchiveDatabase {
return row ? JSON.parse(row.taskJson) as ArchivedTaskEntry : undefined;
}
/**
* Return the subset of `ids` that are present in archived_tasks.
* Used by TaskStore.checkForChanges to distinguish a real deletion from
* an archive (both look like "row gone from tasks table" to the polling
* loop). Single-shot query — much cheaper than N `get()` calls when many
* tasks are archived in a batch.
*/
filterArchived(ids: readonly string[]): Set<string> {
if (ids.length === 0) return new Set();
// SQLite parameter limit defaults to 32766; chunk to be safe.
const result = new Set<string>();
const CHUNK = 500;
for (let i = 0; i < ids.length; i += CHUNK) {
const chunk = ids.slice(i, i + CHUNK);
const placeholders = chunk.map(() => "?").join(",");
const rows = this.db
.prepare(`SELECT id FROM archived_tasks WHERE id IN (${placeholders})`)
.all(...chunk) as Array<{ id: string }>;
for (const row of rows) result.add(row.id);
}
return result;
}
delete(id: string): void {
this.db.prepare("DELETE FROM archived_tasks WHERE id = ?").run(id);
}

View File

@@ -3913,13 +3913,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (currentModified <= this.lastKnownModified) return;
this.lastKnownModified = currentModified;
// Detect deletions cheaply: compare ID sets without loading full rows
// Detect deletions cheaply: compare ID sets without loading full rows.
// A row missing from `tasks` can mean two things: the task was actually
// deleted, OR it was archived (archiveTask removes it from `tasks` after
// copying into `archived_tasks`). Other TaskStore instances polling the
// same DB can't tell the difference from this view alone — without the
// archive check below they emit spurious task:deleted events for every
// archived task, which the activity log records as a deletion.
const idRows = this.db.prepare('SELECT id FROM tasks').all() as Array<{ id: string }>;
const currentIds = new Set(idRows.map((r) => r.id));
for (const [id, cached] of this.taskCache) {
if (!currentIds.has(id)) {
const missingIds: string[] = [];
for (const id of this.taskCache.keys()) {
if (!currentIds.has(id)) missingIds.push(id);
}
if (missingIds.length > 0) {
const archivedSet = this.archiveDb.filterArchived(missingIds);
for (const id of missingIds) {
const cached = this.taskCache.get(id);
if (!cached) continue;
this.taskCache.delete(id);
this.emit("task:deleted", cached);
if (archivedSet.has(id)) {
// Task moved to archive — emit task:moved (matching what
// archiveTask emits in-process) so the activity-log listener
// records it correctly.
this.emit("task:moved", { task: cached, from: cached.column, to: "archived" as Column });
} else {
this.emit("task:deleted", cached);
}
}
}