Skip to content
Closed
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
24 changes: 24 additions & 0 deletions backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
58 changes: 58 additions & 0 deletions backend/__tests__/unit/services/attentionItemService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
12 changes: 7 additions & 5 deletions backend/models/AttentionItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -62,7 +64,7 @@ const attentionItemSchema = new Schema<IAttentionItem>({
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
Expand Down
48 changes: 46 additions & 2 deletions backend/services/attentionItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
try {
const podId = approval?.podId;
Expand Down Expand Up @@ -697,6 +741,6 @@ export const hasEverHadAttention = async (recipientUserId: unknown): Promise<boo
// excluding true decisions and approvals.
export const acknowledgeMention = acknowledgeAttention;

export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, sweepDoneTaskHandoffs, recordApproval, recordActionApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, hasEverHadAttention, acknowledgeAttention, acknowledgeMention };
export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, sweepMissedAgentMentions, sweepDoneTaskHandoffs, recordApproval, recordActionApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, hasEverHadAttention, acknowledgeAttention, acknowledgeMention };
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, sweepDoneTaskHandoffs, recordApproval, recordActionApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, hasEverHadAttention, acknowledgeAttention, acknowledgeMention, TASK_HANDOFF_RE };
module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, sweepMissedAgentMentions, AGENT_MENTION_MISSED_DAYS, sweepDoneTaskHandoffs, recordApproval, recordActionApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, hasEverHadAttention, acknowledgeAttention, acknowledgeMention, TASK_HANDOFF_RE };
19 changes: 19 additions & 0 deletions backend/services/schedulerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,24 @@ class SchedulerService {
// re-deriving from state each pass is drop-proof, where a delayed
// AgentEvent inherited the pending-GC caveat (#993 removed it; the cron
// is still right, for the drop-proofness rather than the deletion).
// An agent's mention with no thread reply for a week leaves the needs-you
// queue as `missed` (attentionItemService.sweepMissedAgentMentions). Hourly
// is plenty for a week-long window.
const missedAgentMentionJob: CronJob = cron.schedule(
'17 * * * *',
async () => {
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 () => {
Expand Down Expand Up @@ -564,6 +582,7 @@ class SchedulerService {
skillsRefreshJob,
onboardingSilenceJob,
stalledConnectJob,
missedAgentMentionJob,
kernelWorkSweepJob,
installableReconcileJob,
decisionCardReconcileJob,
Expand Down
Loading