diff --git a/doc/plans/2026-10-30-attention-retention-sleep.md b/doc/plans/2026-10-30-attention-retention-sleep.md index 92241aefc4f..b680241adf7 100644 --- a/doc/plans/2026-10-30-attention-retention-sleep.md +++ b/doc/plans/2026-10-30-attention-retention-sleep.md @@ -48,3 +48,5 @@ Local embedded PostgreSQL could not start on the test host because system semaph Review the source parity fixtures and producer hints together. The intended outcome is unchanged archive state and notifications with no recurring retention SQL when the sources remain unchanged. No merge or deployment is part of this change. Issue producer invalidation has its own finite discovery budget. Reverse parent/blocker traversal returns at most 101 distinct UUIDs in one recursive query, with each relation adjacency capped at 101 unique edges before materialization. At most 100 affected issues produce one sorted canonical batch hint; an overflow sentinel produces one company hint and the existing complete paged reconstruction. UNION deduplication terminates cycles. A PostgreSQL EXPLAIN ANALYZE on 2,100 reverse edges proves the outer limit stops at 101 CTE rows while the indexed relation branch returns only 100 rows. Repeated identical writes coalesce by canonical key. The shared scheduler is unchanged: a broader company hint does not cancel already pending narrower scopes; those remain finite batches of at most 100 IDs, processed at the existing 25-scope pass limit. This permits redundant bounded reconstruction instead of discarding invalidations or introducing cursor/subsumption policy across other owners. A 151-node cyclic chain proves overflow still registers the far ancestor's exact archive deadline and settles quietly afterward. + +The ordinary feed retains conservative `needs_attention` entries for roots outside the shared graph budget. The batch finalization visits every input root; a root without explored edges points attention at itself. A real PostgreSQL regression with 2,001 blocked roots verifies the nonempty deferred set, bounded graph size, ordinary issue-list metadata, and all 2,001 attention-feed subject IDs. This is a consumer parity proof; it does not increase graph traversal or change archive eligibility. diff --git a/server/src/__tests__/attention-retention-work.test.ts b/server/src/__tests__/attention-retention-work.test.ts index f06355e1ae7..ce24ac1081b 100644 --- a/server/src/__tests__/attention-retention-work.test.ts +++ b/server/src/__tests__/attention-retention-work.test.ts @@ -318,6 +318,25 @@ const delay = DEFAULT_DECISION_ARCHIVE_DAYS * 86_400_000; } }); + it("keeps deferred roots visible in the ordinary attention feed above the root cap", async () => { + await db.execute(sql`insert into issues (id, company_id, title, status, updated_at) + select gen_random_uuid(), ${companyId}::uuid, 'overflow-root-' || n, 'blocked', ${new Date().toISOString()}::timestamptz + from generate_series(1, ${BLOCKER_ATTENTION_MAX_NODES + 1}) n`); + const roots = await db.select().from(issues).where(eq(issues.companyId, companyId)); + const batch = await issueService(db).listBlockerAttentionBatch(companyId, roots); + expect(batch.deferredRootIds.size).toBeGreaterThan(0); + expect(batch.nodeCount).toBeLessThanOrEqual(BLOCKER_ATTENTION_MAX_NODES); + for (const rootId of batch.deferredRootIds) expect(batch.attention.get(rootId)).toMatchObject({ + state: "needs_attention", reason: "attention_required", terminalBlockerIssueId: rootId, + }); + const listed = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); + expect(listed).toHaveLength(roots.length); + for (const root of listed) expect(root.blockerAttention).toMatchObject({ state: "needs_attention", terminalBlockerIssueId: root.id }); + const feed = await attentionService(db).list(companyId, { all: true, allowUnscopedAll: true }); + const visible = new Set(feed.items.filter(item => item.sourceKind === "blocker_attention").map(item => item.subject.id)); + expect(visible).toEqual(new Set(roots.map(root => root.id))); + }); + it("bounds every graph query when independent root trees contain over 65535 unfinished nodes", async () => { await db.execute(sql`insert into issues (id, company_id, title, status, updated_at) select gen_random_uuid(), ${companyId}::uuid, 'parameter-root-' || n, 'blocked', ${old().toISOString()}::timestamptz