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