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:
@@ -876,7 +876,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
|
||||
if (settings.autoMerge) {
|
||||
const existing = await store.listTasks();
|
||||
const existing = await store.listTasks({ column: "in-review" });
|
||||
const inReview = existing.filter((t) => canAutoMergeTask(t as any));
|
||||
if (inReview.length > 0) {
|
||||
console.log(
|
||||
@@ -911,7 +911,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -935,7 +935,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -999,7 +999,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Refresh the cached limit so the semaphore picks up live changes
|
||||
cachedMaxConcurrent = s.maxConcurrent;
|
||||
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -365,7 +365,10 @@ export class SelfHealingManager {
|
||||
|
||||
async archiveStaleDoneTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
// Slim listing — we only need id/column/columnMovedAt/updatedAt to decide
|
||||
// staleness. Pulling full task payloads (logs, comments, steps) here used
|
||||
// to drag in tens of MB on busy boards and stalled the maintenance loop.
|
||||
const tasks = await this.store.listTasks({ slim: true });
|
||||
const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
|
||||
|
||||
const stale = tasks.filter((t) => {
|
||||
|
||||
Reference in New Issue
Block a user