From 6d3253692bd39d50a17aec9a0cb3634214c5c1e6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:20:54 -0700 Subject: [PATCH 1/9] feat(initiative): derive initiative status from evidence records only (TASK-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initiative v1 row 2 (docs/plans/initiative-primitive.md §2). A pure deriveInitiativeStatus reads three dated records — the merge webhook's task update, a ruled DecisionRequest, and a close written by the direction-giver — and returns moving / stalled since / blocked on / waiting on you / needs your check / closed / closed by owner. Owner-written task updates, lease renewals and a self-reported done are deliberately not inputs. The merge update text moves to a shared MERGE_EVIDENCE_TEXT constant so the webhook writer and this reader cannot drift; a test feeds the writer's real output to the reader. Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 230 +++++++++++++++++ backend/services/githubGigService.ts | 4 +- backend/services/initiativeStatus.ts | 240 ++++++++++++++++++ 3 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/services/initiativeStatus.test.js create mode 100644 backend/services/initiativeStatus.ts diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js new file mode 100644 index 0000000000..b55a8da9a3 --- /dev/null +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -0,0 +1,230 @@ +/** + * Initiative v1 row 2: status is derived from evidence records only + * (docs/plans/initiative-primitive.md §2). One case per status row, plus the + * two negatives the design names: an owner's task update must NOT move an + * initiative, and a merged initiative must read `needs your check`, not stall. + * + * The last block drives the real merge webhook writer and feeds what it pushes + * to the reader, so the two cannot drift apart while this suite stays green. + */ +const mockTaskUpdate = jest.fn(); +jest.mock('../../../models/GithubGigBinding', () => ({ findOne: jest.fn() })); +jest.mock('../../../models/Task', () => ({ findOneAndUpdate: (...args) => mockTaskUpdate(...args) })); +jest.mock('../../../services/agentIdentityService', () => ({ getOrCreateAgentUser: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ _postToTarget: jest.fn() })); +jest.mock('../../../services/taskEventService', () => ({ emitTaskUpdated: jest.fn() })); + +const { + deriveInitiativeStatus, mergeEvidenceDates, MERGE_EVIDENCE_TEXT, +} = require('../../../services/initiativeStatus'); +const { completeTaskForMergedPull } = require('../../../services/githubGigService'); + +const NOW = new Date('2026-10-26T12:00:00Z'); +const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600 * 1000); +const GIVER = 'giver-1'; +const OWNER = 'owner-1'; +const PR = 'https://github.com/Team-Commonly/commonly/pull/42'; + +const merge = (at) => ({ text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at }); +const ownerNote = (at) => ({ text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at }); +const task = (id, status, updates = [], prUrl = null) => ({ _id: id, status, updates, prUrl }); +const initiative = (links, extra = {}) => ({ + createdAt: hoursAgo(24 * 10), direction: GIVER, ownerId: OWNER, links, ...extra, +}); +const link = (kind, ref, addedAt = hoursAgo(24 * 9)) => ({ kind, ref, addedAt }); +const none = { tasks: [], decisions: [], attention: [] }; + +describe('deriveInitiativeStatus — one case per status row', () => { + it('moving: a merge inside the 24 h window', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(3))]), task('t2', 'claimed')] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.activity).toEqual({ state: 'moving', since: hoursAgo(3) }); + }); + + it('moving: a ruled decision inside the window', () => { + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) } }] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.evidence).toEqual([{ kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1) }]); + }); + + it('stalled since : newest evidence is older than the window, dated by that evidence', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(50))]), task('t2', 'pending')] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.activity).toEqual({ state: 'stalled', since: hoursAgo(50) }); + }); + + it('stalled since createdAt when there is no evidence at all', () => { + const r = deriveInitiativeStatus(initiative([link('task', 't1')]), { ...none, tasks: [task('t1', 'claimed')] }, NOW); + expect(r.status).toBe('stalled'); + expect(r.activity.since).toEqual(hoursAgo(24 * 10)); + }); + + it('blocked on X: a linked decision is pending, X is its title', () => { + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1'), link('task', 't1')]), + { + ...none, + tasks: [task('t1', 'done', [merge(hoursAgo(2))])], + decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }], + }, + NOW, + ); + expect(r.status).toBe('blocked'); + expect(r.blockedOn).toEqual([{ decisionId: 'd1', title: 'Which vault?' }]); + expect(r.activity.state).toBe('moving'); + }); + + it('waiting on you: the pending decision has an open decision item for the giver', () => { + const attention = [ + { kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + ]; + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Positioning', status: 'pending' }], attention }, + NOW, + ); + expect(r.status).toBe('waiting-on-you'); + expect(r.waitingOnYou).toEqual([{ decisionId: 'd1', title: 'Positioning' }]); + }); + + it('a decision item addressed to someone else is blocked, not waiting on you', () => { + const attention = [ + { kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' } }, + { kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + ]; + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Positioning', status: 'pending' }], attention }, + NOW, + ); + expect(r.status).toBe('blocked'); + expect(r.waitingOnYou).toEqual([]); + }); + + it('needs your check: every linked task done and a PR merged, with no close; never a stall', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('pr', PR)]), + { ...none, tasks: [task('t1', 'done'), task('t2', 'done', [merge(hoursAgo(24 * 5))], PR)] }, + NOW, + ); + expect(r.status).toBe('needs-your-check'); + expect(r.needsCheck).toBe(true); + // The evidence is old, yet the status is not a stall. + expect(r.activity.state).toBe('stalled'); + }); + + it('closed: a close record by the giver', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked' }; + const r = deriveInitiativeStatus(initiative([link('task', 't1')], { closed }), { ...none, tasks: [task('t1', 'done')] }, NOW); + expect(r.status).toBe('closed'); + expect(r.needsCheck).toBe(false); + }); + + it('closed by owner: the owner is also the giver', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus(initiative([], { closed, ownerId: GIVER }), none, NOW); + expect(r.status).toBe('closed-by-owner'); + }); +}); + +describe('deriveInitiativeStatus — what does NOT count', () => { + it("an owner's task update inside the window does not move a stalled initiative", () => { + const before = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(72))])] }, + NOW, + ); + const after = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(72)), ownerNote(hoursAgo(1))])] }, + NOW, + ); + expect(before.status).toBe('stalled'); + expect(after).toEqual(before); + }); + + it('other system-authored task updates are not merge evidence', () => { + const starter = { text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1) }; + const spoofed = { text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1) }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [starter, spoofed])] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.evidence).toEqual([]); + }); + + it('a self-reported done (via /complete, no merge record) is not needs-your-check', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'done', [ownerNote(hoursAgo(1))])] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.needsCheck).toBe(false); + }); + + it('a close written by anyone but the giver is ignored', () => { + const closed = { at: hoursAgo(1), byUserId: OWNER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1')], { closed }), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(2))])] }, + NOW, + ); + expect(r.status).toBe('needs-your-check'); + }); + + it("a task the owner flagged blocked stays a note on its part, not the status", () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'blocked'), task('t2', 'done', [merge(hoursAgo(2))])] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.parts.find((p) => p.ref === 't1')).toMatchObject({ state: 'stalled', ownerFlaggedBlocked: true }); + }); +}); + +describe('deriveInitiativeStatus — parts and cadence', () => { + it('shows a stalled part inside a moving initiative, dated by the link when it has no evidence', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2', hoursAgo(30))]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(2))]), task('t2', 'pending')] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.parts).toEqual([ + { kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2) }, + { kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30) }, + ]); + }); + + it('the window is a per-initiative setting', () => { + const records = { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(30))])] }; + expect(deriveInitiativeStatus(initiative([link('task', 't1')]), records, NOW).status).toBe('stalled'); + expect(deriveInitiativeStatus(initiative([link('task', 't1')], { cadence: { windowHours: 48 } }), records, NOW).status) + .toBe('moving'); + }); +}); + +describe('the merge webhook writes exactly what the derivation reads', () => { + it('feeds the update completeTaskForMergedPull pushes into mergeEvidenceDates', async () => { + mockTaskUpdate.mockResolvedValue(null); + await completeTaskForMergedPull('aaaaaaaaaaaaaaaaaaaaaaaa', PR); + const pushed = mockTaskUpdate.mock.calls[0][1].$push.updates; + expect(mergeEvidenceDates(task('t1', 'done', [pushed], PR))).toHaveLength(1); + }); +}); diff --git a/backend/services/githubGigService.ts b/backend/services/githubGigService.ts index 81acf1a556..9f7ee1fe44 100644 --- a/backend/services/githubGigService.ts +++ b/backend/services/githubGigService.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import Task from '../models/Task'; import GithubGigBinding from '../models/GithubGigBinding'; +import { MERGE_EVIDENCE_TEXT } from './initiativeStatus'; // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const AgentIdentityService = require('./agentIdentityService'); @@ -82,7 +83,8 @@ export const completeTaskForMergedPull = async (podId: string, href: string): Pr const task = await Task.findOneAndUpdate( { podId: new Types.ObjectId(podId), prUrl: href, status: { $in: ['pending', 'claimed'] } }, { $set: { status: 'done', completedAt: at }, $push: { updates: { - text: 'Completed automatically — linked pull request merged', author: 'system', authorId: null, createdAt: at, + // Initiative status reads this exact update as merge evidence (initiativeStatus.ts). + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, } } }, { new: true }, ); diff --git a/backend/services/initiativeStatus.ts b/backend/services/initiativeStatus.ts new file mode 100644 index 0000000000..6b397f5e00 --- /dev/null +++ b/backend/services/initiativeStatus.ts @@ -0,0 +1,240 @@ +/** + * Initiative status, derived from evidence records only (Initiative v1 row 2; + * design: docs/plans/initiative-primitive.md §2). + * + * A status here is never written by an owner. It is a read over three kinds of + * record, each dated by the system that wrote it: + * + * - a merge: the update `completeTaskForMergedPull` pushes onto a Task when its + * linked PR merges (githubGigService). Only that exact update counts; other + * `author: 'system'` updates (starter tasks, sync lines) are not evidence; + * - a ruled decision: a DecisionRequest with `status: 'ruled'`, dated by + * `ruling.at`; + * - the close record, written by the direction-giver with one evidence URL. + * + * Owner-written Task `updates[]`, `claimedAt`, lease renewals and a `done` set + * through `/complete` or PATCH are deliberately NOT inputs: seats renew leases + * with update notes, so counting them would make a stalled initiative read as + * moving. A `blocked` Task is reported as a note on its part, never as status. + * + * Pure: callers resolve the linked records and pass them in, so this module + * reads no collection and owns no schema. + */ + +/** The text `completeTaskForMergedPull` writes; the only Task update that is evidence. */ +export const MERGE_EVIDENCE_TEXT = 'Completed automatically — linked pull request merged'; + +export const DEFAULT_WINDOW_HOURS = 24; + +export type InitiativeLinkKind = 'task' | 'pr' | 'pod' | 'decision'; + +export interface InitiativeLink { + kind: InitiativeLinkKind; + /** Task `_id`, PR URL (matched against `Task.prUrl`), pod id, or DecisionRequest `_id`. */ + ref: string; + addedAt: Date | string; +} + +export interface InitiativeClose { + at: Date | string; + byUserId: string; + evidenceUrl: string; + note?: string; +} + +export interface InitiativeInput { + createdAt: Date | string; + /** User id of the direction-giver. */ + direction: string; + /** `owner.userId || owner.agentUserId`. */ + ownerId?: string | null; + links: InitiativeLink[]; + cadence?: { windowHours?: number } | null; + closed?: InitiativeClose | null; +} + +export interface TaskRecord { + _id: unknown; + status: string; + prUrl?: string | null; + updates?: Array<{ text?: string; author?: string; authorId?: unknown; createdAt?: Date | string }>; +} + +export interface DecisionRecord { + _id: unknown; + title: string; + status: 'pending' | 'ruled' | string; + ruling?: { at?: Date | string } | null; +} + +export interface AttentionRecord { + kind: string; + status: string; + recipientUserId: unknown; + source: { type: string; id: string }; +} + +export interface InitiativeRecords { + /** Tasks named by `task` links, plus tasks whose `prUrl` matches a `pr` link. */ + tasks: TaskRecord[]; + /** DecisionRequests named by `decision` links. */ + decisions: DecisionRecord[]; + /** Open attention items, for `waiting on you`. Others are ignored. */ + attention: AttentionRecord[]; +} + +export type InitiativeStatus = + | 'closed' + | 'closed-by-owner' + | 'waiting-on-you' + | 'blocked' + | 'needs-your-check' + | 'moving' + | 'stalled'; + +export type PartState = 'moving' | 'stalled' | 'merged' | 'ruled' | 'pending'; + +export interface Evidence { + kind: 'merge' | 'decision'; + /** The link the evidence was found through. */ + linkKind: InitiativeLinkKind; + ref: string; + at: Date; +} + +export interface InitiativePart { + kind: InitiativeLinkKind; + ref: string; + state: PartState; + /** For `stalled`, the date the digest prints; otherwise the newest evidence, or null. */ + since: Date | null; + /** The owner flagged the linked Task `blocked`. A note only; never a status. */ + ownerFlaggedBlocked?: boolean; +} + +export interface DerivedInitiativeStatus { + status: InitiativeStatus; + /** Evidence-only activity, computed for every initiative regardless of `status`. */ + activity: { state: 'moving' | 'stalled'; since: Date }; + waitingOnYou: Array<{ decisionId: string; title: string }>; + blockedOn: Array<{ decisionId: string; title: string }>; + parts: InitiativePart[]; + /** Newest first. */ + evidence: Evidence[]; + /** True when the merge-to-check state holds; it never stalls and never nudges. */ + needsCheck: boolean; +} + +const toDate = (value: Date | string | null | undefined): Date | null => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const idOf = (value: unknown): string => String(value ?? ''); + +const newest = (dates: Array): Date | null => dates + .reduce((best, d) => (d && (!best || d > best) ? d : best), null); + +/** Merge evidence on one Task: the webhook's own update, never an owner's note. */ +export const mergeEvidenceDates = (task: TaskRecord): Date[] => (task.updates || []) + .filter((u) => u.author === 'system' && (u.authorId === null || u.authorId === undefined) + && u.text === MERGE_EVIDENCE_TEXT) + .map((u) => toDate(u.createdAt)) + .filter((d): d is Date => d !== null); + +const rulingDate = (decision: DecisionRecord): Date | null => (decision.status === 'ruled' + ? toDate(decision.ruling?.at) : null); + +export const deriveInitiativeStatus = ( + initiative: InitiativeInput, + records: InitiativeRecords, + now: Date = new Date(), +): DerivedInitiativeStatus => { + const windowHours = initiative.cadence?.windowHours && initiative.cadence.windowHours > 0 + ? initiative.cadence.windowHours : DEFAULT_WINDOW_HOURS; + const windowStart = new Date(now.getTime() - windowHours * 3600 * 1000); + const createdAt = toDate(initiative.createdAt) || now; + + const tasksById = new Map(records.tasks.map((t) => [idOf(t._id), t])); + const decisionsById = new Map(records.decisions.map((d) => [idOf(d._id), d])); + + const evidence: Evidence[] = []; + const parts: InitiativePart[] = []; + const linkedTasks = new Map(); + let anyMerge = false; + + const activePart = (link: InitiativeLink, last: Date | null): InitiativePart => { + if (last && last >= windowStart) return { kind: link.kind, ref: link.ref, state: 'moving', since: last }; + return { kind: link.kind, ref: link.ref, state: 'stalled', since: last || toDate(link.addedAt) || createdAt }; + }; + + initiative.links.forEach((link) => { + if (link.kind === 'task' || link.kind === 'pr') { + const tasks = link.kind === 'task' + ? [tasksById.get(link.ref)].filter((t): t is TaskRecord => Boolean(t)) + : records.tasks.filter((t) => t.prUrl && t.prUrl === link.ref); + tasks.forEach((t) => linkedTasks.set(idOf(t._id), t)); + const merges = tasks.flatMap(mergeEvidenceDates); + merges.forEach((at) => evidence.push({ kind: 'merge', linkKind: link.kind, ref: link.ref, at })); + if (merges.length) anyMerge = true; + const last = newest(merges); + const allDone = tasks.length > 0 && tasks.every((t) => t.status === 'done'); + const part = allDone && last + ? { kind: link.kind, ref: link.ref, state: 'merged' as PartState, since: last } + : activePart(link, last); + if (tasks.some((t) => t.status === 'blocked')) part.ownerFlaggedBlocked = true; + parts.push(part); + } else if (link.kind === 'decision') { + const decision = decisionsById.get(link.ref); + if (!decision) return; + const at = rulingDate(decision); + if (at) { + evidence.push({ kind: 'decision', linkKind: 'decision', ref: link.ref, at }); + parts.push({ kind: 'decision', ref: link.ref, state: 'ruled', since: at }); + } else if (decision.status === 'pending') { + parts.push({ kind: 'decision', ref: link.ref, state: 'pending', since: null }); + } + } + // A pod link is context for the digest; no record dates it. + }); + + evidence.sort((a, b) => b.at.getTime() - a.at.getTime()); + const lastEvidence = evidence[0]?.at || null; + const activity = lastEvidence && lastEvidence >= windowStart + ? { state: 'moving' as const, since: lastEvidence } + : { state: 'stalled' as const, since: lastEvidence || createdAt }; + + const pending = initiative.links + .filter((l) => l.kind === 'decision') + .map((l) => decisionsById.get(l.ref)) + .filter((d): d is DecisionRecord => d?.status === 'pending'); + const giver = idOf(initiative.direction); + const giverItems = new Set(records.attention + .filter((a) => a.kind === 'decision' && a.status === 'open' + && a.source?.type === 'decision_request' && idOf(a.recipientUserId) === giver) + .map((a) => idOf(a.source.id))); + const waitingOnYou = pending + .filter((d) => giverItems.has(idOf(d._id))) + .map((d) => ({ decisionId: idOf(d._id), title: d.title })); + const blockedOn = pending.map((d) => ({ decisionId: idOf(d._id), title: d.title })); + + const tasks = [...linkedTasks.values()]; + const needsCheck = tasks.length > 0 && tasks.every((t) => t.status === 'done') && anyMerge; + + // A close counts only when the giver wrote it; the close route enforces that, + // and the derivation does not trust a row that says otherwise. + const close = initiative.closed && idOf(initiative.closed.byUserId) === giver + && toDate(initiative.closed.at) && initiative.closed.evidenceUrl ? initiative.closed : null; + + let status: InitiativeStatus; + if (close) status = initiative.ownerId && idOf(initiative.ownerId) === giver ? 'closed-by-owner' : 'closed'; + else if (waitingOnYou.length) status = 'waiting-on-you'; + else if (blockedOn.length) status = 'blocked'; + else if (needsCheck) status = 'needs-your-check'; + else status = activity.state; + + return { + status, activity, waitingOnYou, blockedOn, parts, evidence, needsCheck: !close && needsCheck, + }; +}; From 76791201303a8da2ac1a25d7ad8bbc706b344d8e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:24:24 -0700 Subject: [PATCH 2/9] =?UTF-8?q?test(initiative):=20one=20case=20per=20prec?= =?UTF-8?q?edence=20overlap=20named=20in=20design=20=C2=A76=20row=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index b55a8da9a3..e4aa3fa44e 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -139,6 +139,36 @@ describe('deriveInitiativeStatus — one case per status row', () => { }); }); +describe('deriveInitiativeStatus — precedence overlaps (§2)', () => { + const recent = [task('t1', 'done', [merge(hoursAgo(1))])]; + + it('a pending decision beside recent evidence is blocked', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('decision', 'd1')]), + { ...none, tasks: recent, decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }] }, + NOW, + ); + expect(r.activity.state).toBe('moving'); + expect(r.status).toBe('blocked'); + }); + + it('a close beside recent evidence and a pending decision is closed', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('decision', 'd1')], { closed }), + { ...none, tasks: recent, decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }] }, + NOW, + ); + expect(r.status).toBe('closed'); + }); + + it('all merged beside recent evidence is needs your check, not moving', () => { + const r = deriveInitiativeStatus(initiative([link('task', 't1')]), { ...none, tasks: recent }, NOW); + expect(r.activity.state).toBe('moving'); + expect(r.status).toBe('needs-your-check'); + }); +}); + describe('deriveInitiativeStatus — what does NOT count', () => { it("an owner's task update inside the window does not move a stalled initiative", () => { const before = deriveInitiativeStatus( From c02e0d59336d05fb1c589d453a9db7f1daf28146 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:38:26 -0700 Subject: [PATCH 3/9] docs(initiative): cite the design PR, not a path that is not on main yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- backend/__tests__/unit/services/initiativeStatus.test.js | 2 +- backend/services/initiativeStatus.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index e4aa3fa44e..219fd1ac66 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -1,6 +1,6 @@ /** * Initiative v1 row 2: status is derived from evidence records only - * (docs/plans/initiative-primitive.md §2). One case per status row, plus the + * (the Initiative v1 design, #2788, §2). One case per status row, plus the * two negatives the design names: an owner's task update must NOT move an * initiative, and a merged initiative must read `needs your check`, not stall. * diff --git a/backend/services/initiativeStatus.ts b/backend/services/initiativeStatus.ts index 6b397f5e00..0e90a13697 100644 --- a/backend/services/initiativeStatus.ts +++ b/backend/services/initiativeStatus.ts @@ -1,6 +1,6 @@ /** * Initiative status, derived from evidence records only (Initiative v1 row 2; - * design: docs/plans/initiative-primitive.md §2). + * design: the Initiative v1 design, #2788, §2). * * A status here is never written by an owner. It is a read over three kinds of * record, each dated by the system that wrote it: From cf64f94198710bba84052e71374accbccc15e404 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:40:49 -0700 Subject: [PATCH 4/9] style(initiative): eslint --fix on the status derivation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index 219fd1ac66..7134c7d82b 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -25,9 +25,15 @@ const GIVER = 'giver-1'; const OWNER = 'owner-1'; const PR = 'https://github.com/Team-Commonly/commonly/pull/42'; -const merge = (at) => ({ text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at }); -const ownerNote = (at) => ({ text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at }); -const task = (id, status, updates = [], prUrl = null) => ({ _id: id, status, updates, prUrl }); +const merge = (at) => ({ + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, +}); +const ownerNote = (at) => ({ + text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at, +}); +const task = (id, status, updates = [], prUrl = null) => ({ + _id: id, status, updates, prUrl, +}); const initiative = (links, extra = {}) => ({ createdAt: hoursAgo(24 * 10), direction: GIVER, ownerId: OWNER, links, ...extra, }); @@ -48,11 +54,18 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('moving: a ruled decision inside the window', () => { const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), - { ...none, decisions: [{ _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) } }] }, + { + ...none, + decisions: [{ + _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) }, + }], + }, NOW, ); expect(r.status).toBe('moving'); - expect(r.evidence).toEqual([{ kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1) }]); + expect(r.evidence).toEqual([{ + kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1), + }]); }); it('stalled since : newest evidence is older than the window, dated by that evidence', () => { @@ -88,7 +101,9 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('waiting on you: the pending decision has an open decision item for the giver', () => { const attention = [ - { kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + { + kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' }, + }, ]; const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), @@ -101,8 +116,12 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('a decision item addressed to someone else is blocked, not waiting on you', () => { const attention = [ - { kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' } }, - { kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + { + kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' }, + }, + { + kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' }, + }, ]; const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), @@ -126,7 +145,9 @@ describe('deriveInitiativeStatus — one case per status row', () => { }); it('closed: a close record by the giver', () => { - const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked' }; + const closed = { + at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked', + }; const r = deriveInitiativeStatus(initiative([link('task', 't1')], { closed }), { ...none, tasks: [task('t1', 'done')] }, NOW); expect(r.status).toBe('closed'); expect(r.needsCheck).toBe(false); @@ -186,8 +207,12 @@ describe('deriveInitiativeStatus — what does NOT count', () => { }); it('other system-authored task updates are not merge evidence', () => { - const starter = { text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1) }; - const spoofed = { text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1) }; + const starter = { + text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1), + }; + const spoofed = { + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1), + }; const r = deriveInitiativeStatus( initiative([link('task', 't1')]), { ...none, tasks: [task('t1', 'claimed', [starter, spoofed])] }, @@ -217,7 +242,7 @@ describe('deriveInitiativeStatus — what does NOT count', () => { expect(r.status).toBe('needs-your-check'); }); - it("a task the owner flagged blocked stays a note on its part, not the status", () => { + it('a task the owner flagged blocked stays a note on its part, not the status', () => { const r = deriveInitiativeStatus( initiative([link('task', 't1'), link('task', 't2')]), { ...none, tasks: [task('t1', 'blocked'), task('t2', 'done', [merge(hoursAgo(2))])] }, @@ -237,8 +262,12 @@ describe('deriveInitiativeStatus — parts and cadence', () => { ); expect(r.status).toBe('moving'); expect(r.parts).toEqual([ - { kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2) }, - { kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30) }, + { + kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2), + }, + { + kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30), + }, ]); }); From 9866ccaba4da71f30a2918c1dc9e38e97d0043af Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:39:29 -0700 Subject: [PATCH 5/9] feat(initiative): one stall nudge per episode, to the owner or the installer (TASK-024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initiative v1 row 3 (design #2788 §4). Adds 'initiative' to the AttentionItem source type (union and schema enum) and a stall writer beside the other attention writers. Only a `stalled` status nudges. The episode is dated by the newest evidence when the stall began, so every run inside one episode upserts onto one row through the existing unique index; a new episode opens a new row and closes the old one, and an initiative that moves again closes its open stall row. The row goes to one current human member of the nudge pod: the owner if a person, else the agent's installer via installerForAgent. No agent is woken. The pod is the open linked task the digest names as Next (lowest taskNum), else the first pod link; with neither, or no human, nothing is written and the result says why. A failed write reports `failed`, not "no human". Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../service/initiativeStallNudge.test.js | 181 ++++++++++++++++++ backend/models/AttentionItem.ts | 4 +- backend/services/attentionItemService.ts | 61 +++++- backend/services/initiativeNudge.ts | 93 +++++++++ 4 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 backend/__tests__/service/initiativeStallNudge.test.js create mode 100644 backend/services/initiativeNudge.ts diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js new file mode 100644 index 0000000000..08996b61a1 --- /dev/null +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -0,0 +1,181 @@ +/** + * 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 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 () => { + 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)); + }); + + 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('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 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..e18306bece 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; @@ -549,6 +549,61 @@ 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. +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 target = stall.ownerUserId ? String(stall.ownerUserId) + : stall.ownerAgentUserId ? await installerForAgent(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: initiativeStallSourceId(stall.initiativeId, stall.episodeStart), + 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 +940,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() }, + }; +}; From 98456efeadc6c7f30a8a7210f362dd50914ce55e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:46:04 -0700 Subject: [PATCH 6/9] test(initiative): the first pod link routes a stall nudge with no open task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lily's ruling 88976 asks for a test for each routing fallback. Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- backend/__tests__/service/initiativeStallNudge.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js index 08996b61a1..ad61b175b5 100644 --- a/backend/__tests__/service/initiativeStallNudge.test.js +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -132,6 +132,15 @@ describe('initiative stall nudge', () => { 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' }); From c87d73f67d1f50d28e073b91b35cdf84dd4488f8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:48 -0700 Subject: [PATCH 7/9] fix(attention): one stall nudge per episode across an owner change; pin email and failed-retry (TASK-024) Vera's HOLD at 5cf8edee: the unique index carries the recipient, so a new owner mid-episode opened a second card. recordInitiativeStall now returns the episode's existing row, to whoever holds it, before writing. Tests: owner change in one episode -> 1 row, 1 email (red without the guard); the two-run case asserts exactly one email to the owner; a failed write returns `failed`, writes and mails nothing, and the next run nudges. Written by Keel, a Commonly agent Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../service/initiativeStallNudge.test.js | 37 +++++++++++++++++++ backend/services/attentionItemService.ts | 10 ++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js index ad61b175b5..bbda7f1502 100644 --- a/backend/__tests__/service/initiativeStallNudge.test.js +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -15,6 +15,7 @@ const { nudgeStalledInitiative, nudgePodId } = require('../../services/initiativ 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); @@ -28,6 +29,7 @@ describe('initiative stall nudge', () => { 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' }, @@ -93,6 +95,41 @@ describe('initiative stall nudge', () => { 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('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 () => { diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index e18306bece..8ac8be6e51 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -553,7 +553,9 @@ export const recordTaskAttention = async (task: any, options: TaskAttentionOptio // 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. +// 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. const INITIATIVE_STALL = 'stall'; export const initiativeStallSourceId = (initiativeId: unknown, episodeStart: Date): string => ( `initiative:${String(initiativeId)}:${INITIATIVE_STALL}:${episodeStart.toISOString()}` @@ -566,6 +568,10 @@ export const recordInitiativeStall = async (stall: { try { const { podId } = stall; if (!podId || !stall.initiativeId) return { recipientUserId: null }; + const sourceId = initiativeStallSourceId(stall.initiativeId, stall.episodeStart); + const existing = await AttentionItem.findOne({ 'source.type': 'initiative', 'source.id': sourceId }) + .select('recipientUserId').lean(); + if (existing) return { recipientUserId: String(existing.recipientUserId) }; const target = stall.ownerUserId ? String(stall.ownerUserId) : stall.ownerAgentUserId ? await installerForAgent(stall.ownerAgentUserId, podId) : null; if (!target) return { recipientUserId: null }; @@ -574,7 +580,7 @@ export const recordInitiativeStall = async (stall: { const pod = await Pod.findById(podId).select('name').lean(); await recordForRecipients([recipient], { podId, kind: 'blocked' as Kind, sourceType: 'initiative' as SourceType, - sourceId: initiativeStallSourceId(stall.initiativeId, stall.episodeStart), + 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, From b0e46e78d9e388e038ac1ba20d22daf036237a98 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:21:20 -0700 Subject: [PATCH 8/9] fix(attention): an episode row only blocks a second nudge while its recipient is still in the pod (TASK-024) CodeRabbit discussion_r4397463788: a departed owner's row is hidden by the queue, so returning it reported a nudge nobody sees. The guard now fails toward a second card. Test: owner leaves, new owner gets the episode (red against c87d73f6). Written by Keel, a Commonly agent Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../service/initiativeStallNudge.test.js | 16 ++++++++++++++++ backend/services/attentionItemService.ts | 16 ++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js index bbda7f1502..54cc145e1b 100644 --- a/backend/__tests__/service/initiativeStallNudge.test.js +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -115,6 +115,22 @@ describe('initiative stall nudge', () => { 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))])]; diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 8ac8be6e51..5fc2d0c9ef 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -555,7 +555,9 @@ export const recordTaskAttention = async (task: any, options: TaskAttentionOptio // 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. +// 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()}` @@ -569,9 +571,15 @@ export const recordInitiativeStall = async (stall: { const { podId } = stall; if (!podId || !stall.initiativeId) return { recipientUserId: null }; const sourceId = initiativeStallSourceId(stall.initiativeId, stall.episodeStart); - const existing = await AttentionItem.findOne({ 'source.type': 'initiative', 'source.id': sourceId }) - .select('recipientUserId').lean(); - if (existing) return { recipientUserId: String(existing.recipientUserId) }; + 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 installerForAgent(stall.ownerAgentUserId, podId) : null; if (!target) return { recipientUserId: null }; From fac01003b413185f628a167fe79d7c681e8f215e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:54:43 -0700 Subject: [PATCH 9/9] fix(attention): a failed installer lookup reports a stall nudge as failed, not no-human (TASK-024) installerForAgent swallows lookup errors because its other callers only use it for an optional email owner. For an initiative stall the installer is the required recipient, so recordInitiativeStall now uses a strict lookup and a read error reaches the outer catch as `failed`. CodeRabbit review 5678666214. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../service/initiativeStallNudge.test.js | 13 +++++++ backend/services/attentionItemService.ts | 34 ++++++++++++------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/backend/__tests__/service/initiativeStallNudge.test.js b/backend/__tests__/service/initiativeStallNudge.test.js index 54cc145e1b..a41a0e56fb 100644 --- a/backend/__tests__/service/initiativeStallNudge.test.js +++ b/backend/__tests__/service/initiativeStallNudge.test.js @@ -173,6 +173,19 @@ describe('initiative stall nudge', () => { 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' }); diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 5fc2d0c9ef..047c0ae2d0 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -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. @@ -581,7 +589,7 @@ export const recordInitiativeStall = async (stall: { } } const target = stall.ownerUserId ? String(stall.ownerUserId) - : stall.ownerAgentUserId ? await installerForAgent(stall.ownerAgentUserId, podId) : null; + : 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 };