From 3933a10800e23b75dc9440ce0e455ec23c4e6e1b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:54:42 +0000 Subject: [PATCH 1/3] fix(overseer): de-flood upstream PR notifications in the inbox (title + scoring) The operator inbox was dominated by upstream GitHub PR-notification items: ~25 of 174 items had a bare PR URL as their entire title, and channel-sourced PR babysit items shared the worker/system priority scale so they occupied the highest-priority tier, drowning genuine operator-attention items. Ingest + scoring fixes (channel producer = contrib-state / meta-daily): - Title: pickPrimaryArtifactTitle now synthesizes a human ref ("tiann/hapi#987", or "repo#num: " when a title is present, or parsed from the PR/issue URL) and never falls through to a bare https URL. - Scoring: computeCoarseBasePriority is sourceKind-aware. Channel items (external GitHub PR notifications) are demoted below every worker/system item via CHANNEL_PRIORITY_OFFSET, and `progress` gets a defined rank instead of the unknown default. Genuine blocked/needs_decision/failed workers now always rank above routine PR notifications; order within the channel band is preserved. promoteAttentionEvent threads event.sourceKind. - Backfill: ensureOverseerInboxSchema re-derives title + base_priority for existing rows from their latest source event (deterministic; title/priority only, never status), so the live wall is repaired on next hub start. - Producer: pec_build_channel_event_body gains --title (emitted into the artifactRef only when set); wired at the notification site where the GitHub subject title is in hand. Tests: overseerInbox (channel demotion, progress rank, repo#number + no-bare-URL regression), store (channel promotion + backfill repair), pr-emoji-core (--title). Co-authored-by: Cursor <cursoragent@cursor.com> --- hub/src/store/inboxItems.test.ts | 85 +++++++++++++++++++++++ hub/src/store/inboxItems.ts | 55 ++++++++++++++- scripts/tooling/hapi-meta-daily.sh | 1 + scripts/tooling/lib/pr-emoji-core.sh | 28 +++++--- scripts/tooling/lib/pr-emoji-core.test.sh | 9 +++ shared/src/overseerInbox.test.ts | 53 +++++++++++++- shared/src/overseerInbox.ts | 56 +++++++++++++-- 7 files changed, 270 insertions(+), 17 deletions(-) diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts index b01933ed86..93485d5f80 100644 --- a/hub/src/store/inboxItems.test.ts +++ b/hub/src/store/inboxItems.test.ts @@ -3,6 +3,7 @@ import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hap import { Store } from './index' import type { StoredSession } from './types' import { deleteSession } from './sessions' +import { backfillInboxDerivedFields } from './inboxItems' import { Database } from 'bun:sqlite' function payloadForSession(session: StoredSession, extra: Record<string, unknown> = {}): string { @@ -137,6 +138,90 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { expect(item?.reasonForPriority).toContain('QUESTION tier') }) + it('demotes external channel PR items and titles them repo#number', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('pr-babysit', { name: 'peer-x' }, null, 'default') + const refs = JSON.stringify([ + { kind: 'github_pr', url: 'https://github.com/tiann/hapi/pull/987', repo: 'tiann/hapi', number: 987, source: 'external' } + ]) + const channelBlocked = store.events.insert({ + ts: 1000, + sourceKind: 'channel', + sourceRef: 'contrib-state:tiann/hapi', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'resolve 1 open thread(s)', + artifactRefs: refs, + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'contrib-state@meta-daily' + }) + const item = store.inbox.promoteAttentionEvent(channelBlocked!) + // Title is a human ref, never the bare URL. + expect(item?.title).toBe('tiann/hapi#987') + // Channel band = worker rank (20) + offset (100). + expect(item?.basePriority).toBe(120) + + // A genuine worker blocker on another session still ranks first. + const worker = store.sessions.getOrCreateSession('real-blocker', { name: 'worker' }, null, 'default') + const workerBlocked = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'CI failed', + relatedSessionId: worker.id, + payloadJson: payloadForSession(worker), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(workerBlocked!) + const ordered = store.inbox.list({ activeOnly: true }) + expect(ordered[0]?.title).toBe('worker') + expect(ordered[ordered.length - 1]?.title).toBe('tiann/hapi#987') + }) + + it('backfill repairs legacy bare-URL titles and worker-band priority', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const session = store.sessions.getOrCreateSession('legacy', { name: 'legacy' }, null, 'default') + const refs = JSON.stringify([ + { kind: 'github_pr', url: 'https://github.com/tiann/hapi/pull/958', repo: 'tiann/hapi', number: 958, source: 'external' } + ]) + const event = store.events.insert({ + ts: 1000, + sourceKind: 'channel', + sourceRef: 'contrib-state:tiann/hapi', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'fix failing CI', + artifactRefs: refs, + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'contrib-state@meta-daily' + }) + // Simulate a row promoted BEFORE this change: bare-URL title, worker-band priority. + const inserted = db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + artifact_refs, attention_class, created_at, updated_at, related_session_id, + title, category, summary + ) VALUES ( + 'surfaced', 20, 20, ?, '[]', ?, 'live', 1, 1, ?, + 'https://github.com/tiann/hapi/pull/958', 'BLOCKED', 'fix failing CI' + ) + `).run(JSON.stringify([event!.id]), refs, session.id) + const id = Number(inserted.lastInsertRowid) + + backfillInboxDerivedFields(db) + + const fixed = store.inbox.getById(id) + expect(fixed?.title).toBe('tiann/hapi#958') + expect(fixed?.basePriority).toBe(120) + expect(fixed?.priority).toBe(120) + // Status untouched by backfill. + expect(fixed?.status).toBe('surfaced') + }) + it('records operator actions as training labels', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('actions', { name: 'actions' }, null, 'default') diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts index ed45580ceb..f8be90bfab 100644 --- a/hub/src/store/inboxItems.ts +++ b/hub/src/store/inboxItems.ts @@ -218,7 +218,7 @@ export function promoteAttentionEvent( const now = Date.now() const category = mapEventTypeToInboxCategory(event.eventType) - const basePriority = computeCoarseBasePriority(event.eventType) + const basePriority = computeCoarseBasePriority(event.eventType, event.sourceKind) const title = buildInboxTitleFromEvent(event.artifactRefs, event.payloadJson, event.summary) const suggestedAction = extractSuggestedAction(event.payloadJson) const existing = findActiveInboxItemForSession(db, event.relatedSessionId) @@ -347,6 +347,57 @@ function extractSuggestedAction(payloadJson: string | null): string | null { return null } +/** + * Idempotent backfill — re-derive `title` + `base_priority`/`priority` for + * existing inbox rows from their latest source event. Deterministic (pure + * functions of the event + artifact refs) and touches only title/priority + * (never status/resolution/feedback), so it is safe to run on every boot. + * + * Repairs rows promoted before the title-synthesis + channel-priority-band + * changes (bare-PR-URL titles at worker-band priority) without waiting for the + * next PR transition to re-promote them. A no-op once every row already matches. + */ +export function backfillInboxDerivedFields(db: Database): void { + const rows = db.prepare( + 'SELECT id, title, base_priority, artifact_refs, summary, source_event_ids FROM inbox_items' + ).all() as Array<{ + id: number + title: string + base_priority: number + artifact_refs: string | null + summary: string + source_event_ids: string | null + }> + if (rows.length === 0) return + + const update = db.prepare( + 'UPDATE inbox_items SET title = ?, base_priority = ?, priority = ? WHERE id = ?' + ) + for (const row of rows) { + const eventIds = parseIdArray(row.source_event_ids) + const latestId = eventIds.length > 0 ? Math.max(...eventIds) : null + const latest = latestId !== null ? getSystemEventById(db, latestId) : null + + // Only recompute the title when we have material to derive it from, + // so a row with a good session-name title and a since-deleted event + // is never regressed to its summary. + let nextTitle = row.title + if (latest || row.artifact_refs) { + nextTitle = buildInboxTitleFromEvent( + row.artifact_refs ?? latest?.artifactRefs ?? null, + latest?.payloadJson ?? null, + row.summary + ) + } + const nextPriority = latest + ? computeCoarseBasePriority(latest.eventType, latest.sourceKind) + : row.base_priority + + if (nextTitle === row.title && nextPriority === row.base_priority) continue + update.run(nextTitle, nextPriority, nextPriority, row.id) + } +} + /** * Idempotent Overseer inbox DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. */ @@ -399,6 +450,8 @@ export function ensureOverseerInboxSchema(db: Database): void { ); CREATE INDEX IF NOT EXISTS idx_inbox_operator_actions_item ON inbox_operator_actions(inbox_item_id, created_at DESC); `) + + backfillInboxDerivedFields(db) } export function dropOverseerInboxSchema(db: Database): void { diff --git a/scripts/tooling/hapi-meta-daily.sh b/scripts/tooling/hapi-meta-daily.sh index bc0dd48910..0eb088069e 100755 --- a/scripts/tooling/hapi-meta-daily.sh +++ b/scripts/tooling/hapi-meta-daily.sh @@ -577,6 +577,7 @@ _emit_notif_event() { --reason transition \ --date "$date" \ --notif \ + --title "$title" \ --url "${url:-https://github.com/${repo}/pull/${pr}}")" hub_emit_event "$jwt" "$body" || { MD_EMIT_FAILURES=$((MD_EMIT_FAILURES + 1)) diff --git a/scripts/tooling/lib/pr-emoji-core.sh b/scripts/tooling/lib/pr-emoji-core.sh index 12a858dd0b..fe31e75a89 100644 --- a/scripts/tooling/lib/pr-emoji-core.sh +++ b/scripts/tooling/lib/pr-emoji-core.sh @@ -408,7 +408,7 @@ pec_severity_for_emoji() { # --date YYYY-MM-DD [--notif] [--url] pec_build_channel_event_body() { local repo="" number="" emoji="" action="" fingerprint="" session_id="" \ - reason="transition" date="" notif=0 url="" + reason="transition" date="" notif=0 url="" title="" while [[ $# -gt 0 ]]; do case "$1" in --repo) repo="$2"; shift 2 ;; @@ -421,6 +421,7 @@ pec_build_channel_event_body() { --date) date="$2"; shift 2 ;; --notif) notif=1; shift ;; --url) url="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; *) echo "pec_build_channel_event_body: unknown arg $1" >&2; return 2 ;; esac done @@ -449,6 +450,8 @@ pec_build_channel_event_body() { summary="${action:-ContributionState $emoji}" [[ ${#summary} -gt 280 ]] && summary="${summary:0:277}..." [[ -z "$url" ]] && url="https://github.com/${repo}/pull/${number}" + # PR/issue titles run long; keep the artifact label human-scannable. + [[ ${#title} -gt 120 ]] && title="${title:0:117}..." local github_state="open" [[ "$emoji" == "🔧" ]] && github_state="merged" @@ -471,6 +474,7 @@ pec_build_channel_event_body() { --arg reason "$reason" \ --arg dedupe "$dedupe" \ --arg idempo "$idempo" \ + --arg title "$title" \ --argjson severity "$severity" \ '{ sourceKind: "channel", @@ -480,16 +484,18 @@ pec_build_channel_event_body() { operatorActionRequired: $opReq, summary: $summary, relatedSessionId: (if $sessionId == "" then null else $sessionId end), - artifactRefs: [{ - kind: "github_pr", - url: $url, - repo: $repo, - number: $number, - target_id: $target, - control: $control, - github_state: $ghState, - source: "external" - }], + artifactRefs: [( + { + kind: "github_pr", + url: $url, + repo: $repo, + number: $number, + target_id: $target, + control: $control, + github_state: $ghState, + source: "external" + } + (if $title == "" then {} else {title: $title} end) + )], payload: { emoji: $emoji, action: $action, emitReason: $reason }, tags: ["contrib-state"], dedupeKey: $dedupe, diff --git a/scripts/tooling/lib/pr-emoji-core.test.sh b/scripts/tooling/lib/pr-emoji-core.test.sh index a15301701d..1e78d03083 100644 --- a/scripts/tooling/lib/pr-emoji-core.test.sh +++ b/scripts/tooling/lib/pr-emoji-core.test.sh @@ -201,6 +201,15 @@ eq "body dedupeKey embeds fingerprint + session" \ eq "body idempotencyKey embeds session" \ "$(jq -r '.idempotencyKey' <<<"$body")" \ "contrib:tiann/hapi#999:$FP_A:sess:aaaaaaaa-1111" +eq "body omits artifact title when --title not given" \ + "$(jq -r '.artifactRefs[0].title // "ABSENT"' <<<"$body")" "ABSENT" + +body_titled="$(pec_build_channel_event_body \ + --repo tiann/hapi --number 1215 --emoji "⚠️" --action "fix CI" \ + --fingerprint "$FP_A" --session-id "aaaaaaaa-1111" --reason transition \ + --date 2026-07-25 --title "feat(web): rich composer")" +eq "body carries artifact title when --title given" \ + "$(jq -r '.artifactRefs[0].title' <<<"$body_titled")" "feat(web): rich composer" # Two sessions tracking the same PR + fingerprint → distinct body keys. body_s1="$(pec_build_channel_event_body \ diff --git a/shared/src/overseerInbox.test.ts b/shared/src/overseerInbox.test.ts index 7714146bd5..f17cf21f32 100644 --- a/shared/src/overseerInbox.test.ts +++ b/shared/src/overseerInbox.test.ts @@ -5,7 +5,8 @@ import { buildInboxTitleFromEvent, computeCoarseBasePriority, mapEventTypeToInboxCategory, - mapOperatorActionToStatus + mapOperatorActionToStatus, + parseGithubRefFromUrl } from './overseerInbox' describe('overseerInbox', () => { @@ -16,6 +17,26 @@ describe('overseerInbox', () => { expect(computeCoarseBasePriority('completed')).toBeLessThan(computeCoarseBasePriority('stale')) }) + it('gives progress a defined rank below stale (not the unknown default)', () => { + expect(computeCoarseBasePriority('progress')).toBeGreaterThan(computeCoarseBasePriority('stale')) + expect(computeCoarseBasePriority('progress')).not.toBe(computeCoarseBasePriority('some_unknown_type')) + }) + + it('demotes external channel items below every worker/system item', () => { + // The most urgent channel event (blocked PR babysit) must still rank + // below the least urgent genuine worker item (unknown default = 70). + const channelBlocked = computeCoarseBasePriority('blocked', 'channel') + const workerDefault = computeCoarseBasePriority('anything', 'worker') + expect(channelBlocked).toBeGreaterThan(workerDefault) + // Order within the channel band is preserved. + expect(computeCoarseBasePriority('blocked', 'channel')) + .toBeLessThan(computeCoarseBasePriority('needs_decision', 'channel')) + expect(computeCoarseBasePriority('completed', 'channel')) + .toBeLessThan(computeCoarseBasePriority('progress', 'channel')) + // Non-channel source is unaffected. + expect(computeCoarseBasePriority('blocked')).toBe(computeCoarseBasePriority('blocked', 'worker')) + }) + it('maps event types to inbox category badges', () => { expect(mapEventTypeToInboxCategory('approval_requested')).toBe('APPROVAL') expect(mapEventTypeToInboxCategory('blocked')).toBe('BLOCKED') @@ -32,6 +53,36 @@ describe('overseerInbox', () => { expect(buildInboxTitleFromEvent(null, null, 'summary body')).toBe('summary body') }) + it('never renders a bare PR URL as the inbox title (regression: #wall-of-urls)', () => { + // Real shape emitted by contrib-state before the producer carried a title. + const refs = JSON.stringify([{ + kind: 'github_pr', + url: 'https://github.com/tiann/hapi/pull/987', + repo: 'tiann/hapi', + number: 987 + }]) + expect(buildInboxTitleFromEvent(refs, null, 'resolve 1 open thread(s)')) + .toBe('tiann/hapi#987') + }) + + it('combines repo#number with the PR title when the producer carries one', () => { + const refs = JSON.stringify([{ + kind: 'github_pr', + url: 'https://github.com/tiann/hapi/pull/1215', + repo: 'tiann/hapi', + number: 1215, + title: 'feat(web): rich composer' + }]) + expect(buildInboxTitleFromEvent(refs, null, 'x')).toBe('tiann/hapi#1215: feat(web): rich composer') + }) + + it('derives repo#number from a github URL when repo/number fields are absent', () => { + const refs = JSON.stringify([{ kind: 'github_pr', url: 'https://github.com/tiann/hapi/pull/42' }]) + expect(buildInboxTitleFromEvent(refs, null, 'summary')).toBe('tiann/hapi#42') + expect(parseGithubRefFromUrl('https://github.com/heavygee/hapi/issues/7')).toBe('heavygee/hapi#7') + expect(parseGithubRefFromUrl('https://example.com/foo')).toBeNull() + }) + it('builds explain_priority lite from category age and source ids', () => { const now = Date.UTC(2026, 5, 20, 12, 0, 0) const createdAt = now - 15 * 60_000 diff --git a/shared/src/overseerInbox.ts b/shared/src/overseerInbox.ts index b8a115548e..cee8ff3e23 100644 --- a/shared/src/overseerInbox.ts +++ b/shared/src/overseerInbox.ts @@ -38,6 +38,8 @@ export type ArtifactRef = { url?: string title?: string ref?: string + repo?: string + number?: number } const TITLE_PRIORITY_KINDS = [ @@ -48,8 +50,15 @@ const TITLE_PRIORITY_KINDS = [ 'deploy_id' ] as const -/** Fixed coarse rank — lower number = higher priority (v1, not learned). */ -export function computeCoarseBasePriority(eventType: string): number { +/** + * External channel notifications (e.g. the GitHub PR watcher) are routine and + * must never outrank genuine worker/system attention items. Demoting the whole + * channel band below the worker/system band (which maxes at 70) keeps a flood + * of upstream PR notifications from dominating triage. + */ +export const CHANNEL_PRIORITY_OFFSET = 100 + +function coarseRankForEventType(eventType: string): number { switch (eventType) { case 'approval_requested': case 'permission_request': @@ -66,11 +75,25 @@ export function computeCoarseBasePriority(eventType: string): number { return 50 case 'stale': return 60 + case 'progress': + return 65 default: return 70 } } +/** + * Fixed coarse rank — lower number = higher priority (v1, not learned). + * `sourceKind === 'channel'` items (external GitHub/PR notifications) are + * demoted below every worker/system item via {@link CHANNEL_PRIORITY_OFFSET}, + * so genuine operator items (blocked workers, needs_decision, failures) always + * rank above routine PR notifications while preserving order within each band. + */ +export function computeCoarseBasePriority(eventType: string, sourceKind?: string | null): number { + const rank = coarseRankForEventType(eventType) + return sourceKind === 'channel' ? rank + CHANNEL_PRIORITY_OFFSET : rank +} + export function mapEventTypeToInboxCategory(eventType: string): InboxCategory { switch (eventType) { case 'approval_requested': @@ -104,16 +127,41 @@ export function parseArtifactRefs(raw: string | null | undefined): ArtifactRef[] } } +const GITHUB_REF_URL_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:pull|issues)\/(\d+)/i + +/** "owner/repo#123" from a GitHub PR/issue URL, else null. */ +export function parseGithubRefFromUrl(url: string | null | undefined): string | null { + if (!url) return null + const match = GITHUB_REF_URL_RE.exec(url) + if (!match) return null + return `${match[1]}/${match[2]}#${match[3]}` +} + +/** Compact human ref for an artifact ("owner/repo#123"), never a bare URL. */ +function shortRepoRef(ref: ArtifactRef): string | null { + if (ref.repo?.trim() && typeof ref.number === 'number') { + return `${ref.repo.trim()}#${ref.number}` + } + return parseGithubRefFromUrl(ref.url) +} + export function pickPrimaryArtifactTitle(artifactRefs: ArtifactRef[]): string | null { for (const kind of TITLE_PRIORITY_KINDS) { const match = artifactRefs.find((ref) => ref.kind === kind) if (!match) continue - if (match.title?.trim()) return match.title.trim() + const shortRef = shortRepoRef(match) + const title = match.title?.trim() + if (title && shortRef) return `${shortRef}: ${title}` + if (title) return title + if (shortRef) return shortRef if (match.ref?.trim()) return match.ref.trim() - if (match.url?.trim()) return match.url.trim() + // Deliberately do NOT fall through to a bare match.url — a naked + // "https://github.com/…/pull/987" title is exactly the wall we kill. } for (const ref of artifactRefs) { if (ref.title?.trim()) return ref.title.trim() + const shortRef = shortRepoRef(ref) + if (shortRef) return shortRef if (ref.ref?.trim()) return ref.ref.trim() } return null From e6150ad59a32739af1e790da3a53dd32e27cc7f2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:27:11 +0000 Subject: [PATCH 2/3] feat(overseer): auto-dispose terminal inbox items so triage stops bloating A completed item is "nothing more to do - the only relevance is that it happened" (context, not attention). Let FINALE (completed) items decay off the active attention surface after a window, and immediately obsolete any lingering STALE rows (idle-silence detection was retired as noise; those are orphaned legacy). Rows are retained as history - status is moved out of the active set, never deleted. - sweepDecayedTerminalItems: resolve FINALE past FINALE_DECAY_WINDOW_MS (14d), obsolete STALE regardless of age. Idempotent. - Run on Store init (immediate effect on existing DB) and on the 5s sync tick (live decay) alongside checkStaleSessions. Co-authored-by: Cursor <cursoragent@cursor.com> --- hub/src/store/inboxItems.test.ts | 52 +++++++++++++++++++++++++++++++- hub/src/store/inboxItems.ts | 42 ++++++++++++++++++++++++++ hub/src/store/inboxStore.ts | 6 ++++ hub/src/sync/syncEngine.ts | 3 ++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts index 93485d5f80..d925337280 100644 --- a/hub/src/store/inboxItems.test.ts +++ b/hub/src/store/inboxItems.test.ts @@ -3,7 +3,7 @@ import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hap import { Store } from './index' import type { StoredSession } from './types' import { deleteSession } from './sessions' -import { backfillInboxDerivedFields } from './inboxItems' +import { backfillInboxDerivedFields, sweepDecayedTerminalItems, FINALE_DECAY_WINDOW_MS } from './inboxItems' import { Database } from 'bun:sqlite' function payloadForSession(session: StoredSession, extra: Record<string, unknown> = {}): string { @@ -222,6 +222,56 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { expect(fixed?.status).toBe('surfaced') }) + it('auto-resolves decayed FINALE items but keeps recent ones and non-terminal ones', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const now = 1_000_000_000_000 + const insert = (category: string, updatedAt: number, title: string): number => { + const res = db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + attention_class, created_at, updated_at, related_session_id, title, category, summary + ) VALUES ( + 'surfaced', 50, 50, '[]', '[]', 'live', 1, ?, NULL, ?, ?, 's' + ) + `).run(updatedAt, title, category) + return Number(res.lastInsertRowid) + } + const oldDone = insert('FINALE', now - FINALE_DECAY_WINDOW_MS - 1, 'old-done') + const freshDone = insert('FINALE', now - 1000, 'fresh-done') + const blocked = insert('BLOCKED', now - FINALE_DECAY_WINDOW_MS - 1, 'still-blocked') + + const disposed = sweepDecayedTerminalItems(db, now) + expect(disposed).toBe(1) + expect(store.inbox.getById(oldDone)?.status).toBe('resolved') + expect(store.inbox.getById(oldDone)?.resolvedAt).toBe(now) + expect(store.inbox.getById(freshDone)?.status).toBe('surfaced') + expect(store.inbox.getById(blocked)?.status).toBe('surfaced') + + // Idempotent: nothing left to sweep. + expect(sweepDecayedTerminalItems(db, now)).toBe(0) + }) + + it('obsoletes orphaned STALE items immediately regardless of age', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const now = 1_000_000_000_000 + const res = db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + attention_class, created_at, updated_at, related_session_id, title, category, summary + ) VALUES ( + 'surfaced', 60, 60, '[]', '[]', 'live', ?, ?, NULL, + 'No agent output for 30 minutes', 'STALE', 'silent' + ) + `).run(now - 1000, now - 1000) + const staleId = Number(res.lastInsertRowid) + + expect(sweepDecayedTerminalItems(db, now)).toBe(1) + expect(store.inbox.getById(staleId)?.status).toBe('obsoleted') + expect(store.inbox.getById(staleId)?.resolvedAt).toBe(now) + }) + it('records operator actions as training labels', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('actions', { name: 'actions' }, null, 'default') diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts index f8be90bfab..a72fc7d998 100644 --- a/hub/src/store/inboxItems.ts +++ b/hub/src/store/inboxItems.ts @@ -398,6 +398,47 @@ export function backfillInboxDerivedFields(db: Database): void { } } +/** + * How long a terminal (FINALE / completed) item stays on the active attention + * surface before it auto-resolves. A completed item is "nothing more to do — + * the only relevance is that it happened" (operator, 2026-07-31), so it is + * context, not attention. Keep it visible briefly, then get it out of the way. + */ +export const FINALE_DECAY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000 + +/** + * Auto-dispose terminal inbox items so a backlog of finished work stops crowding + * the operator's attention surface. Rows are RETAINED as history — status is + * moved out of the active set, never deleted. + * + * - FINALE (completed): resolve once the item has sat past the decay window. + * - STALE: idle-silence detection was retired as noise (see + * `OverseerEventRecorder.checkStaleSessions`, which now returns []). Any + * lingering STALE rows are orphaned legacy from before that removal — obsolete + * them immediately regardless of age. + * + * Idempotent: matches only active rows, so re-running changes nothing once swept. + * Returns the number of rows disposed. + */ +export function sweepDecayedTerminalItems( + db: Database, + now: number = Date.now(), + windowMs: number = FINALE_DECAY_WINDOW_MS +): number { + const active = "status IN ('new', 'surfaced', 'deferred', 'snoozed')" + const resolved = db.prepare( + `UPDATE inbox_items + SET status = 'resolved', resolved_at = ?, updated_at = ? + WHERE ${active} AND category = 'FINALE' AND updated_at < ?` + ).run(now, now, now - windowMs) + const obsoleted = db.prepare( + `UPDATE inbox_items + SET status = 'obsoleted', resolved_at = ?, updated_at = ? + WHERE ${active} AND category = 'STALE'` + ).run(now, now) + return resolved.changes + obsoleted.changes +} + /** * Idempotent Overseer inbox DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. */ @@ -452,6 +493,7 @@ export function ensureOverseerInboxSchema(db: Database): void { `) backfillInboxDerivedFields(db) + sweepDecayedTerminalItems(db) } export function dropOverseerInboxSchema(db: Database): void { diff --git a/hub/src/store/inboxStore.ts b/hub/src/store/inboxStore.ts index 35e6d3ce7f..759d8b0ab2 100644 --- a/hub/src/store/inboxStore.ts +++ b/hub/src/store/inboxStore.ts @@ -9,6 +9,7 @@ import { promoteAttentionEvent, recordInboxOperatorAction, repointSessionInboxItems, + sweepDecayedTerminalItems, type ListInboxItemsOptions, type StoredInboxItem } from './inboxItems' @@ -50,4 +51,9 @@ export class InboxStore { repointSession(fromSessionId: string, toSessionId: string): number { return repointSessionInboxItems(this.db, fromSessionId, toSessionId) } + + /** Auto-dispose decayed terminal (completed) + orphaned stale items. Returns rows disposed. */ + sweepDecayedTerminal(now: number = Date.now()): number { + return sweepDecayedTerminalItems(this.db, now) + } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a3f81b1fb3..4180ee06e1 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -564,6 +564,9 @@ export class SyncEngine { // Piggybacked on the inactivity tick; not a logical part of expireInactive // but shares its 5s cadence (avoids a second timer). this.messageService.releaseMatureScheduledMessages(Date.now()) + // Terminal inbox items are context, not attention — auto-dispose the + // decayed ones so a backlog of finished work stops crowding triage. + this.store.inbox.sweepDecayedTerminal() } private reloadAll(): void { From 72aaab1baca17ded920846056633ff7ffc89d84d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:25:15 +0000 Subject: [PATCH 3/3] fix(overseer): F5 sweep must not obsolete STALE - worker self-reports share it Worker self-reported status:"stalled" (via AGENT_NOTIFY_SUMMARY) lands as event_type 'stale' -> category STALE, same as the retired hub-inferred silence detection. P0.5 analysis of the live DB found these are alive (4 in the last 7d) while hub-inferred stale stopped 2026-07-17. Blanket-obsoleting category=STALE would eat live "I'm stalled" signals, so the sweep now resolves FINALE (completed) only. Historical hub-inferred STALE cruft is a separate operator-approved one-shot, not this live sweep. Co-authored-by: Cursor <cursoragent@cursor.com> --- hub/src/store/inboxItems.test.ts | 15 ++++++++----- hub/src/store/inboxItems.ts | 38 +++++++++++++++++--------------- hub/src/store/inboxStore.ts | 2 +- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts index d925337280..eec1bda2e5 100644 --- a/hub/src/store/inboxItems.test.ts +++ b/hub/src/store/inboxItems.test.ts @@ -252,7 +252,11 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { expect(sweepDecayedTerminalItems(db, now)).toBe(0) }) - it('obsoletes orphaned STALE items immediately regardless of age', () => { + it('leaves STALE items alone (worker self-reported stalls share category STALE)', () => { + // A worker that self-reports status:"stalled" via AGENT_NOTIFY_SUMMARY lands + // as event_type 'stale' -> category STALE, same as the retired hub-inferred + // silence detection. Sweeping STALE would eat those live signals, so the + // sweep must ignore STALE entirely — even when well past the decay window. const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db const now = 1_000_000_000_000 @@ -262,14 +266,13 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { attention_class, created_at, updated_at, related_session_id, title, category, summary ) VALUES ( 'surfaced', 60, 60, '[]', '[]', 'live', ?, ?, NULL, - 'No agent output for 30 minutes', 'STALE', 'silent' + 'worker self-reported stalled', 'STALE', 'stalled' ) - `).run(now - 1000, now - 1000) + `).run(now - FINALE_DECAY_WINDOW_MS - 1, now - FINALE_DECAY_WINDOW_MS - 1) const staleId = Number(res.lastInsertRowid) - expect(sweepDecayedTerminalItems(db, now)).toBe(1) - expect(store.inbox.getById(staleId)?.status).toBe('obsoleted') - expect(store.inbox.getById(staleId)?.resolvedAt).toBe(now) + expect(sweepDecayedTerminalItems(db, now)).toBe(0) + expect(store.inbox.getById(staleId)?.status).toBe('surfaced') }) it('records operator actions as training labels', () => { diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts index a72fc7d998..0f8298c14d 100644 --- a/hub/src/store/inboxItems.ts +++ b/hub/src/store/inboxItems.ts @@ -407,36 +407,38 @@ export function backfillInboxDerivedFields(db: Database): void { export const FINALE_DECAY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000 /** - * Auto-dispose terminal inbox items so a backlog of finished work stops crowding - * the operator's attention surface. Rows are RETAINED as history — status is - * moved out of the active set, never deleted. + * Auto-resolve decayed terminal (completed) inbox items so a backlog of finished + * work stops crowding the operator's attention surface. Rows are RETAINED as + * history — status leaves the active set, never deleted. * - * - FINALE (completed): resolve once the item has sat past the decay window. - * - STALE: idle-silence detection was retired as noise (see - * `OverseerEventRecorder.checkStaleSessions`, which now returns []). Any - * lingering STALE rows are orphaned legacy from before that removal — obsolete - * them immediately regardless of age. + * Only FINALE (completed) is swept, and only once it has sat past the decay + * window. + * + * STALE is deliberately NOT swept here. The hub-inferred "No agent output for N + * minutes" silence detection was retired (see + * `OverseerEventRecorder.checkStaleSessions`, now returns []; last such event on + * the live DB was 2026-07-17) — but a worker that self-reports status:"stalled" + * via AGENT_NOTIFY_SUMMARY ALSO lands as `event_type='stale'` → category STALE, + * and that is a live, operator-relevant signal (observed as recently as 3 days + * before this was written). Blanket-obsoleting STALE would eat those self- + * reports. Historical hub-inferred STALE cruft is a separate operator-approved + * one-shot, not this live sweep. * * Idempotent: matches only active rows, so re-running changes nothing once swept. - * Returns the number of rows disposed. + * Returns the number of rows resolved. */ export function sweepDecayedTerminalItems( db: Database, now: number = Date.now(), windowMs: number = FINALE_DECAY_WINDOW_MS ): number { - const active = "status IN ('new', 'surfaced', 'deferred', 'snoozed')" - const resolved = db.prepare( + const result = db.prepare( `UPDATE inbox_items SET status = 'resolved', resolved_at = ?, updated_at = ? - WHERE ${active} AND category = 'FINALE' AND updated_at < ?` + WHERE status IN ('new', 'surfaced', 'deferred', 'snoozed') + AND category = 'FINALE' AND updated_at < ?` ).run(now, now, now - windowMs) - const obsoleted = db.prepare( - `UPDATE inbox_items - SET status = 'obsoleted', resolved_at = ?, updated_at = ? - WHERE ${active} AND category = 'STALE'` - ).run(now, now) - return resolved.changes + obsoleted.changes + return result.changes } /** diff --git a/hub/src/store/inboxStore.ts b/hub/src/store/inboxStore.ts index 759d8b0ab2..18da6e2f23 100644 --- a/hub/src/store/inboxStore.ts +++ b/hub/src/store/inboxStore.ts @@ -52,7 +52,7 @@ export class InboxStore { return repointSessionInboxItems(this.db, fromSessionId, toSessionId) } - /** Auto-dispose decayed terminal (completed) + orphaned stale items. Returns rows disposed. */ + /** Auto-resolve decayed terminal (completed) items. Returns rows resolved. */ sweepDecayedTerminal(now: number = Date.now()): number { return sweepDecayedTerminalItems(this.db, now) }