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..fc4bc0eb23 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -29,10 +29,12 @@ 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. + // 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; } @@ -62,7 +64,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,