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/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..6176c5096c 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,99 @@ 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('"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); + } + }); + + 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. @@ -626,3 +714,83 @@ 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'); + }); +}); + +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/__tests__/unit/services/stalledConnectService.test.js b/backend/__tests__/unit/services/stalledConnectService.test.js index ba57673e45..55f9065242 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', () => ({ + resolveActioned: (...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/models/AttentionItem.ts b/backend/models/AttentionItem.ts index 0c949b22aa..fd8be1eeea 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -29,10 +29,14 @@ 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). 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' | 'missed'; createdAt: Date; updatedAt: Date; } @@ -62,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'] }, + 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/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..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; @@ -358,16 +402,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, { @@ -440,6 +525,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; @@ -697,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, diff --git a/backend/services/stalledConnectService.ts b/backend/services/stalledConnectService.ts index 30b7565fb6..d9b5a856c5 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 `actioned`, 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.resolveActioned('message', String(ep.nudgeMessageId)); await StalledConnectEpisode.updateOne( { _id: ep._id, status: 'open' },