From 18bd9560b10282266fb77cf698f3df6381a11187 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:21:25 -0700 Subject: [PATCH 1/4] fix(attention): a handoff card is an ask, and it goes to the person asked The needs-you audit of 2026-10-20 found three ways the queue said "needs you" when nothing did: - TASK_HANDOFF_RE spelled one person's name and matched a report of a ruling ("Sam's ruling, 10-19: ...") as if it asked for one. One such note on ten rows opened 40 cards across a pod's four humans. isHandoffRequest now takes an ASK: "awaiting", "waiting on/for", "ready for" or "needs" followed by "the human" or a username of one of this pod's own human members, or "human (merge) press". No instance's names live in the code. - A blocked or handoff card paged every human in the pod. handoffRecipients sends it to the members the update names (@handle or username), otherwise to the pod's creator, and to everyone only when the creator has left. - The stalled-connect nudge asks the installer to start their agent, but its row could only clear on a thread reply. When the agent connects, the nudge message's rows now resolve as an action taken. A cheap candidate gate keeps ordinary progress prose from costing a member lookup, and serves the backfill as its Mongo prefilter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../services/attentionItemService.test.js | 81 ++++++++++++++++++- .../services/stalledConnectService.test.js | 31 +++++++ backend/scripts/backfill-attention-items.ts | 4 +- backend/services/attentionItemService.ts | 53 ++++++++++-- backend/services/stalledConnectService.ts | 7 ++ 5 files changed, 165 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index b03f414754..afa9eeee24 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -420,7 +420,10 @@ describe('attentionItemService', () => { expect(mockUpdateOne).not.toHaveBeenCalled(); }); - it('materializes a blocked board row once for each current human recipient', async () => { + // A blocked card that names nobody goes to the pod's creator, not to every + // human in the pod: a status flip in a shared work pod used to page all of + // them (2026-10-20 needs-you audit). + it('materializes a blocked board row for the pod creator when the update names nobody', async () => { mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: ['owner', 'sam'] })); mockUserFind.mockReturnValue(chain([ { _id: 'owner', username: 'owner', isBot: false }, @@ -432,14 +435,86 @@ describe('attentionItemService', () => { title: 'Choose a deploy shape', updates: [{ _id: 'update-1', text: 'Blocked on an upstream choice.' }], }, { includeBlocked: true }); - expect(mockUpdateOne).toHaveBeenCalledTimes(2); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); expect(mockUpdateOne).toHaveBeenCalledWith( - expect.objectContaining({ 'source.type': 'task', 'source.id': 'task-1:update-1' }), + expect.objectContaining({ recipientUserId: 'owner', 'source.type': 'task', 'source.id': 'task-1:update-1' }), expect.objectContaining({ $setOnInsert: expect.objectContaining({ kind: 'handoff', title: 'Choose a deploy shape' }) }), { upsert: true }, ); }); + describe('a handoff asks; a report of a ruling does not', () => { + const shipRoom = () => { + mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: ['owner', 'sam', 'lee'] })); + mockUserFind.mockReturnValue(chain([ + { _id: 'owner', username: 'owner', isBot: false }, + { _id: 'sam', username: 'Sam', isBot: false }, + { _id: 'lee', username: 'lee', isBot: false }, + ])); + }; + const update = (text) => ({ + _id: 'task-2', podId: 'pod-1', taskId: 'TASK-2', status: 'pending', + title: 'Wave 1 #1', updates: [{ _id: 'update-2', text }], + }); + const recipients = () => mockUpdateOne.mock.calls.map(([selector]) => selector.recipientUserId); + + it("a note reporting a ruling already made opens nothing (the 2026-10-19 \"Sam's ruling\" fan-out)", async () => { + shipRoom(); + // "needs" passes the cheap gate, so this reaches isHandoffRequest itself. + await AttentionItemService.recordTaskAttention(update("Sam's ruling, 10-19 (relayed by Cofounder): writes are allowed when the member approves them; the Linear entry needs a re-pin.")); + expect(mockUserFind).toHaveBeenCalled(); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + it('an ask naming a human member goes to that member only', async () => { + shipRoom(); + await AttentionItemService.recordTaskAttention(update("Awaiting @sam's call on the scope before the build.")); + expect(recipients()).toEqual(['sam']); + }); + + it('an ask of "the human" goes to the pod creator', async () => { + shipRoom(); + await AttentionItemService.recordTaskAttention(update('Gated and green; ready for the human press.')); + expect(recipients()).toEqual(['owner']); + }); + + it('an ask naming someone who is not a member of this pod opens nothing', async () => { + shipRoom(); + await AttentionItemService.recordTaskAttention(update("Awaiting Bob's call before the build.")); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + it('ordinary progress prose costs no member lookup', async () => { + shipRoom(); + await AttentionItemService.recordTaskAttention(update('Rebased onto main; CI is running.')); + expect(mockUserFind).not.toHaveBeenCalled(); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); + + describe('isHandoffRequest and handoffRecipients', () => { + it('knows no names of its own: a name counts only when it is a member of this pod', () => { + expect(AttentionItemService.isHandoffRequest('ready for sam', [])).toBe(false); + expect(AttentionItemService.isHandoffRequest('ready for sam', ['Sam'])).toBe(true); + expect(AttentionItemService.isHandoffRequest('needs lee-ops review', ['lee'])).toBe(false); + expect(AttentionItemService.isHandoffRequest("Sam's ruling, 10-19: ship it", ['Sam'])).toBe(false); + expect(AttentionItemService.isHandoffRequest('Waiting on a human merge press.', [])).toBe(true); + }); + + it('every accepted text passes the cheap candidate gate', () => { + for (const text of ['ready for sam', 'Awaiting the human', 'human merge press', 'needs @lee', 'waiting for lee']) { + expect(AttentionItemService.TASK_HANDOFF_CANDIDATE_RE.test(text)).toBe(true); + } + }); + + it('named members first, then the creator, then everyone when the creator has left', () => { + const members = [{ _id: 'owner', username: 'owner' }, { _id: 'sam', username: 'Sam' }]; + expect(AttentionItemService.handoffRecipients(members, 'over to @Sam', 'owner').map((m) => m._id)).toEqual(['sam']); + expect(AttentionItemService.handoffRecipients(members, 'blocked', 'owner').map((m) => m._id)).toEqual(['owner']); + expect(AttentionItemService.handoffRecipients(members, 'blocked', 'gone').map((m) => m._id)).toEqual(['owner', 'sam']); + }); + }); + // createdBy keeps a departed creator's id after leavePod. Membership is // Pod.members alone (isListedPodMember), so the creator who left gets no // row from the pod they can no longer open. diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index ba57673e45..e979eb33d3 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -39,6 +39,11 @@ jest.mock('../../../services/agentIdentityService', () => ({ buildAgentUsername: (n, i) => (i && i !== 'default' ? `${n}-${i}` : n), })); +const mockAttentionResolve = jest.fn(); +jest.mock('../../../services/attentionItemService', () => ({ + resolve: (...a) => mockAttentionResolve(...a), +})); + const mockEpCreate = jest.fn(); const mockEpFind = jest.fn(); const mockEpUpdate = jest.fn(); @@ -276,6 +281,31 @@ describe('keeping the forward promise', () => { expect(posted.content).toContain('commonly agent run ai1-agent'); }); + it("resolves the installer's needs-you row for the nudge once the agent connects", async () => { + const usedAt = new Date(NOW.getTime() - 10 * MINUTE); + setup({ installs: [], open: [{ ...openEpisode, nudgeMessageId: 'msg-nudge-1' }] }); + mockInstallFindById.mockReturnValue({ + lean: () => Promise.resolve(install({ runtimeTokens: [{ createdAt: OLD_TOKEN, lastUsedAt: usedAt }] })), + }); + + await scan({ now: NOW }); + + expect(mockAttentionResolve).toHaveBeenCalledWith('message', 'msg-nudge-1'); + }); + + it('resolves the nudge row even when the confirmation post fails: the person still did it', async () => { + const usedAt = new Date(NOW.getTime() - 10 * MINUTE); + setup({ installs: [], open: [{ ...openEpisode, nudgeMessageId: 'msg-nudge-2' }] }); + mockInstallFindById.mockReturnValue({ + lean: () => Promise.resolve(install({ runtimeTokens: [{ createdAt: OLD_TOKEN, lastUsedAt: usedAt }] })), + }); + mockPost.mockRejectedValueOnce(new Error('post down')); + + await scan({ now: NOW }); + + expect(mockAttentionResolve).toHaveBeenCalledWith('message', 'msg-nudge-2'); + }); + it('keeps waiting while it is still never-connected', async () => { setup({ installs: [], open: [openEpisode] }); mockInstallFindById.mockReturnValue({ lean: () => Promise.resolve(install()) }); @@ -284,6 +314,7 @@ describe('keeping the forward promise', () => { expect(r.resolved).toHaveLength(0); expect(mockPost).not.toHaveBeenCalled(); + expect(mockAttentionResolve).not.toHaveBeenCalled(); }); it('closes silently when the seat was uninstalled — no room, no promise to keep', async () => { diff --git a/backend/scripts/backfill-attention-items.ts b/backend/scripts/backfill-attention-items.ts index 9e3a8af277..77e56f95ac 100644 --- a/backend/scripts/backfill-attention-items.ts +++ b/backend/scripts/backfill-attention-items.ts @@ -18,7 +18,7 @@ const Message = require('../models/Message'); const Task = require('../models/Task'); const User = require('../models/User'); const { - recordApproval, recordDecision, recordMentionedUsers, recordTaskAttention, TASK_HANDOFF_RE, + recordApproval, recordDecision, recordMentionedUsers, recordTaskAttention, TASK_HANDOFF_CANDIDATE_RE, } = require('../services/attentionItemService'); const APPLY = process.argv.includes('--apply'); @@ -54,7 +54,7 @@ export const main = async (): Promise => { Activity.find({ type: 'approval_needed', 'approval.status': 'pending', deleted: { $ne: true } }).lean(), DecisionRequest.find({ status: 'pending' }).lean(), Task.find({ - $or: [{ status: 'blocked' }, { 'updates.text': { $regex: TASK_HANDOFF_RE } }], + $or: [{ status: 'blocked' }, { 'updates.text': { $regex: TASK_HANDOFF_CANDIDATE_RE } }], }).select('podId taskId title status notes updates updatedAt').lean(), legacyAcknowledgements(), Message.find({ createdAt: { $gte: cutoff } }).lean(), diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 777adf1c3d..27dbac648b 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -358,16 +358,57 @@ export const recordDecision = async (decision: any): Promise => { // A task may be ordinary board history, or it may state a concrete blocked / // handoff fact. Capture only the latter at the write boundary; no reader // should parse arbitrary historic task prose into a queue card. -export const TASK_HANDOFF_RE = /human\s+(merge\s+)?press|ready for (the\s+)?(human|sam)|sam'?s?\s+(ruling|call|decision)|awaiting\s+(sam|human)/i; +// +// A handoff ASKS for someone: "awaiting", "waiting on/for", "ready for" or +// "needs", followed by "the human" or the username of one of the pod's own +// human members, or "human (merge) press". A report of a ruling already made +// ("X's ruling, 10-19: ...") asks nothing, and no instance's names live here: +// the previous pattern spelled one person's name, and one report beginning +// "Sam's ruling" opened 40 rows across a pod's four humans (2026-10-19). +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const HANDOFF_LEAD = String.raw`(?:awaiting|waiting\s+(?:on|for)|ready\s+for|needs?)`; +const HUMAN_PRESS_RE = /\bhuman\s+(?:merge\s+)?press\b/i; +const ASKS_A_HUMAN_RE = new RegExp(String.raw`\b${HANDOFF_LEAD}\s+(?:the\s+|a\s+)?human\b`, 'i'); +// Cheap gate before any member lookup, and the backfill's Mongo prefilter. +// Every text isHandoffRequest accepts matches it. +export const TASK_HANDOFF_CANDIDATE_RE = /human|awaiting|waiting\s+(?:on|for)|ready\s+for|\bneeds?\b/i; + +const named = (text: string, username: string): boolean => Boolean(username) + && new RegExp(String.raw`(^|[^A-Za-z0-9_-])@?${escapeRegExp(username)}(?![A-Za-z0-9_-])`, 'i').test(text); + +export const isHandoffRequest = (text: string, memberUsernames: string[] = []): boolean => { + const value = String(text || ''); + if (HUMAN_PRESS_RE.test(value) || ASKS_A_HUMAN_RE.test(value)) return true; + const names = memberUsernames.map((name) => String(name || '').trim()).filter(Boolean).map(escapeRegExp); + if (!names.length) return false; + return new RegExp(String.raw`\b${HANDOFF_LEAD}\s+@?(?:${names.join('|')})(?![A-Za-z0-9_-])`, 'i').test(value); +}; + +// Who a blocked or handoff card goes to: the human members the update names +// (@handle or username), otherwise the pod's creator. Paging every member made +// each status flip in a shared work pod a card for every human in it. If the +// creator has left, the card goes to the current members rather than to nobody. +export const handoffRecipients = ( + members: T[], text: string, createdBy: unknown, +): T[] => { + const mentioned = members.filter((member) => named(String(text || ''), String(member.username || ''))); + if (mentioned.length) return mentioned; + const creator = members.filter((member) => String(member._id) === String(createdBy || '')); + return creator.length ? creator : members; +}; export const recordTaskAttention = async (task: any, options: TaskAttentionOptions = {}): Promise => { try { const last = Array.isArray(task?.updates) ? task.updates[task.updates.length - 1] : null; const blocked = task?.status === 'blocked'; - const handoff = Boolean(last && TASK_HANDOFF_RE.test(String(last.text || ''))); - if (!task?.podId || (!handoff && !(options.includeBlocked && blocked))) return; - const recipients = await currentHumanMembers(task.podId); - const pod = await Pod.findById(task.podId).select('name').lean(); + const text = String(last?.text || ''); + const mightAsk = Boolean(last) && TASK_HANDOFF_CANDIDATE_RE.test(text); + if (!task?.podId || (!mightAsk && !(options.includeBlocked && blocked))) return; + const members = await currentHumanMembers(task.podId); + const handoff = mightAsk && isHandoffRequest(text, members.map((member) => String(member.username || ''))); + if (!handoff && !(options.includeBlocked && blocked)) return; + const pod = await Pod.findById(task.podId).select('name createdBy').lean(); + const recipients = handoffRecipients(members, text, pod?.createdBy); const taskKey = String(task._id || task.taskId); const sequence = String(last?._id || last?.createdAt?.getTime?.() || task.updatedAt?.getTime?.() || taskKey); await recordForRecipients(recipients, { @@ -699,4 +740,4 @@ export const acknowledgeMention = acknowledgeAttention; export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, 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, sweepDoneTaskHandoffs, recordApproval, recordActionApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, hasEverHadAttention, acknowledgeAttention, acknowledgeMention, TASK_HANDOFF_CANDIDATE_RE, isHandoffRequest, handoffRecipients }; diff --git a/backend/services/stalledConnectService.ts b/backend/services/stalledConnectService.ts index 30b7565fb6..5664a3a6a0 100644 --- a/backend/services/stalledConnectService.ts +++ b/backend/services/stalledConnectService.ts @@ -328,6 +328,8 @@ const resolveConnected = async ({ const AgentMessageService = require('./agentMessageService'); // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { buildAgentUsername } = require('./agentIdentityService'); + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const AttentionItemService = require('./attentionItemService'); const open = await StalledConnectEpisode.find({ status: 'open' }).limit(200).lean(); for (const ep of open as Array>) { @@ -376,6 +378,11 @@ const resolveConnected = async ({ } catch (error) { console.warn('[stalled-connect] resolution post failed:', (error as Error).message); } + // The nudge asked the installer to start the agent, and they did. Its + // "needs you" row resolves now, as an action taken, whether or not the + // confirmation post above went out: a thread reply was never going to be + // how this one got answered. + if (ep.nudgeMessageId) await AttentionItemService.resolve('message', String(ep.nudgeMessageId)); await StalledConnectEpisode.updateOne( { _id: ep._id, status: 'open' }, From b4d69c009f5078daf8d6bf22959b4fc0bc4ab26a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:33:51 -0700 Subject: [PATCH 2/4] feat(attention): the nudge row resolves as `actioned` when the person starts their agent Growth's NEEDSYOU metric counts as acted on only `replied`, plus an unset resolvedBy on approval, decision and handoff rows. The stalled-connect nudge is a mention, so a plain resolve() would have counted as not acted even though the person did exactly what it asked. - AttentionItem.resolvedBy gains `actioned`: the recipient did what the row asked, outside its thread. The enum carries a pointer to Growth's allowlist. - resolveActioned(sourceType, sourceId) writes it. It's used only by the stalled-connect resolution; plain resolve() still writes no resolvedBy. Growth adds `actioned` to its acted-on allowlist before this deploys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../models/AttentionItem.actioned.test.js | 24 +++++++++++++++++++ .../services/attentionItemService.test.js | 22 +++++++++++++++++ .../services/stalledConnectService.test.js | 2 +- backend/models/AttentionItem.ts | 13 ++++++---- backend/services/attentionItemService.ts | 23 ++++++++++++++++-- backend/services/stalledConnectService.ts | 4 ++-- 6 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 backend/__tests__/unit/models/AttentionItem.actioned.test.js diff --git a/backend/__tests__/unit/models/AttentionItem.actioned.test.js b/backend/__tests__/unit/models/AttentionItem.actioned.test.js new file mode 100644 index 0000000000..5108392696 --- /dev/null +++ b/backend/__tests__/unit/models/AttentionItem.actioned.test.js @@ -0,0 +1,24 @@ +// The schema itself: `actioned` is a value the store accepts, and an unknown +// value is still refused. +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: 'Your agent is installed but not running', + status: 'resolved', + resolvedBy, +}); + +describe('AttentionItem.resolvedBy accepts actioned', () => { + it.each(['replied', 'acknowledged', 'actioned'])('accepts %s', (value) => { + expect(row(value).validateSync()).toBeUndefined(); + }); + + it('refuses an unknown value', () => { + expect(row('done').validateSync()?.errors?.resolvedBy).toBeDefined(); + }); +}); diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index afa9eeee24..53665081fd 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -701,3 +701,25 @@ describe('an agent answering the person who asked', () => { warn.mockRestore(); }); }); + +describe('actioned: the recipient did what the row asked, outside its thread', () => { + beforeEach(() => { jest.clearAllMocks(); mockUpdateMany.mockResolvedValue({ modifiedCount: 1 }); }); + + it("closes a source's open rows as actioned, which Growth counts as acted on", async () => { + await AttentionItemService.resolveActioned('message', 'msg-nudge-1'); + expect(mockUpdateMany).toHaveBeenCalledWith( + { 'source.type': 'message', 'source.id': 'msg-nudge-1', status: 'open' }, + { $set: { status: 'resolved', resolvedAt: expect.any(Date), resolvedBy: 'actioned' } }, + ); + }); + + it('does nothing without a source id', async () => { + await AttentionItemService.resolveActioned('message', ''); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('plain resolve() still writes no resolvedBy (approval, decision and task resolvers rely on that)', async () => { + await AttentionItemService.resolve('approval', 'appr-1'); + expect(mockUpdateMany.mock.calls[0][1].$set).not.toHaveProperty('resolvedBy'); + }); +}); diff --git a/backend/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index e979eb33d3..55f9065242 100644 --- a/backend/__tests__/unit/services/stalledConnectService.test.js +++ b/backend/__tests__/unit/services/stalledConnectService.test.js @@ -41,7 +41,7 @@ jest.mock('../../../services/agentIdentityService', () => ({ const mockAttentionResolve = jest.fn(); jest.mock('../../../services/attentionItemService', () => ({ - resolve: (...a) => mockAttentionResolve(...a), + resolveActioned: (...a) => mockAttentionResolve(...a), })); const mockEpCreate = jest.fn(); diff --git a/backend/models/AttentionItem.ts b/backend/models/AttentionItem.ts index 0c949b22aa..fc52ad2293 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -29,10 +29,13 @@ 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. `actioned` means the + // recipient did what the row asked, outside the thread (today: started the + // agent a stalled-connect nudge asked them to start). 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' | 'actioned'; createdAt: Date; updatedAt: Date; } @@ -62,7 +65,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', 'actioned'] }, }, { 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 27dbac648b..54a9cf0008 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -481,6 +481,25 @@ export const sweepDoneTaskHandoffs = async ({ apply = false }: { apply?: boolean return { scanned: rows.length, eligible: eligibleIds.length, resolved, missing, apply }; }; +/** + * Close a source's open rows as `actioned`: the recipient did what the row + * asked, somewhere other than its thread. Used only where that action is + * observable, today the stalled-connect nudge, whose ask ("start me") is met + * when the agent first connects. Growth counts `actioned` as acted on. + */ +export const resolveActioned = async (sourceType: SourceType, sourceId: unknown): Promise => { + const id = sourceKey(sourceType, sourceId); + if (!id) return; + try { + await AttentionItem.updateMany( + { 'source.type': sourceType, 'source.id': id, status: 'open' }, + { $set: { status: 'resolved', resolvedAt: new Date(), resolvedBy: 'actioned' } }, + ); + } catch (error) { + console.warn('[attention] actioned resolution failed; leaving item visible:', (error as Error).message); + } +}; + export const resolve = async (sourceType: SourceType, sourceId: unknown): Promise => { const id = sourceKey(sourceType, sourceId); if (!id) return; @@ -738,6 +757,6 @@ export const hasEverHadAttention = async (recipientUserId: unknown): Promise Date: Tue, 20 Oct 2026 13:01:40 -0700 Subject: [PATCH 3/4] test(attention): pin HUMAN_PRESS_RE and the empty-names guard sprint-review on #2363 (PASS at b4d69c00): dropping either left the suite green. "human merge press" with no members is accepted only by HUMAN_PRESS_RE, and without the empty-names guard 'awaiting .' with no members became an ask. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../unit/services/attentionItemService.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index 53665081fd..4088288ee9 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -501,6 +501,19 @@ describe('attentionItemService', () => { expect(AttentionItemService.isHandoffRequest('Waiting on a human merge press.', [])).toBe(true); }); + it('"human (merge) press" is an ask on its own, with no lead word and no members (only HUMAN_PRESS_RE accepts it)', () => { + expect(AttentionItemService.isHandoffRequest('Gated and green; human merge press please.', [])).toBe(true); + expect(AttentionItemService.isHandoffRequest('human press', [])).toBe(true); + }); + + it('with no human members left, a bare lead word is not an ask (the empty-names guard)', () => { + // Without the guard the alternation is empty and the lookahead passes on + // any non-word character, so these would page the creator fallback. + expect(AttentionItemService.isHandoffRequest('awaiting .', [])).toBe(false); + expect(AttentionItemService.isHandoffRequest('needs ', [])).toBe(false); + expect(AttentionItemService.isHandoffRequest('awaiting .', ['', ' '])).toBe(false); + }); + it('every accepted text passes the cheap candidate gate', () => { for (const text of ['ready for sam', 'Awaiting the human', 'human merge press', 'needs @lee', 'waiting for lee']) { expect(AttentionItemService.TASK_HANDOFF_CANDIDATE_RE.test(text)).toBe(true); From b24fe55aab84080679121139ff58ee6160ff9360 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 4/4] 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 4088288ee9..6176c5096c 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -736,3 +736,61 @@ describe('actioned: the recipient did what the row asked, outside its thread', ( expect(mockUpdateMany.mock.calls[0][1].$set).not.toHaveProperty('resolvedBy'); }); }); + +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 fc52ad2293..fd8be1eeea 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -31,11 +31,12 @@ export interface IAttentionItem extends Document { // Mention rows may resolve when their recipient replies; mention and handoff // rows also support an explicit recipient acknowledgement. `actioned` means the // recipient did what the row asked, outside the thread (today: started the - // agent a stalled-connect nudge asked them to start). Decision and approval - // actions have source-specific writers and never become dismissible through - // this field. + // agent a stalled-connect nudge asked them to start). 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' | 'actioned'; + resolvedBy?: 'replied' | 'acknowledged' | 'actioned' | 'missed'; createdAt: Date; updatedAt: Date; } @@ -65,7 +66,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', 'actioned'] }, + resolvedBy: { type: String, enum: ['replied', 'acknowledged', 'actioned', '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 54a9cf0008..0a3c4540d8 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; @@ -757,6 +801,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,