Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions hub/src/store/inboxItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hap
import { Store } from './index'
import type { StoredSession } from './types'
import { deleteSession } from './sessions'
import { backfillInboxDerivedFields, sweepDecayedTerminalItems, FINALE_DECAY_WINDOW_MS } from './inboxItems'
import { Database } from 'bun:sqlite'

function payloadForSession(session: StoredSession, extra: Record<string, unknown> = {}): string {
Expand Down Expand Up @@ -137,6 +138,143 @@ 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('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('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
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,
'worker self-reported stalled', 'STALE', 'stalled'
)
`).run(now - FINALE_DECAY_WINDOW_MS - 1, now - FINALE_DECAY_WINDOW_MS - 1)
const staleId = Number(res.lastInsertRowid)

expect(sweepDecayedTerminalItems(db, now)).toBe(0)
expect(store.inbox.getById(staleId)?.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')
Expand Down
99 changes: 98 additions & 1 deletion hub/src/store/inboxItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve worker priority when merging channel events

When a channel notification is bound to a session that already has an active worker item, findActiveInboxItemForSession returns that same item and the update replaces its worker priority with the new 100-offset channel priority. A routine PR notification can therefore push an unresolved worker blocker below every other worker item—the opposite of the stated ordering guarantee. Keep the most urgent priority among the item's source events or prevent channel events from merging into worker items.

Useful? React with 👍 / 👎.

const title = buildInboxTitleFromEvent(event.artifactRefs, event.payloadJson, event.summary)
const suggestedAction = extractSuggestedAction(event.payloadJson)
const existing = findActiveInboxItemForSession(db, event.relatedSessionId)
Expand Down Expand Up @@ -347,6 +347,100 @@ 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
Comment on lines +376 to +379

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rescanning the entire inbox on every startup

On long-lived installations, every historical inbox row is retained, including resolved items, but ensureOverseerInboxSchema invokes this backfill on every hub start and this loop performs a separate event lookup for every row. Startup work therefore grows linearly with the unbounded audit history and incurs an N+1 query pattern even after all rows have already been repaired. Gate this as a one-time migration or target only legacy rows with a set-based query.

Useful? React with 👍 / 👎.


// 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
Comment on lines +386 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive backfilled titles from the latest event

When an earlier event supplied artifact refs but the latest event for the same active item does not, promotion preserves the old artifact_refs via COALESCE while correctly deriving the current title from the latest event's session payload. On the next hub start, this precedence selects those stale row-level refs ahead of the latest event's null refs and rewrites the title back to the old PR artifact. Use the latest event's artifact refs whenever that event exists, falling back to the row only when the source event is unavailable.

Useful? React with 👍 / 👎.

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

/**
* 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-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.
*
* 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 resolved.
*/
export function sweepDecayedTerminalItems(
db: Database,
now: number = Date.now(),
windowMs: number = FINALE_DECAY_WINDOW_MS
): number {
const result = db.prepare(
`UPDATE inbox_items
SET status = 'resolved', resolved_at = ?, updated_at = ?
WHERE status IN ('new', 'surfaced', 'deferred', 'snoozed')
AND category = 'FINALE' AND updated_at < ?`
).run(now, now, now - windowMs)
return result.changes
}

/**
* Idempotent Overseer inbox DDL — runs on every Store init, NOT gated on SCHEMA_VERSION.
*/
Expand Down Expand Up @@ -399,6 +493,9 @@ 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)
sweepDecayedTerminalItems(db)
}

export function dropOverseerInboxSchema(db: Database): void {
Expand Down
6 changes: 6 additions & 0 deletions hub/src/store/inboxStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
promoteAttentionEvent,
recordInboxOperatorAction,
repointSessionInboxItems,
sweepDecayedTerminalItems,
type ListInboxItemsOptions,
type StoredInboxItem
} from './inboxItems'
Expand Down Expand Up @@ -50,4 +51,9 @@ export class InboxStore {
repointSession(fromSessionId: string, toSessionId: string): number {
return repointSessionInboxItems(this.db, fromSessionId, toSessionId)
}

/** Auto-resolve decayed terminal (completed) items. Returns rows resolved. */
sweepDecayedTerminal(now: number = Date.now()): number {
return sweepDecayedTerminalItems(this.db, now)
}
}
3 changes: 3 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the terminal sweep off the five-second tick

On every running hub, the inactivity timer invokes this branch every 5 seconds (syncEngine.ts:186), so the 14-day decay policy executes 17,280 UPDATE statements per day. The query filters by category and updated_at, but the inbox index only covers (status, base_priority, created_at), forcing repeated scans of all active rows and unnecessary SQLite writer transactions even when nothing can expire. Run this sweep on a much coarser cadence or index/schedule it by the next decay deadline.

Useful? React with 👍 / 👎.

}

private reloadAll(): void {
Expand Down
1 change: 1 addition & 0 deletions scripts/tooling/hapi-meta-daily.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
28 changes: 17 additions & 11 deletions scripts/tooling/lib/pr-emoji-core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions scripts/tooling/lib/pr-emoji-core.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading
Loading