From 60ddaaabfa2c359f6b9080198bd4d17b152fbdc5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:57:18 +0100 Subject: [PATCH] fix(overseer): hub-inferred stale silence is captured-only, not inbox-promoted The hub silence sweep (checkStaleSessions) emitted `stale` events with attention_candidate=1, so every idle session auto-promoted into the operator inbox. On a fleet with N persistent-but-idle agents this floods the inbox with N identical "No agent output for 30 minutes" items - the "narrating log file" failure mode the framing doc explicitly warns against ("under-surface rather than over-surface", build-sequence Step 2.5 risk). Inferred silence is ambient awareness, not an operator-action signal (operator_action_required was already 0). Flip attention_candidate to 0 so the event is still recorded - the Overseer/replay harness can synthesize a coalesced "N sessions idle" view when it judges it worth attention - but it no longer auto-floods the inbox. A worker that EXPLICITLY self-reports `stalled` keeps attention_candidate=1 via deriveAttentionCandidate; only this inferred sweep becomes captured-only. Belongs to the events-substrate sweep (#22); branched off the inbox-substrate tip (#23) to be soup-able without disturbing the live #22/#23 worktrees. Co-authored-by: Cursor --- hub/src/sync/overseerEventRecorder.test.ts | 25 ++++++++++++++++++++++ hub/src/sync/overseerEventRecorder.ts | 10 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts index cccf1f290..fb96d1c05 100644 --- a/hub/src/sync/overseerEventRecorder.test.ts +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { OVERSEER_STALE_SILENCE_MS } from '@hapi/protocol' import type { Session } from '@hapi/protocol/types' import { Store } from '../store' import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' @@ -97,6 +98,30 @@ describe('OverseerEventRecorder', () => { expect(event?.attentionCandidate).toBe(0) }) + it('records hub-inferred stale silence as captured-only (not inbox-promoted)', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('idle', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + + const now = Date.now() + const silentSince = now - OVERSEER_STALE_SILENCE_MS - 60_000 + const live = makeSession(session.id, 'claude', { + active: true, + activeAt: silentSince, + updatedAt: silentSince + }) + + const emitted = recorder.checkStaleSessions([live], now) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.eventType).toBe('stale') + expect(emitted[0]?.attentionCandidate).toBe(0) + expect(store.events.count()).toBe(1) + // captured-only: the silence event is recorded for the Overseer/replay to query, + // but it must NOT flood the operator inbox (one item per idle session). + expect(store.inbox.count()).toBe(0) + }) + it('synthesizes approval_requested from permission prompts', () => { const store = new Store(':memory:') const recorder = new OverseerEventRecorder(store.events, store.inbox) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts index 0c0e4583f..90cd5d571 100644 --- a/hub/src/sync/overseerEventRecorder.ts +++ b/hub/src/sync/overseerEventRecorder.ts @@ -232,7 +232,15 @@ export class OverseerEventRecorder { sourceKind: 'system', sourceRef: session.id, eventType: 'stale', - attentionCandidate: 1, + // Captured-only: hub-inferred silence is ambient awareness, not an + // operator-action signal (operatorActionRequired stays 0). Auto-promoting + // it floods the inbox with one item per idle session ("narrating log file" + // failure mode); the framing doc mandates under-surface > over-surface. + // The event is still recorded so the Overseer/replay can synthesize a + // coalesced view ("N sessions idle") when it judges it worth attention. + // A worker that EXPLICITLY self-reports `stalled` keeps attention_candidate=1 + // via deriveAttentionCandidate; only this inferred sweep is captured-only. + attentionCandidate: 0, operatorActionRequired: 0, summary: `No agent output for ${Math.round((now - lastAt) / 60_000)} minutes`, relatedSessionId: session.id,