From 6a9092b03ebac998b5e7e7786029ecfc1cd8c739 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:29:12 -0700 Subject: [PATCH 1/2] feat(attention): an agent mention nobody answered for a week is missed, not "needs you" A thread reply is the only reply (Sam, 2026-09-06), so an agent's mention that nobody answered in its thread stayed in the needs-you queue for good. Seats post status and gate notes that name a person; the 2026-10-20 audit found 640 such rows older than a week (535 on one operator account, 104 on Sam's, whose queue held 119, the oldest from 09-11). - AttentionItem.resolvedBy gains `missed`: resolved, never counted as acted on. The row stays in the store and its message stays in the pod. - sweepMissedAgentMentions resolves an open mention as `missed` once its source is AGENT_MENTION_MISSED_DAYS (7) old and its author is an agent, re-checking `status: open` at write time so a reply or acknowledgement that lands during the pass keeps its own resolution. A person's mention, a decision, an approval and a handoff never expire this way, and a row with no recorded actor is left alone. - schedulerService runs it hourly and logs every pass. Reply semantics are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../models/AttentionItem.resolvedBy.test.js | 24 ++++++++ .../services/attentionItemService.test.js | 58 +++++++++++++++++++ backend/models/AttentionItem.ts | 11 ++-- backend/services/attentionItemService.ts | 48 ++++++++++++++- backend/services/schedulerService.ts | 19 ++++++ 5 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js diff --git a/backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js b/backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js new file mode 100644 index 0000000000..edb75c92db --- /dev/null +++ b/backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js @@ -0,0 +1,24 @@ +// The schema itself, not a mock: `missed` must be a value the store accepts, +// alongside the two it already had, and nothing else may slip in. +const mongoose = require('mongoose'); +const AttentionItem = require('../../../models/AttentionItem'); + +const row = (resolvedBy) => new AttentionItem({ + recipientUserId: new mongoose.Types.ObjectId(), + podId: new mongoose.Types.ObjectId(), + kind: 'mention', + source: { type: 'message', id: '42' }, + title: 'Kai mentioned you', + status: 'resolved', + resolvedBy, +}); + +describe('AttentionItem.resolvedBy', () => { + it.each(['replied', 'acknowledged', 'missed'])('accepts %s', (value) => { + expect(row(value).validateSync()).toBeUndefined(); + }); + + it('refuses anything else', () => { + expect(row('expired').validateSync()?.errors?.resolvedBy).toBeDefined(); + }); +}); diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index b03f414754..92e3eb2c11 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -626,3 +626,61 @@ describe('an agent answering the person who asked', () => { warn.mockRestore(); }); }); + +describe('an agent mention nobody answered for a week leaves the queue as missed', () => { + const NOW = new Date('2026-10-20T12:00:00Z'); + const CUTOFF = new Date('2026-10-13T12:00:00Z'); + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdateMany.mockResolvedValue({ modifiedCount: 1 }); + }); + + it("resolves an agent's week-old mention as missed and leaves a person's alone", async () => { + mockFind.mockReturnValue(chain([{ _id: 'row-agent', actorUserId: 'bot-1' }, { _id: 'row-person', actorUserId: 'human-1' }])); + mockUserFind.mockReturnValue(chain([{ _id: 'bot-1' }])); + + const result = await AttentionItemService.sweepMissedAgentMentions({ now: NOW }); + + expect(mockUserFind).toHaveBeenCalledWith({ _id: { $in: ['bot-1', 'human-1'] }, isBot: true }); + expect(mockUpdateMany).toHaveBeenCalledTimes(1); + expect(mockUpdateMany).toHaveBeenCalledWith( + { _id: { $in: ['row-agent'] }, kind: 'mention', status: 'open' }, + { $set: { status: 'resolved', resolvedAt: NOW, resolvedBy: 'missed' } }, + ); + expect(result).toEqual({ scanned: 2, eligible: 1, missed: 1 }); + }); + + it('looks only at open mentions with an actor, older than seven days by their source time', async () => { + mockFind.mockReturnValue(chain([])); + + await AttentionItemService.sweepMissedAgentMentions({ now: NOW }); + + expect(mockFind).toHaveBeenCalledWith({ + kind: 'mention', + status: 'open', + actorUserId: { $exists: true, $ne: null }, + $or: [ + { sourceCreatedAt: { $lt: CUTOFF } }, + { sourceCreatedAt: { $exists: false }, createdAt: { $lt: CUTOFF } }, + ], + }); + // Nothing old enough: no actor lookup, no write. + expect(mockUserFind).not.toHaveBeenCalled(); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('a dry run counts without writing', async () => { + mockFind.mockReturnValue(chain([{ _id: 'row-agent', actorUserId: 'bot-1' }])); + mockUserFind.mockReturnValue(chain([{ _id: 'bot-1' }])); + + const result = await AttentionItemService.sweepMissedAgentMentions({ now: NOW, apply: false }); + + expect(mockUpdateMany).not.toHaveBeenCalled(); + expect(result).toEqual({ scanned: 1, eligible: 1, missed: 0 }); + }); + + it('the window is seven days', () => { + expect(AttentionItemService.AGENT_MENTION_MISSED_DAYS).toBe(7); + }); +}); diff --git a/backend/models/AttentionItem.ts b/backend/models/AttentionItem.ts index 0c949b22aa..57763bbe0c 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -29,10 +29,11 @@ export interface IAttentionItem extends Document { status: 'open' | 'resolved'; resolvedAt?: Date; // Mention rows may resolve when their recipient replies; mention and handoff - // rows also support an explicit recipient acknowledgement. Decision and - // approval actions have source-specific writers and never become dismissible - // through this field. - resolvedBy?: 'replied' | 'acknowledged'; + // rows also support an explicit recipient acknowledgement. An agent's mention + // nobody answered in its thread for a week leaves the queue as `missed`: + // resolved, never counted as acted on. Decision and approval actions have + // source-specific writers and never become dismissible through this field. + resolvedBy?: 'replied' | 'acknowledged' | 'missed'; createdAt: Date; updatedAt: Date; } @@ -62,7 +63,7 @@ const attentionItemSchema = new Schema({ sourceCreatedAt: { type: Date }, status: { type: String, enum: ['open', 'resolved'], default: 'open', required: true }, resolvedAt: { type: Date }, - resolvedBy: { type: String, enum: ['replied', 'acknowledged'] }, + resolvedBy: { type: String, enum: ['replied', 'acknowledged', 'missed'] }, }, { timestamps: true }); // A source can notify each recipient once. Retried source writes must not diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 777adf1c3d..78f19243aa 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -281,6 +281,50 @@ export const sweepResolvedMentionAttention = async ({ apply = false }: { apply?: return { scanned: rows.length, eligible, resolved, unavailable }; }; +/** + * A thread reply is the only reply (Sam, 2026-09-06), so an agent's mention + * nobody answers in its thread stays open for good. Seats post status and gate + * notes that name a person, and the 2026-10-20 audit found one founder's queue + * holding 113 of them, the oldest five weeks old. After + * AGENT_MENTION_MISSED_DAYS without a reply, such a row leaves the needs-you + * queue as `missed`: resolved, never counted as acted on, kept in the store, + * and its message still in the pod. + * + * Only an agent's mention expires this way. A person's mention, a decision, an + * approval and a handoff stay until answered: the person waiting on them is + * real. A row with no recorded actor is left alone, since it can't be shown to + * be an agent's. + */ +export const AGENT_MENTION_MISSED_DAYS = 7; + +export const sweepMissedAgentMentions = async ( + { now = new Date(), apply = true }: { now?: Date; apply?: boolean } = {}, +) => { + const cutoff = new Date(now.getTime() - AGENT_MENTION_MISSED_DAYS * 24 * 60 * 60 * 1000); + const rows = await AttentionItem.find({ + kind: 'mention', + status: 'open', + actorUserId: { $exists: true, $ne: null }, + $or: [ + { sourceCreatedAt: { $lt: cutoff } }, + { sourceCreatedAt: { $exists: false }, createdAt: { $lt: cutoff } }, + ], + }).select('_id actorUserId').lean(); + if (!rows.length) return { scanned: 0, eligible: 0, missed: 0 }; + const actorIds = [...new Set((rows as any[]).map((row) => String(row.actorUserId || '')).filter(Boolean))]; + const bots = await User.find({ _id: { $in: actorIds }, isBot: true }).select('_id').lean(); + const botIds = new Set((bots as any[]).map((bot) => String(bot._id))); + const ids = (rows as any[]).filter((row) => botIds.has(String(row.actorUserId || ''))).map((row) => row._id); + if (!apply || !ids.length) return { scanned: rows.length, eligible: ids.length, missed: 0 }; + // Still open at write time: a reply or an acknowledgement that lands during + // the sweep keeps its own resolution. + const result = await AttentionItem.updateMany( + { _id: { $in: ids }, kind: 'mention', status: 'open' }, + { $set: { status: 'resolved', resolvedAt: now, resolvedBy: 'missed' } }, + ); + return { scanned: rows.length, eligible: ids.length, missed: Number(result.modifiedCount || 0) }; +}; + export const recordApproval = async (approval: any): Promise => { try { const podId = approval?.podId; @@ -697,6 +741,6 @@ export const hasEverHadAttention = async (recipientUserId: unknown): Promise { + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { sweepMissedAgentMentions } = require('./attentionItemService'); + const r = await sweepMissedAgentMentions(); + console.log(`[attention] missed-mention pass scanned=${r.scanned} eligible=${r.eligible} missed=${r.missed}`); + } catch (error) { + console.error('[attention] missed-mention pass failed:', error); + } + }, + { scheduled: false, timezone: 'UTC' }, + ); + const stalledConnectJob: CronJob = cron.schedule( '*/5 * * * *', async () => { @@ -564,6 +582,7 @@ class SchedulerService { skillsRefreshJob, onboardingSilenceJob, stalledConnectJob, + missedAgentMentionJob, kernelWorkSweepJob, installableReconcileJob, decisionCardReconcileJob, From 3bc4f0a0a324ad41f1b5661200a1a19ca54c2a11 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:31:48 -0700 Subject: [PATCH 2/2] docs(attention): point the resolvedBy enum at Growth's acted-on allowlist Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- backend/models/AttentionItem.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/models/AttentionItem.ts b/backend/models/AttentionItem.ts index 57763bbe0c..fc4bc0eb23 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -33,6 +33,7 @@ export interface IAttentionItem extends Document { // nobody answered in its thread for a week leaves the queue as `missed`: // resolved, never counted as acted on. Decision and approval actions have // source-specific writers and never become dismissible through this field. + // Growth's NEEDSYOU metric allowlists what counts as acted on; tell it when a value is added or a new kind starts writing one. resolvedBy?: 'replied' | 'acknowledged' | 'missed'; createdAt: Date; updatedAt: Date;