perf(dashboard): kill startup SSE storm and slim hot-path task scans

- TaskStore.watch() now initializes lastPollTime so the first
  checkForChanges() poll filters by "modified since now" instead of
  doing an unfiltered SELECT * and emitting a task:updated event for
  every cached task. On a 1200-task board this dropped ~60 MB of SSE
  traffic and a 1199-call setState storm one second after dashboard
  startup.
- listTasks() gains a column option so callers can filter in SQL.
- dashboard CLI auto-merge sweeps (startup + 2 unpause handlers + the
  15s periodic retry) now use listTasks({ column: "in-review" })
  instead of pulling the full table on every cycle.
- self-healing archiveStaleDoneTasks() uses slim listTasks — it only
  needs id/column/columnMovedAt to decide staleness.
- Document the listTasks() perf contract and the watch() polling
  invariant in AGENTS.md and project memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-10 20:40:42 -07:00
parent 54fdeb66df
commit 1fe55b22f1
5 changed files with 112 additions and 7 deletions

View File

@@ -1316,9 +1316,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* from each row to make list responses cheap for board-style consumers. Detail fields default
* to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */
slim?: boolean;
/** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */
column?: Column;
}): Promise<Task[]> {
const includeArchived = options?.includeArchived ?? true;
const slim = options?.slim ?? false;
const columnFilter = options?.column;
const slimColumns = `
id, title, description, "column", status, size, reviewLevel, currentStep,
@@ -1336,10 +1339,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkedOutBy, checkedOutAt
`;
const selectClause = slim ? slimColumns : '*';
const whereClause = includeArchived ? '' : ` WHERE "column" != 'archived'`;
const whereParts: string[] = [];
const params: string[] = [];
if (columnFilter) {
whereParts.push(`"column" = ?`);
params.push(columnFilter);
} else if (!includeArchived) {
whereParts.push(`"column" != 'archived'`);
}
const whereClause = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : "";
const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`;
const rows = this.db.prepare(sql).all();
const rows = this.db.prepare(sql).all(...params);
const tasks = (rows as any[]).map((row) => this.rowToTask(row));
// Sort by createdAt, then by numeric ID suffix for tie-breaking
@@ -2699,6 +2710,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Store current lastModified
this.lastKnownModified = this.db.getLastModified();
// Initialize lastPollTime so the first checkForChanges() cycle filters by
// "modified since now" instead of doing a full SELECT * + emitting an
// update event for every cached task. Without this, dashboard startup
// re-loaded the entire tasks table 1s after watch() began.
this.lastPollTime = new Date().toISOString();
// Use a sentinel watcher object so existing code that checks `this.watcher` still works
try {