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
---
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 {
@@ -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
---
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 {
@@ -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
---
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)
}