diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js new file mode 100644 index 0000000000..a41a0e56fb --- /dev/null +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -0,0 +1,256 @@ +/** + * Initiative v1 row 3 (design #2788, §4 and §6): the stall + * nudge against a real Mongo, so the unique index that dedupes an episode is + * the one under test. Witnesses named by §6: two stalls in one episode produce + * one row; an agent owner with no installer produces none; `needs your check` + * produces no nudge. + */ +const mongoose = require('mongoose'); +const Pod = require('../../models/Pod'); +const User = require('../../models/User'); +const AttentionItem = require('../../models/AttentionItem'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { deriveInitiativeStatus, MERGE_EVIDENCE_TEXT } = require('../../services/initiativeStatus'); +const { nudgeStalledInitiative, nudgePodId } = require('../../services/initiativeNudge'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +jest.mock('../../services/needsYouEmailService', () => ({ sendNeedsYouEmail: jest.fn().mockResolvedValue(undefined) })); +const { sendNeedsYouEmail } = require('../../services/needsYouEmailService'); + +const NOW = new Date('2026-10-26T12:00:00Z'); +const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600 * 1000); +const merge = (at) => ({ + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, +}); + +describe('initiative stall nudge', () => { + let giver; let owner; let installer; let agent; let pod; let otherPod; + + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + beforeEach(async () => { + sendNeedsYouEmail.mockClear(); + await AttentionItem.init(); + [giver, owner, installer] = await User.create([ + { username: 'init-giver', email: 'init-giver@test.com', password: 'password123' }, + { username: 'init-owner', email: 'init-owner@test.com', password: 'password123' }, + { username: 'init-installer', email: 'init-installer@test.com', password: 'password123' }, + ]); + agent = await User.create({ + username: 'init-agent', + email: 'init-agent@test.com', + password: 'password123', + isBot: true, + botType: 'agent', + botMetadata: { agentName: 'init-agent', instanceId: 'seat' }, + }); + [pod, otherPod] = await Pod.create([ + { + name: 'Ship room', type: 'chat', createdBy: giver._id, members: [giver._id, owner._id, installer._id, agent._id], + }, + { + name: 'Elsewhere', type: 'chat', createdBy: giver._id, members: [giver._id], + }, + ]); + }); + afterEach(clearMongoDb); + + const taskRow = (id, status, updates = [], podId = pod._id, taskNum = 1) => ({ + _id: id, podId, taskNum, status, updates, + }); + const initiativeFor = (ownerField, links, extra = {}) => ({ + _id: new mongoose.Types.ObjectId(), + title: 'Ship the onboarding', + createdAt: hoursAgo(24 * 10), + direction: String(giver._id), + owner: ownerField, + ownerId: String(ownerField.userId || ownerField.agentUserId), + links, + ...extra, + }); + const run = (initiative, tasks, at = NOW) => nudgeStalledInitiative( + initiative, + deriveInitiativeStatus(initiative, { tasks, decisions: [], attention: [] }, at), + tasks, + ); + const link = (ref) => ({ kind: 'task', ref, addedAt: hoursAgo(24 * 9) }); + const stallRows = () => AttentionItem.find({ 'source.type': 'initiative' }).lean(); + + it('two runs inside one episode write one row, to the human owner', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + const tasks = [taskRow('t1', 'claimed', [merge(hoursAgo(50))])]; + + const first = await run(initiative, tasks); + const second = await run(initiative, tasks, new Date(NOW.getTime() + 3600 * 1000)); + + expect(first).toMatchObject({ state: 'nudged', recipientUserId: String(owner._id), podId: String(pod._id) }); + expect(first.episodeStart).toEqual(hoursAgo(50)); + expect(second).toMatchObject({ state: 'nudged', episodeStart: hoursAgo(50) }); + const rows = await stallRows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: 'blocked', + status: 'open', + title: 'Stalled: Ship the onboarding', + source: { type: 'initiative', id: `initiative:${initiative._id}:stall:${hoursAgo(50).toISOString()}` }, + }); + expect(String(rows[0].recipientUserId)).toBe(String(owner._id)); + expect(sendNeedsYouEmail).toHaveBeenCalledTimes(1); + expect(String(sendNeedsYouEmail.mock.calls[0][0].recipientUserId)).toBe(String(owner._id)); + }); + + it('an owner change inside one episode does not nudge the new owner too', async () => { + const tasks = [taskRow('t1', 'claimed', [merge(hoursAgo(50))])]; + const before = initiativeFor({ userId: owner._id }, [link('t1')]); + const after = { ...before, owner: { userId: giver._id }, ownerId: String(giver._id) }; + + await run(before, tasks); + const second = await run(after, tasks, new Date(NOW.getTime() + 3600 * 1000)); + + expect(second).toMatchObject({ state: 'nudged', recipientUserId: String(owner._id) }); + const rows = await stallRows(); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe('open'); + expect(String(rows[0].recipientUserId)).toBe(String(owner._id)); + expect(sendNeedsYouEmail).toHaveBeenCalledTimes(1); + }); + + it('when the nudged owner has left the pod, the new owner gets the episode', async () => { + const tasks = [taskRow('t1', 'claimed', [merge(hoursAgo(50))])]; + const before = initiativeFor({ userId: owner._id }, [link('t1')]); + const after = { ...before, owner: { userId: giver._id }, ownerId: String(giver._id) }; + + await run(before, tasks); + await Pod.updateOne({ _id: pod._id }, { $pull: { members: owner._id } }); + const second = await run(after, tasks, new Date(NOW.getTime() + 3600 * 1000)); + + expect(second).toMatchObject({ state: 'nudged', recipientUserId: String(giver._id) }); + const rows = await stallRows(); + expect(rows.map((r) => String(r.recipientUserId)).sort()) + .toEqual([String(giver._id), String(owner._id)].sort()); + expect(sendNeedsYouEmail).toHaveBeenCalledTimes(2); + }); + + it('a failed write reports failed, and the next run retries it', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + const tasks = [taskRow('t1', 'claimed', [merge(hoursAgo(50))])]; + const spy = jest.spyOn(AttentionItem, 'updateOne').mockRejectedValueOnce(new Error('mongo down')); + try { + expect(await run(initiative, tasks)).toMatchObject({ state: 'failed', podId: String(pod._id) }); + expect(await stallRows()).toHaveLength(0); + expect(sendNeedsYouEmail).not.toHaveBeenCalled(); + + expect(await run(initiative, tasks)).toMatchObject({ state: 'nudged', recipientUserId: String(owner._id) }); + expect(await stallRows()).toHaveLength(1); + expect(sendNeedsYouEmail).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + + it('an episode already in the audit trail is not written again', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')], { + nudges: [{ episodeStart: hoursAgo(50).toISOString(), nudgedAt: hoursAgo(20) }], + }); + expect(await run(initiative, [taskRow('t1', 'claimed', [merge(hoursAgo(50))])])) + .toEqual({ state: 'already-nudged', episodeStart: hoursAgo(50) }); + expect(await stallRows()).toHaveLength(0); + }); + + it("an agent owner's stall goes to the agent's installer", async () => { + await AgentInstallation.create({ + agentName: 'init-agent', instanceId: 'seat', podId: pod._id, version: '1', installedBy: installer._id, + }); + const initiative = initiativeFor({ agentUserId: agent._id }, [link('t1')]); + const result = await run(initiative, [taskRow('t1', 'claimed')]); + expect(result).toMatchObject({ state: 'nudged', recipientUserId: String(installer._id) }); + expect(result.episodeStart).toEqual(hoursAgo(24 * 10)); + }); + + it('an agent owner with no installer produces no row', async () => { + const initiative = initiativeFor({ agentUserId: agent._id }, [link('t1')]); + expect(await run(initiative, [taskRow('t1', 'claimed')])).toMatchObject({ state: 'no-human', podId: String(pod._id) }); + expect(await stallRows()).toHaveLength(0); + }); + + it('a failed installer lookup reports failed, not no-human', async () => { + const initiative = initiativeFor({ agentUserId: agent._id }, [link('t1')]); + const spy = jest.spyOn(AgentInstallation, 'findOne').mockImplementationOnce(() => { + throw new Error('mongo down'); + }); + try { + expect(await run(initiative, [taskRow('t1', 'claimed')])).toMatchObject({ state: 'failed', podId: String(pod._id) }); + expect(await stallRows()).toHaveLength(0); + } finally { + spy.mockRestore(); + } + }); + + it('needs your check produces no nudge, however old the merge', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + expect(await run(initiative, [taskRow('t1', 'done', [merge(hoursAgo(24 * 5))])])).toEqual({ state: 'not-stalled' }); + expect(await stallRows()).toHaveLength(0); + }); + + it('an owner who is not a human member of the nudge pod produces no row', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + expect(await run(initiative, [taskRow('t1', 'claimed', [], otherPod._id)])).toMatchObject({ state: 'no-human' }); + expect(await stallRows()).toHaveLength(0); + }); + + it('with no open linked task, the first pod link routes the nudge', async () => { + const initiative = initiativeFor({ userId: owner._id }, [ + link('t1'), { kind: 'pod', ref: String(pod._id), addedAt: hoursAgo(24 * 9) }, + ]); + expect(await run(initiative, [taskRow('t1', 'done', [], otherPod._id)])) + .toMatchObject({ state: 'nudged', podId: String(pod._id), recipientUserId: String(owner._id) }); + expect(await stallRows()).toHaveLength(1); + }); + + it('with no open linked task and no pod link, nothing is written', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + expect(await run(initiative, [taskRow('t1', 'done')])).toMatchObject({ state: 'no-pod' }); + expect(await stallRows()).toHaveLength(0); + }); + + it('a new episode opens a new row and closes the old one; moving again closes it too', async () => { + const initiative = initiativeFor({ userId: owner._id }, [link('t1')]); + await run(initiative, [taskRow('t1', 'claimed', [merge(hoursAgo(50))])]); + // A merge 30 h ago moved it; it has been quiet since, so this is a new stall. + await run(initiative, [taskRow('t1', 'claimed', [merge(hoursAgo(50)), merge(hoursAgo(30))])]); + let rows = await stallRows(); + expect(rows.map((r) => [r.source.id.endsWith(hoursAgo(50).toISOString()), r.status]).sort()) + .toEqual([[false, 'open'], [true, 'resolved']]); + + await run(initiative, [taskRow('t1', 'claimed', [merge(hoursAgo(30)), merge(hoursAgo(1))])]); + rows = await stallRows(); + expect(rows.every((r) => r.status === 'resolved')).toBe(true); + }); +}); + +describe('nudgePodId', () => { + const init = (links) => ({ + _id: 'i', title: 't', createdAt: new Date(), direction: 'g', links, + }); + const l = (kind, ref) => ({ kind, ref, addedAt: new Date() }); + + it('picks the open linked task with the lowest taskNum, through task or pr links', () => { + const tasks = [ + { + _id: 'a', podId: 'pod-a', taskNum: 9, status: 'claimed', + }, + { + _id: 'b', podId: 'pod-b', taskNum: 3, status: 'pending', prUrl: 'https://example.test/pr/1', + }, + { + _id: 'c', podId: 'pod-c', taskNum: 1, status: 'done', + }, + ]; + expect(nudgePodId(init([l('task', 'a'), l('pr', 'https://example.test/pr/1'), l('task', 'c')]), tasks)).toBe('pod-b'); + }); + + it('falls back to the first pod link, then to none', () => { + expect(nudgePodId(init([l('task', 'x'), l('pod', 'pod-z')]), [])).toBe('pod-z'); + expect(nudgePodId(init([l('task', 'x')]), [])).toBeNull(); + }); +}); diff --git a/backend/models/AttentionItem.ts b/backend/models/AttentionItem.ts index 45f4334385..9e53ccfc75 100644 --- a/backend/models/AttentionItem.ts +++ b/backend/models/AttentionItem.ts @@ -1,7 +1,7 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; export type AttentionKind = 'mention' | 'approval' | 'decision' | 'handoff' | 'blocked'; -export type AttentionSourceType = 'message' | 'approval' | 'decision_request' | 'task'; +export type AttentionSourceType = 'message' | 'approval' | 'decision_request' | 'task' | 'initiative'; export interface IAttentionItem extends Document { recipientUserId: Types.ObjectId; @@ -52,7 +52,7 @@ const attentionItemSchema = new Schema({ podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true }, kind: { type: String, enum: ['mention', 'approval', 'decision', 'handoff', 'blocked'], required: true }, source: { - type: { type: String, enum: ['message', 'approval', 'approval_action', 'decision_request', 'task'], required: true }, + type: { type: String, enum: ['message', 'approval', 'approval_action', 'decision_request', 'task', 'initiative'], required: true }, id: { type: String, required: true }, }, title: { type: String, required: true }, diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index a710747fc7..047c0ae2d0 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -23,7 +23,7 @@ const { humansAnsweredBy } = require('./agentAnswerService'); // eslint-disable-next-line global-require const { isListedPodMember } = require('../utils/isPodMember'); -type SourceType = 'message' | 'approval' | 'approval_action' | 'decision_request' | 'task'; +type SourceType = 'message' | 'approval' | 'approval_action' | 'decision_request' | 'task' | 'initiative'; type Kind = 'mention' | 'approval' | 'decision' | 'handoff' | 'blocked'; type MentionOptions = { isAlreadyAcknowledged?: (recipientUserId: unknown, legacyMentionId: string) => boolean; @@ -46,22 +46,30 @@ const currentHumanMembers = async (podId: unknown): Promise isListedPodMember(pod, user._id)); }; +// Throws on a failed lookup. Use it where the installer is the required +// recipient, so a read error is reported as a failure and not as "no human". +const lookupInstaller = async ( + agentUserId: unknown, podId: unknown, declaredName?: unknown, declaredInstanceId?: unknown, +): Promise => { + const agent = agentUserId + ? await User.findById(agentUserId).select('isBot username botMetadata').lean() : null; + const name = String(declaredName || (agent?.isBot && (agent.botMetadata?.agentName || agent.username)) || '').toLowerCase(); + if (name) { + const instanceId = String(declaredInstanceId || agent?.botMetadata?.instanceId || 'default'); + const installation = await AgentInstallation.findOne({ + podId, agentName: name, instanceId, status: { $ne: 'uninstalled' }, + }).select('installedBy').lean(); + if (installation?.installedBy) return String(installation.installedBy); + } + return agent?.isBot && agent.botMetadata?.ownerUserId + ? String(agent.botMetadata.ownerUserId) : null; +}; + const installerForAgent = async ( agentUserId: unknown, podId: unknown, declaredName?: unknown, declaredInstanceId?: unknown, ): Promise => { try { - const agent = agentUserId - ? await User.findById(agentUserId).select('isBot username botMetadata').lean() : null; - const name = String(declaredName || (agent?.isBot && (agent.botMetadata?.agentName || agent.username)) || '').toLowerCase(); - if (name) { - const instanceId = String(declaredInstanceId || agent?.botMetadata?.instanceId || 'default'); - const installation = await AgentInstallation.findOne({ - podId, agentName: name, instanceId, status: { $ne: 'uninstalled' }, - }).select('installedBy').lean(); - if (installation?.installedBy) return String(installation.installedBy); - } - return agent?.isBot && agent.botMetadata?.ownerUserId - ? String(agent.botMetadata.ownerUserId) : null; + return await lookupInstaller(agentUserId, podId, declaredName, declaredInstanceId); } catch (error) { // The attention row still matters if its optional email owner cannot be // resolved. Never send to the pod creator as a fallback. @@ -549,6 +557,75 @@ export const recordTaskAttention = async (task: any, options: TaskAttentionOptio } }; +// Initiative v1 design (#2788) §4: one nudge per stall +// episode. The episode start is part of the source id, so every run inside one +// episode upserts onto the same row and the next episode opens a new one. The +// row goes to one current human member of the pod: the owner when the owner is +// a person, otherwise the agent's installer. No agent is woken. The unique +// index includes the recipient, so an owner change mid-episode would open a +// second card; the episode's existing row, to anyone, is checked first. It +// only counts while its recipient is still a human member of its pod: a row +// the queue hides is no nudge, so the guard fails toward a second card. +const INITIATIVE_STALL = 'stall'; +export const initiativeStallSourceId = (initiativeId: unknown, episodeStart: Date): string => ( + `initiative:${String(initiativeId)}:${INITIATIVE_STALL}:${episodeStart.toISOString()}` +); + +export const recordInitiativeStall = async (stall: { + initiativeId: unknown; title: string; podId: unknown; episodeStart: Date; + ownerUserId?: unknown; ownerAgentUserId?: unknown; +}, options: RecordOptions = {}): Promise<{ recipientUserId: string | null; failed?: true }> => { + try { + const { podId } = stall; + if (!podId || !stall.initiativeId) return { recipientUserId: null }; + const sourceId = initiativeStallSourceId(stall.initiativeId, stall.episodeStart); + const existing = await AttentionItem.find({ 'source.type': 'initiative', 'source.id': sourceId }) + .select('recipientUserId podId').lean(); + for (const row of existing) { + // eslint-disable-next-line no-await-in-loop + const members = await currentHumanMembers(row.podId); + if (members.some((member) => String(member._id) === String(row.recipientUserId))) { + return { recipientUserId: String(row.recipientUserId) }; + } + } + const target = stall.ownerUserId ? String(stall.ownerUserId) + : stall.ownerAgentUserId ? await lookupInstaller(stall.ownerAgentUserId, podId) : null; + if (!target) return { recipientUserId: null }; + const recipient = (await currentHumanMembers(podId)).find((member) => String(member._id) === target); + if (!recipient) return { recipientUserId: null }; + const pod = await Pod.findById(podId).select('name').lean(); + await recordForRecipients([recipient], { + podId, kind: 'blocked' as Kind, sourceType: 'initiative' as SourceType, + sourceId, + title: `Stalled: ${compact(stall.title, 200)}`, + detail: `No merge or ruling since ${stall.episodeStart.toISOString().slice(0, 10)}.`, + podName: pod?.name || 'Pod', emailRecipientId: target, suppressEmail: options.suppressEmail, + }); + return { recipientUserId: target }; + } catch (error) { + console.warn('[attention] initiative stall materialization failed:', (error as Error).message); + return { recipientUserId: null, failed: true }; + } +}; + +// Closes this initiative's open stall rows, except the current episode's. A +// card for a stall that has ended would otherwise sit in the queue forever. +export const resolveInitiativeStalls = async (initiativeId: unknown, keepSourceId?: string): Promise => { + const prefix = escapeRegExp(`initiative:${String(initiativeId)}:${INITIATIVE_STALL}:`); + try { + await AttentionItem.updateMany( + { + 'source.type': 'initiative', + 'source.id': keepSourceId ? { $regex: `^${prefix}`, $ne: keepSourceId } : { $regex: `^${prefix}` }, + status: 'open', + }, + { $set: { status: 'resolved', resolvedAt: new Date() } }, + ); + } catch (error) { + console.warn('[attention] initiative stall resolution failed; leaving items visible:', (error as Error).message); + } +}; + export const resolveTaskAttention = async (task: any): Promise => { const taskKey = String(task?._id || task?.taskId || '').trim(); if (!taskKey) return; @@ -885,6 +962,6 @@ export const hasEverHadAttention = async (recipientUserId: unknown): Promise; +} + +export type StallNudge = + | { state: 'not-stalled' } + | { state: 'already-nudged'; episodeStart: Date } + | { state: 'no-pod'; episodeStart: Date } + | { state: 'no-human'; episodeStart: Date; podId: string } + | { state: 'failed'; episodeStart: Date; podId: string } + | { state: 'nudged'; episodeStart: Date; podId: string; recipientUserId: string; + nudge: { episodeStart: Date; nudgedAt: Date } }; + +const OPEN = new Set(['pending', 'claimed']); + +/** The pod a stall nudge is written in, or null when the initiative names none. */ +export const nudgePodId = (initiative: NudgeInitiative, tasks: NudgeTask[]): string | null => { + const taskRefs = new Set(initiative.links.filter((l) => l.kind === 'task').map((l) => l.ref)); + const prRefs = new Set(initiative.links.filter((l) => l.kind === 'pr').map((l) => l.ref)); + const next = tasks + .filter((t) => (taskRefs.has(String(t._id)) || Boolean(t.prUrl && prRefs.has(t.prUrl))) + && OPEN.has(t.status) && t.podId) + .sort((a, b) => (a.taskNum ?? Infinity) - (b.taskNum ?? Infinity))[0]; + if (next) return String(next.podId); + const pod = initiative.links.find((l) => l.kind === 'pod'); + return pod ? pod.ref : null; +}; + +export const nudgeStalledInitiative = async ( + initiative: NudgeInitiative, + derived: DerivedInitiativeStatus, + tasks: NudgeTask[], + options: { suppressEmail?: boolean } = {}, +): Promise => { + // eslint-disable-next-line global-require + const attention = require('./attentionItemService'); + if (derived.status !== 'stalled') { + await attention.resolveInitiativeStalls(initiative._id); + return { state: 'not-stalled' }; + } + const episodeStart = derived.activity.since; + await attention.resolveInitiativeStalls( + initiative._id, attention.initiativeStallSourceId(initiative._id, episodeStart), + ); + const seen = (initiative.nudges || []) + .some((n) => new Date(n.episodeStart).getTime() === episodeStart.getTime()); + if (seen) return { state: 'already-nudged', episodeStart }; + + const podId = nudgePodId(initiative, tasks); + if (!podId) return { state: 'no-pod', episodeStart }; + const { recipientUserId, failed } = await attention.recordInitiativeStall({ + initiativeId: initiative._id, + title: initiative.title, + podId, + episodeStart, + ownerUserId: initiative.owner?.userId, + ownerAgentUserId: initiative.owner?.agentUserId, + }, options); + if (failed) return { state: 'failed', episodeStart, podId }; + if (!recipientUserId) return { state: 'no-human', episodeStart, podId }; + return { + state: 'nudged', episodeStart, podId, recipientUserId, nudge: { episodeStart, nudgedAt: new Date() }, + }; +};