From 49963ddc7c4a4831a2c1f6ef6be30fd9e48f18e7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:56:21 -0700 Subject: [PATCH 01/28] TASK-414 checkpoint: classify addressed wakes and carry digest context Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../models/AgentEvent.deliveryMode.test.js | 14 +++ .../unit/services/agentEventService.test.js | 60 ++++++++++++- ...ervice.addressingIsNotThreadScoped.test.js | 7 +- .../agentMentionService.adjacentReply.test.js | 14 ++- .../unit/services/agentMentionService.test.js | 9 +- .../agentMentionService.threadScoping.test.js | 13 +++ .../agentMentionService.wakeOnMessage.test.js | 16 ++-- ...ssageClaimService.liveClaims.pgmem.test.js | 15 ++++ .../messageDeliveryModeService.test.js | 52 +++++++++++ backend/models/AgentEvent.ts | 4 + backend/services/agentEventService.ts | 29 +++++-- backend/services/agentMentionService.ts | 86 +++++++++++-------- backend/services/messageClaimService.ts | 17 ++++ .../services/messageDeliveryModeService.ts | 85 ++++++++++++++++++ backend/services/threadWakeScopeService.ts | 11 ++- cli/__tests__/run-loop.test.mjs | 47 ++++++++++ cli/src/commands/agent.js | 41 +++++++-- 17 files changed, 446 insertions(+), 74 deletions(-) create mode 100644 backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js create mode 100644 backend/__tests__/unit/services/messageDeliveryModeService.test.js create mode 100644 backend/services/messageDeliveryModeService.ts diff --git a/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js b/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js new file mode 100644 index 0000000000..a65eac6d38 --- /dev/null +++ b/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); +const AgentEvent = require('../../../models/AgentEvent'); + +describe('AgentEvent delivery mode persistence shape', () => { + const base = { + agentName: 'flint', instanceId: 'default', podId: new mongoose.Types.ObjectId(), + type: 'message.posted', status: 'pending', attempts: 0, + }; + + test('legacy callers default to wake; digest survives strict schema conversion', () => { + expect(new AgentEvent(base).toObject().deliveryMode).toBe('wake'); + expect(new AgentEvent({ ...base, deliveryMode: 'digest' }).toObject().deliveryMode).toBe('digest'); + }); +}); diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index e0a36e3c4e..872a7e4da2 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -319,7 +319,8 @@ describe('AgentEventService', () => { // covers pod-b too. AgentEvent.find .mockReturnValueOnce(query([{ _id: 'head', podId: 'pod-a' }])) - .mockReturnValueOnce(query([{ _id: 'head' }, { _id: 'same-pod' }])); + .mockReturnValueOnce(query([{ _id: 'head' }, { _id: 'same-pod' }])) + .mockReturnValueOnce(query([])); AgentEvent.countDocuments.mockResolvedValue(2); AgentMemory.findOne.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), @@ -346,6 +347,7 @@ describe('AgentEventService', () => { $and: expect.arrayContaining([ expect.objectContaining({ podId: { $in: ['pod-a', 'pod-b'] } }), { podId: { $exists: true, $ne: null } }, + { deliveryMode: { $ne: 'digest' } }, ]), })); expect(AgentEvent.find).toHaveBeenNthCalledWith(2, expect.objectContaining({ podId: 'pod-a' })); @@ -378,12 +380,68 @@ describe('AgentEventService', () => { podId: { $in: ['pod-a', 'pod-b'] }, }, { podId: { $exists: true, $ne: null } }, + { deliveryMode: { $ne: 'digest' } }, ], }); expect(AgentEvent.countDocuments).not.toHaveBeenCalled(); expect(AgentEvent.findOneAndUpdate).not.toHaveBeenCalled(); }); + test('digest-only inbox stays pending; a later wake claims it in the same pod', async () => { + const AgentMemory = require('../../../models/AgentMemory'); + const query = (rows) => ({ + sort: () => ({ limit: () => ({ select: () => ({ lean: jest.fn().mockResolvedValue(rows) }) }) }), + }); + AgentEvent.find.mockReturnValueOnce(query([])); + expect(await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' })) + .toEqual({ events: [], inboxCount: 0 }); + expect(AgentEvent.findOneAndUpdate).not.toHaveBeenCalled(); + + AgentEvent.find + .mockReturnValueOnce(query([{ _id: 'wake', podId: 'pod-a' }])) + .mockReturnValueOnce(query([{ _id: 'wake' }])) + .mockReturnValueOnce(query([{ _id: 'digest' }])); + AgentEvent.countDocuments.mockResolvedValue(2); + AgentMemory.findOne.mockReturnValue({ + select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), + }); + AgentEvent.findOneAndUpdate + .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ + _id: 'wake', podId: 'pod-a', type: 'chat.mention', status: 'delivered', payload: {}, + }) }) + .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ + _id: 'digest', podId: 'pod-a', type: 'message.posted', deliveryMode: 'digest', + status: 'delivered', payload: { messageId: '42' }, + }) }); + const page = await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' }); + expect(page.events.map((event) => event._id)).toEqual(['wake', 'digest']); + expect(page.inboxCount).toBe(2); + expect(AgentEvent.find).toHaveBeenNthCalledWith(3, expect.objectContaining({ + podId: 'pod-a', deliveryMode: { $ne: 'digest' }, + })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(4, expect.objectContaining({ + podId: 'pod-a', deliveryMode: 'digest', + })); + }); + + test('a sibling winning the wake leaves its digest companion unclaimed', async () => { + const AgentMemory = require('../../../models/AgentMemory'); + const query = (rows) => ({ + sort: () => ({ limit: () => ({ select: () => ({ lean: jest.fn().mockResolvedValue(rows) }) }) }), + }); + AgentEvent.find + .mockReturnValueOnce(query([{ _id: 'wake', podId: 'pod-a' }])) + .mockReturnValueOnce(query([{ _id: 'wake' }])) + .mockReturnValueOnce(query([{ _id: 'digest' }])); + AgentMemory.findOne.mockReturnValue({ + select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), + }); + AgentEvent.findOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + const page = await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' }); + expect(page.events).toEqual([]); + expect(AgentEvent.findOneAndUpdate).toHaveBeenCalledTimes(1); + }); + test('listInboxPage preserves its pre-claim count when a sibling wins every candidate', async () => { const query = (rows) => ({ sort: () => ({ diff --git a/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js b/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js index 1362164d77..cb9eff9c75 100644 --- a/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js +++ b/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js @@ -50,6 +50,9 @@ jest.mock('../../../models/pg/ThreadUserState', () => ({ jest.mock('../../../services/threadWakeScopeService', () => ({ narrowToThread: jest.fn(async (_rootId, targets) => targets), })); +jest.mock('../../../services/messageDeliveryModeService', () => ({ + addressedMessageTargets: jest.fn(async () => new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -158,9 +161,9 @@ describe('the addressing fan-out never consults thread scope', () => { }); expect(mentions()).toHaveLength(1); - expect(narrowToThread).toHaveBeenCalledTimes(1); + expect(narrowToThread).not.toHaveBeenCalled(); // The mentioned seat is excluded from its own ambient companion, so the - // narrowing cannot double-deliver — it can only remove. + // empty companion does not need a scoping read. expect(wakes()).toHaveLength(0); }); diff --git a/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js b/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js index e69b88de15..a80c4a63ef 100644 --- a/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js +++ b/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js @@ -11,7 +11,7 @@ * within 30 minutes, reaches exactly that agent as one chat.mention carrying * the replies-to-you frame; * - a second person, an older or human or system previous message, a thread - * reply, or a DM-shaped pod: nothing; + * reply, or a DM-shaped pod: no adjacency address; * - an @mention or a reply edge is addressing already, and adjacency adds * nothing on top; * - a wake-on agent already hears every message, so it is not delivered twice; @@ -162,7 +162,7 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { test('a second person in the room: nothing, because the line may be for them', async () => { setup({ members: [OWNER, SECOND_HUMAN, SEAT_A_BOT] }); const res = await send('what do you both think?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); expect(res.implicit).toEqual([]); }); @@ -197,11 +197,10 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { ])('a previous message that is %s: nothing', async (_label, previous) => { setup({ previous }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); // Each of these is refused from the previous row alone: the only User // lookup is the sender's own, never the previous author's. expect(User.findById).toHaveBeenCalledTimes(1); - expect(Pod.findById).not.toHaveBeenCalled(); }); test('a previous message by a person whose row carries agent metadata: nothing, isBot decides', async () => { @@ -210,20 +209,19 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { previous: { user_id: PERSON_WITH_STALE_BOT_METADATA, created_at: new Date(NOW.getTime() - 5000), message_type: 'text' }, }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); - expect(Pod.findById).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); }); test('a previous message from an agent no longer installed here: nothing', async () => { setup({ installations: [install('other-seat')] }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); }); test('a thread reply is not adjacent to the room', async () => { setup(); await send('in the thread', { message: { thread_root_id: 79400 } }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); expect(PGMessage.findPreviousTopLevel).not.toHaveBeenCalled(); }); diff --git a/backend/__tests__/unit/services/agentMentionService.test.js b/backend/__tests__/unit/services/agentMentionService.test.js index 9b93df3edf..d9993af438 100644 --- a/backend/__tests__/unit/services/agentMentionService.test.js +++ b/backend/__tests__/unit/services/agentMentionService.test.js @@ -257,7 +257,8 @@ describe('AgentMentionService', () => { username: 'theo', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); expect(User.findById).toHaveBeenCalledTimes(1); expect(result.implicit).toEqual([]); }); @@ -391,7 +392,8 @@ describe('AgentMentionService', () => { username: 'alice', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); expect(result.implicit).toEqual([]); }); @@ -2000,7 +2002,8 @@ describe('AgentMentionService', () => { }); expect(res.enqueued).toEqual([]); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); }); test('the budget is shared, so alternating surfaces cannot each sit at half of it', async () => { diff --git a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js index d7c33eb030..c101199566 100644 --- a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js +++ b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js @@ -29,6 +29,9 @@ jest.mock('../../../services/threadWakeScopeService', () => ({ narrowToThread: jest.fn(async (_root, targets) => targets), effectiveFollowerIds: jest.fn(async () => new Set()), })); +jest.mock('../../../services/messageDeliveryModeService', () => ({ + addressedMessageTargets: jest.fn(async () => new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -38,6 +41,7 @@ const Pod = require('../../../models/Pod'); const User = require('../../../models/User'); const AgentEvent = require('../../../models/AgentEvent'); const { narrowToThread } = require('../../../services/threadWakeScopeService'); +const { addressedMessageTargets } = require('../../../services/messageDeliveryModeService'); const ThreadUserState = require('../../../models/pg/ThreadUserState'); const PGMessage = require('../../../models/pg/Message'); @@ -83,6 +87,7 @@ const send = (message, extra = {}) => AgentMentionService.enqueueMentions({ beforeEach(() => { jest.clearAllMocks(); narrowToThread.mockImplementation(async (_root, targets) => targets); + addressedMessageTargets.mockResolvedValue(new Set()); AgentInstallation.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([ optIn('seat-a', 'human-installer-a'), optIn('seat-b', 'human-installer-b'), @@ -211,6 +216,14 @@ describe('scoping narrows delivery, and only delivery', () => { expect(byType('message.posted')).toHaveLength(0); }); + test('a current thread author stays addressed when ambient scoping drops everyone', async () => { + addressedMessageTargets.mockResolvedValue(new Set(['seat-a:default'])); + narrowToThread.mockImplementation(async () => []); + await send({ id: 'm5-addressed', content: 'threaded', thread_root_id: 101 }); + expect(byType('message.posted').map((event) => [event.agentName, event.deliveryMode])) + .toEqual([['seat-a', 'wake']]); + }); + test('CONTROL: the same message unthreaded still wakes both', async () => { // Without this, the two tests above pass from a pipeline that wakes nobody. narrowToThread.mockImplementation(async () => []); diff --git a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js index eb2542cb08..07dd6703e2 100644 --- a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js +++ b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js @@ -3,7 +3,7 @@ * exercised through enqueueMentions, the module boundary). * * The contract under test: - * - opt-in only: `config.wakeOnMessage.enabled === true`, default OFF + * - opt-in wakes; default-off installations receive a pending digest * - unrouted messages wake opt-ins as `message.posted` with the inline frame * - mention-enqueued targets are NOT double-delivered * - an agent never wakes on its own post @@ -174,10 +174,12 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual(['seat-a', 'seat-b']); - expect(wakeCalls().map((call) => call.agentName)).toEqual(['seat-a', 'seat-b']); + expect(wakeCalls().filter((call) => call.deliveryMode === 'wake').map((call) => call.agentName)) + .toEqual(['seat-a', 'seat-b']); + expect(wakeCalls().find((call) => call.agentName === 'seat-c').deliveryMode).toBe('digest'); }); - test('default is OFF: installs without the flag are never woken', async () => { + test('default is OFF: installs without the flag get digest delivery without a wake', async () => { mockInstallations([install('seat-a'), install('seat-b')]); const res = await AgentMentionService.enqueueMentions({ @@ -188,11 +190,9 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual([]); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); - // No pod-shape lookup: with no opt-in there is no wake fan-out, and D6.4 - // adjacency looks at the pod only when the previous message is a recent - // agent post (here there is none). - expect(Pod.findById).not.toHaveBeenCalled(); + expect(wakeCalls().map((call) => [call.agentName, call.deliveryMode])) + .toEqual([['seat-a', 'digest'], ['seat-b', 'digest']]); + expect(wakeCalls()[0].payload.content).toBe('nobody asked anyone anything'); }); test('a mention-enqueued target is not double-delivered; other opt-ins still wake', async () => { diff --git a/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js b/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js index 8782984339..cda1f60dc5 100644 --- a/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js +++ b/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js @@ -36,6 +36,9 @@ const lease = (messageId, { ); beforeAll(async () => { + await realPool.query(`CREATE TABLE messages ( + id INTEGER PRIMARY KEY, pod_id TEXT NOT NULL, thread_root_id INTEGER + )`); await realPool.query(`CREATE TABLE message_claims ( message_id TEXT PRIMARY KEY, pod_id TEXT NOT NULL, @@ -49,6 +52,7 @@ beforeAll(async () => { beforeEach(async () => { await realPool.query('DELETE FROM message_claims'); + await realPool.query('DELETE FROM messages'); }); const held = () => MessageClaimService.liveClaimsHeldBy({ podId: POD, agentName: 'Scout', instanceId: 'u1' }); @@ -83,3 +87,14 @@ describe('liveClaimsHeldBy', () => { await expect(held()).resolves.toEqual([]); }); }); + +describe('liveClaimantsInThread', () => { + test('finds only active claims on this thread in this pod', async () => { + await realPool.query('INSERT INTO messages (id, pod_id, thread_root_id) VALUES (900, $1, NULL), (901, $1, 900), (902, $1, 900), (903, $2, 900)', [POD, OTHER_POD]); + await lease('901', { claimedBy: 'Scout' }); + await lease('902', { claimedBy: 'flint', state: 'completed' }); + await lease('903', { claimedBy: 'other', podId: OTHER_POD }); + await expect(MessageClaimService.liveClaimantsInThread(POD, 900)) + .resolves.toEqual(new Set(['scout:u1'])); + }); +}); diff --git a/backend/__tests__/unit/services/messageDeliveryModeService.test.js b/backend/__tests__/unit/services/messageDeliveryModeService.test.js new file mode 100644 index 0000000000..070a287cd8 --- /dev/null +++ b/backend/__tests__/unit/services/messageDeliveryModeService.test.js @@ -0,0 +1,52 @@ +jest.mock('../../../models/Task', () => ({ find: jest.fn() })); +jest.mock('../../../services/threadWakeScopeService', () => ({ threadAuthorIds: jest.fn() })); +jest.mock('../../../services/messageClaimService', () => ({ liveClaimantsInThread: jest.fn() })); + +const Task = require('../../../models/Task'); +const { threadAuthorIds } = require('../../../services/threadWakeScopeService'); +const MessageClaimService = require('../../../services/messageClaimService'); +const { + addressedMessageTargets, messageReferenceMatchers, +} = require('../../../services/messageDeliveryModeService'); + +const targets = [{ agentName: 'flint', instanceId: 'default' }, { agentName: 'sable', instanceId: 'default' }]; +const botUserIds = new Map([['flint:default', 'bot-flint'], ['sable:default', 'bot-sable']]); +const classify = (content, threadRootId = null) => addressedMessageTargets({ + podId: 'pod-a', content, threadRootId, targets, botUserIds, +}); + +describe('ambient addressed delivery', () => { + beforeEach(() => { + jest.clearAllMocks(); + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([]) }) }); + threadAuthorIds.mockResolvedValue(new Set()); + MessageClaimService.liveClaimantsInThread.mockResolvedValue(new Set()); + }); + + test('thread authors and live claimants wake only their own seat', async () => { + threadAuthorIds.mockResolvedValue(new Set(['bot-flint'])); + MessageClaimService.liveClaimantsInThread.mockResolvedValue(new Set(['sable:default'])); + expect(await classify('ambient', 42)).toEqual(new Set(['flint:default', 'sable:default'])); + expect(MessageClaimService.liveClaimantsInThread).toHaveBeenCalledWith('pod-a', 42); + }); + + test('an exact TASK reference addresses its assignee and a live claim holder', async () => { + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([ + { taskId: 'TASK-414', assignee: 'flint', status: 'pending' }, + { taskId: 'TASK-415', claimedBy: 'bot-sable', status: 'claimed', claimExpiresAt: new Date(Date.now() + 60000) }, + ]) }) }); + expect(await classify('TASK-414 and TASK-415')).toEqual(new Set(['flint:default', 'sable:default'])); + expect(await classify('TASK-4140')).toEqual(new Set()); + expect(messageReferenceMatchers.taskRefs('xTASK-414y')).toEqual([]); + }); + + test('a PR number matches the task prUrl exactly, not a numeric prefix', async () => { + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([ + { taskId: 'TASK-414', assignee: 'flint', status: 'pending', prUrl: 'https://github.com/Team-Commonly/commonly/pull/2534' }, + ]) }) }); + expect(await classify('review #2534')).toEqual(new Set(['flint:default'])); + expect(await classify('review #25345')).toEqual(new Set()); + expect(messageReferenceMatchers.prNumber('https://github.com/Team-Commonly/commonly/pull/25345')) + .toBe('25345'); + }); +}); diff --git a/backend/models/AgentEvent.ts b/backend/models/AgentEvent.ts index da41d76246..9ba0f3d1a5 100644 --- a/backend/models/AgentEvent.ts +++ b/backend/models/AgentEvent.ts @@ -7,6 +7,7 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; // Existing rows without an `'acked'` value continue to read as `'delivered'` // indefinitely; the ack-gate below short-circuits if status is already `'acked'`. export type AgentEventStatus = 'pending' | 'delivered' | 'acked' | 'failed'; +export type AgentEventDeliveryMode = 'wake' | 'digest'; export type DeliveryOutcome = 'acknowledged' | 'posted' | 'no_action' | 'skipped' | 'error'; // ADR-003 Phase 4: cross-agent ask/respond payloads. The `type` field on @@ -92,6 +93,8 @@ export interface IAgentEvent extends Document { type: string; payload?: unknown; status: AgentEventStatus; + // Independent of `delivery`, which records the eventual ack outcome. + deliveryMode: AgentEventDeliveryMode; attempts: number; deliveredAt?: Date; error?: string; @@ -119,6 +122,7 @@ const AgentEventSchema = new Schema( type: { type: String, required: true }, payload: Schema.Types.Mixed, status: { type: String, enum: ['pending', 'delivered', 'acked', 'failed'], default: 'pending' }, + deliveryMode: { type: String, enum: ['wake', 'digest'], default: 'wake' }, attempts: { type: Number, default: 0 }, deliveredAt: Date, error: String, diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 68c9290ca0..f0144d44ec 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -122,6 +122,7 @@ interface EventDoc { createdAt?: Date; payload?: Record; status?: string; + deliveryMode?: 'wake' | 'digest'; attempts?: number; delivery?: DeliveryMeta; memoryRevisionAtDelivery?: number | null; @@ -219,6 +220,7 @@ interface EnqueueOptions { type: string; payload?: Record; instanceId?: string; + deliveryMode?: 'wake' | 'digest'; } interface ListOptions { @@ -1399,7 +1401,7 @@ class AgentEventService { } static async enqueue({ - agentName, podId, type, payload = {}, instanceId = 'default', + agentName, podId, type, payload = {}, instanceId = 'default', deliveryMode = 'wake', }: EnqueueOptions): Promise { if (!agentName || !podId || !type) { throw new Error('agentName, podId, and type are required'); @@ -1445,6 +1447,7 @@ class AgentEventService { podId, type, payload: eventPayload, + ...(deliveryMode === 'digest' ? { deliveryMode } : {}), // Native runs resolve in-process, so the event is claimed at creation // rather than polled. `attempts: 1` because this IS its one delivery — // the claim in list() never runs for native events, and a native event @@ -1483,7 +1486,7 @@ class AgentEventService { }); const wsService = getWebSocketService(); - if (wsService) { + if (wsService && deliveryMode === 'wake') { wsService.pushEvent({ _id: event._id, agentName: event.agentName, @@ -1509,7 +1512,7 @@ class AgentEventService { // if the gateway is dead." Typing_stop is still emitted when // AgentMessageService.postMessage runs.) - AgentInstallation.find({ + if (deliveryMode === 'wake') AgentInstallation.find({ agentName: event.agentName, instanceId, podId, @@ -1594,7 +1597,7 @@ class AgentEventService { // behind a row no poller can process. Keep the caller's scope inside // the same `$and` so this exclusion only narrows access. const headQuery = { - $and: [query, { podId: { $exists: true, $ne: null } }], + $and: [query, { podId: { $exists: true, $ne: null } }, { deliveryMode: { $ne: 'digest' } }], }; const [head] = await AgentEvent.find(headQuery) .sort({ createdAt: 1 }) @@ -1614,11 +1617,22 @@ class AgentEventService { inboxCount = await AgentEvent.countDocuments(batchQuery); } - const candidates = await AgentEvent.find(batchQuery) + const wakeQuery = onePod ? { ...batchQuery, deliveryMode: { $ne: 'digest' } } : batchQuery; + const wakeCandidates = await AgentEvent.find(wakeQuery) .sort({ createdAt: 1 }) .limit(limit) .select({ _id: 1 }) .lean() as Array<{ _id: unknown }>; + // Digest rows are only claimed alongside a wake in the same pod. Reserve + // a slot for wake work so a long digest backlog cannot starve the turn. + const digestCandidates = onePod && wakeCandidates.length > 0 && wakeCandidates.length < limit + ? await AgentEvent.find({ ...batchQuery, deliveryMode: 'digest' }) + .sort({ createdAt: 1 }) + .limit(limit - wakeCandidates.length) + .select({ _id: 1 }) + .lean() as Array<{ _id: unknown }> + : []; + const candidates = [...wakeCandidates, ...digestCandidates]; // A sibling poller can claim every candidate after our pre-claim count. // The page is empty, but the count still describes what this reader saw @@ -1642,7 +1656,10 @@ class AgentEventService { const digestBundle = buildMemoryDigestBundle(memoryDoc || {}, lastSeenRevision); const claimed: EventDoc[] = []; - for (const candidate of candidates) { + for (const [index, candidate] of candidates.entries()) { + // Another poller may win every wake after our read. In that race the + // digest must remain pending instead of being claimed on its own. + if (onePod && index >= wakeCandidates.length && claimed.length === 0) break; // Coerce candidate _id to string before it lands in the query — Mongo // accepts both string and ObjectId, and `unknown`-typed values are the // CodeQL trip wire. The find() above scopes by agentName + instanceId diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 48710ee717..a16a7ccda5 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -1217,7 +1217,7 @@ const isWakeLoopDampened = async ( /** `agentName:instanceId`, lowercased — the identity an install and a bot User row share. */ const installKey = (inst: { agentName?: unknown; instanceId?: unknown }): string => `${ - String(inst.agentName || '').toLowerCase()}:${String(inst.instanceId || 'default')}`; + String(inst.agentName || '').toLowerCase()}:${String(inst.instanceId || 'default').toLowerCase()}`; /** A thread root arrives from PG as either its numeric column or JSON spelling. */ const resolveThreadRootId = (message: EnqueueMentionsOptions['message']): number | null => { @@ -1346,14 +1346,13 @@ const followMentionedThreadUsers = async ( }; /** - * Fan a message out to the pod's wake-on-message opt-ins. Skips the sender's + * Fan a message out to the pod's agent inboxes. Skips the sender's * own identity (an agent never wakes on its own post), anything the mention * path already enqueued this message (a mention is the stronger cue — double * delivery would burn a second model turn on the same trigger), and DM-shaped * pods (enqueueDmEvent already routes every message there). * - * The zero-opt-in fast path costs nothing beyond the installations array the - * caller already loaded — no queries, no pod lookup. + * Unaddressed activity for seats without wake-on-message becomes digest work. */ const enqueueWakeOnMessage = async ({ podId, message, rawContent, userId, username, source, installations, sender, excludeKeys, authorFrame, resolvedBotUserIds, @@ -1373,27 +1372,21 @@ const enqueueWakeOnMessage = async ({ replyToMessageId?: string | null; }): Promise => { const woken: string[] = []; - let targets = (installations || []).filter(wakeOnMessageEnabled); + const senderKey = sender?.isBot + ? `${String(sender.botMetadata?.agentName || '').toLowerCase()}:${String(sender.botMetadata?.instanceId || 'default').toLowerCase()}` + : null; + let targets = (installations || []).filter((inst) => { + const key = installKey(inst); + return key !== senderKey && !excludeKeys?.has(key); + }); if (targets.length === 0) return woken; // Ambient-only thread scoping (W-T 3/4, #1045). A reply inside a thread is // AMBIENT: it reaches the thread's effective followers, not the room. // - // Applied HERE, to the already-computed opt-in list, and nowhere else. Two - // consequences that are the point rather than side effects: - // - it can only NARROW. A thread follower who never opted into - // wake-on-message still gets nothing, because threading is additive and - // must not start delivering to seats that opted into nothing. - // - it cannot touch addressing. NOT because this branch is unreachable on - // a routed message — it is reachable, via the second call site at the - // tail of enqueueMentions, which runs unconditionally so a routed - // message's ambient companion is scoped too. It cannot touch addressing - // because the chat.mention has ALREADY been enqueued by the time this - // runs, and the mentioned seat arrives here inside `excludeKeys`. A mute - // scopes ambient activity; it never suppresses being addressed. - // (An earlier version of this comment claimed the branch runs only when - // `!isRouted`, contradicting the call-site comment below it and giving a - // reader the right conclusion from a false mechanism.) + // Only non-addressed delivery is narrowed. A current thread author or + // claimant is addressed by this message; an explicit @mention was already + // routed and removed from this fan-out above. // // Keyed on the BOT's User row id, which is what thread_user_state.user_id // holds: the follow/mute routes are dualAuth, so an agent writing its own @@ -1422,16 +1415,33 @@ const enqueueWakeOnMessage = async ({ // narrowing and the reply-evidence stamp share this (a suite pins the // single User.find). let cachedBotUserIds: Map | undefined = resolvedBotUserIds; + const needsOwnershipIds = Boolean(threadRootId) || /\bTASK-\d+\b|#\d+\b/i.test(rawContent); + if (needsOwnershipIds) cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); + let addressedTargets: Set; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { addressedMessageTargets } = require('./messageDeliveryModeService'); + addressedTargets = await addressedMessageTargets({ + podId, content: rawContent, threadRootId, targets, + botUserIds: cachedBotUserIds ?? new Map(), + }); + } catch (error) { + console.warn('[message-delivery] addressed lookup failed (waking):', (error as Error).message); + addressedTargets = new Set(targets.map(installKey)); + } if (threadRootId) { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { narrowToThread } = require('./threadWakeScopeService'); cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); const botUserIds = cachedBotUserIds; - targets = await narrowToThread( + const addressed = targets.filter((inst) => addressedTargets.has(installKey(inst))); + const ambient = targets.filter((inst) => !addressedTargets.has(installKey(inst))); + const scopedAmbient = await narrowToThread( threadRootId, - targets, + ambient, (inst: Record) => botUserIds.get(installKey(inst)) ?? null, ); + targets = [...addressed, ...scopedAmbient]; if (targets.length === 0) return woken; } @@ -1451,10 +1461,6 @@ const enqueueWakeOnMessage = async ({ // one-line change instead of a hunt for a call site that lied. const collaborativePod = isCollaborativePod(podRow?.type, installations); - const senderKey = sender?.isBot - ? `${String(sender.botMetadata?.agentName || '').toLowerCase()}:${String(sender.botMetadata?.instanceId || 'default').toLowerCase()}` - : null; - // Reply-evidence flag (ADR-018 D6.3, interim). The #703 implicit-reply path // is gated on `sender.isBot === false`, so a BOT's reply to an agent's // message reaches its author as plain ambient activity — and the claim @@ -1470,7 +1476,7 @@ const enqueueWakeOnMessage = async ({ // seat+thread — is NOT implemented, so the dampener is the only loop bound. // Read the ADR before widening this: the decision is deliberately staged. const parentMessageId = String( - replyToMessageId || (message as { reply_to_message_id?: unknown })?.reply_to_message_id || threadRootId || '', + replyToMessageId || (message as { reply_to_message_id?: unknown })?.reply_to_message_id || '', ) || null; let parentAuthorUserId: string | null = null; if (parentMessageId) { @@ -1503,23 +1509,28 @@ const enqueueWakeOnMessage = async ({ if (excludeKeys?.has(key)) return; if (humanOnlyWake(inst) && sender?.isBot !== false) return; if (senderKey && await isWakeLoopDampened({ agentName, instanceId }, podId)) return; + const addressed = addressedTargets.has(key); + const deliveryMode = wakeOnMessageEnabled(inst) || addressed ? 'wake' : 'digest'; const repliesToYou = !!(parentAuthorUserId && parentBotUserIds && parentBotUserIds.get(installKey(inst)) === parentAuthorUserId); try { - const built = buildContentForTarget( - podId, - rawContent, - 'message.posted', - agentName, - collaborativePod, - authorFrame, - ); + const built = deliveryMode === 'digest' + ? String(rawContent || '').replace(/\s+/g, ' ').slice(0, 200) + : buildContentForTarget( + podId, + rawContent, + 'message.posted', + agentName, + collaborativePod, + authorFrame, + ); await AgentEventService.enqueue({ agentName, instanceId, podId, type: 'message.posted', + deliveryMode, payload: { messageId: authorFrame.messageId, // Inline cue, not only metadata — the flag below is for the @@ -1530,7 +1541,8 @@ const enqueueWakeOnMessage = async ({ source, messageType: message?.messageType || message?.message_type || 'text', createdAt: message?.createdAt || message?.created_at || new Date(), - wakeOnMessage: true, + wakeOnMessage: wakeOnMessageEnabled(inst), + ...(addressed ? { addressed: true } : {}), // Claim-decline handoff is deliberately human-only. This stamp is // the cross-layer proof the wrapper and kernel need; absent remains // non-human/unknown for old events rather than widening a bot loop. @@ -1538,7 +1550,7 @@ const enqueueWakeOnMessage = async ({ ...(repliesToYou ? { repliesToYourMessage: true } : {}), }, }); - woken.push(agentName); + if (deliveryMode === 'wake') woken.push(agentName); } catch (error) { console.warn('Failed to enqueue wake-on-message event:', (error as Error).message); } diff --git a/backend/services/messageClaimService.ts b/backend/services/messageClaimService.ts index 8e7c09413b..3389b5499a 100644 --- a/backend/services/messageClaimService.ts +++ b/backend/services/messageClaimService.ts @@ -612,6 +612,23 @@ class MessageClaimService { return res.rows.map((r: { message_id: unknown }) => String(r.message_id)); } + /** Current claimants on any message in this thread, scoped to this pod. */ + static async liveClaimantsInThread(podId: string, threadRootId: number): Promise> { + if (!podId || !Number.isSafeInteger(threadRootId) || threadRootId <= 0) return new Set(); + await ensureTable(); + const res = await pool.query( + `SELECT DISTINCT LOWER(c.claimed_by) AS agent_name, c.instance_id + FROM message_claims c + JOIN messages m ON m.id::text = c.message_id AND m.pod_id = c.pod_id + WHERE c.pod_id = $1 AND COALESCE(m.thread_root_id, m.id) = $2 + AND c.state = 'claimed' AND c.expires_at >= NOW()`, + [String(podId), threadRootId], + ); + return new Set(res.rows.map((row: { agent_name: string; instance_id: string }) => ( + `${row.agent_name}:${row.instance_id || 'default'}` + ))); + } + static async holder(messageId: string): Promise { await ensureTable(); const res = await pool.query( diff --git a/backend/services/messageDeliveryModeService.ts b/backend/services/messageDeliveryModeService.ts new file mode 100644 index 0000000000..49c4d5d287 --- /dev/null +++ b/backend/services/messageDeliveryModeService.ts @@ -0,0 +1,85 @@ +/** Addressed cues for ambient messages. A match only counts for the seat that owns it. */ +/* eslint-disable @typescript-eslint/no-require-imports, global-require */ +const Task = require('../models/Task'); +const { threadAuthorIds } = require('./threadWakeScopeService'); +const MessageClaimService = require('./messageClaimService'); + +interface Target { + agentName?: unknown; + instanceId?: unknown; +} + +const key = (target: Target): string => `${String(target.agentName || '').toLowerCase()}:${String(target.instanceId || 'default').toLowerCase()}`; + +const taskRefs = (content: string): string[] => [...new Set( + [...content.matchAll(/(?:^|[^a-z0-9])TASK-(\d+)(?![a-z0-9])/gi)] + .map((match) => `TASK-${match[1]}`), +)]; + +const prRefs = (content: string): Set => new Set( + [...content.matchAll(/(?:^|[^a-z0-9])#(\d+)(?![a-z0-9])/gi)] + .map((match) => match[1]), +); + +const prNumber = (url: unknown): string | null => { + if (typeof url !== 'string') return null; + const match = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#]|$)/i.exec(url); + return match?.[1] || null; +}; + +export async function addressedMessageTargets({ + podId, content, threadRootId, targets, botUserIds, +}: { + podId: string; + content: string; + threadRootId: number | null; + targets: Target[]; + botUserIds: Map; +}): Promise> { + const addressed = new Set(); + if (threadRootId) { + const [authors, claimants] = await Promise.all([ + threadAuthorIds(threadRootId) as Promise>, + MessageClaimService.liveClaimantsInThread(podId, threadRootId) as Promise>, + ]); + for (const target of targets) { + const targetKey = key(target); + if (authors.has(botUserIds.get(targetKey) || '') || claimants.has(targetKey)) { + addressed.add(targetKey); + } + } + } + + const ids = taskRefs(content); + const prs = prRefs(content); + if (ids.length === 0 && prs.size === 0) return addressed; + const selectors: Array> = []; + if (ids.length) selectors.push({ taskId: { $in: ids } }); + if (prs.size) selectors.push({ + prUrl: { $regex: `/pull/(?:${[...prs].join('|')})(?:[/?#]|$)` }, + }); + const rows = await Task.find({ + podId, + status: { $in: ['pending', 'claimed'] }, + $or: selectors, + }).select('taskId assignee claimedBy claimExpiresAt status prUrl').lean() as Array<{ + taskId: string; assignee?: string | null; claimedBy?: string | null; + claimExpiresAt?: Date | null; status: string; prUrl?: string | null; + }>; + const now = Date.now(); + for (const row of rows) { + if (!ids.includes(row.taskId) && !prs.has(prNumber(row.prUrl) || '')) continue; + for (const target of targets) { + const targetKey = key(target); + const agentName = String(target.agentName || '').toLowerCase(); + const assignee = String(row.assignee || '').replace(/^@/, '').toLowerCase(); + const liveClaim = row.status === 'claimed' + && (!row.claimExpiresAt || new Date(row.claimExpiresAt).getTime() >= now) + && String(row.claimedBy || '') === (botUserIds.get(targetKey) || ''); + if (assignee === agentName || liveClaim) addressed.add(targetKey); + } + } + return addressed; +} + +export const messageReferenceMatchers = { taskRefs, prRefs, prNumber }; diff --git a/backend/services/threadWakeScopeService.ts b/backend/services/threadWakeScopeService.ts index d97754ef20..eab4ccd2b9 100644 --- a/backend/services/threadWakeScopeService.ts +++ b/backend/services/threadWakeScopeService.ts @@ -78,6 +78,15 @@ export async function effectiveFollowerIds(threadRootId: number): Promise String(r.user_id))); } +/** Authorship is an addressed cue; following alone is not. */ +export async function threadAuthorIds(threadRootId: number): Promise> { + const { rows } = await (pool as PgPool).query( + 'SELECT DISTINCT user_id FROM messages WHERE thread_root_id = $1 OR id = $1', + [threadRootId], + ); + return new Set(rows.map((row) => String(row.user_id))); +} + /** * Narrow an already-computed wake target list to the thread's effective * followers. Returns the input unchanged when the message is not threaded, so @@ -112,5 +121,5 @@ export async function narrowToThread( }); } -module.exports = { effectiveFollowerIds, narrowToThread }; +module.exports = { effectiveFollowerIds, threadAuthorIds, narrowToThread }; Object.assign(module.exports, exports); diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 3ae8ae0b40..86c9f18081 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -302,6 +302,53 @@ describe('performRun', () => { ); }); + test('a wake carries compact digest context without claiming ambient messages', async () => { + const events = [ + makeEvent({ _id: 'evt-wake', type: 'chat.mention', payload: { content: 'please review' } }), + makeEvent({ _id: 'evt-digest', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'daily update', username: 'alice', messageId: '42' } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: 2 }; + }); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'reviewed' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 1 ambient update'); + expect(spawn.mock.calls[0][0]).toContain('- alice (message 42): daily update'); + expect(spawn.mock.calls[0][0]).not.toContain('Event 2/2: message.posted'); + expect(post.mock.calls.filter(([route]) => route.endsWith('/claim'))).toHaveLength(0); + expect(ackPosts(post)).toHaveLength(2); + }); + + test('a digest-only page never starts a turn or acknowledges the ambient event', async () => { + const event = makeEvent({ deliveryMode: 'digest', type: 'message.posted', + payload: { content: 'ambient', messageId: '42' } }); + const get = jest.fn(async () => ({ events: [event], inboxCount: 1 })); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'should not run' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + expect(spawn).not.toHaveBeenCalled(); + expect(ackPosts(post)).toHaveLength(0); + }); + test('a lost binding item stays read-only context while another batch item may run', async () => { const events = [ makeEvent({ diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 1ed0b44883..e5b7216522 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -463,7 +463,10 @@ const DEFERRABLE_SOLO_EVENT_TYPES = new Set(['first_contact']); // Inside one batch the person's item leads the prompt; the greeting follows it. const PERSON_WAITING_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']); -const inboxPriority = (event) => (PERSON_WAITING_EVENT_TYPES.has(event?.type) ? 0 : 1); +const inboxPriority = (event) => { + if (event?.deliveryMode === 'digest') return 2; + return PERSON_WAITING_EVENT_TYPES.has(event?.type) ? 0 : 1; +}; // Consultations have a per-request private response channel, rather than a // pod-chat delivery. They stay on their existing single-event path; every @@ -1506,7 +1509,7 @@ export const performRun = ({ // own conversation (Sage stood down twice on Anvil's thread // replies, 2026-08-24). Interim for TASK-058. const repliesToThisSeat = event.payload?.repliesToYourMessage === true; - if (ADDRESSED_EVENT_TYPES.has(event.type) || repliesToThisSeat) { + if (ADDRESSED_EVENT_TYPES.has(event.type) || event.payload?.addressed === true || repliesToThisSeat) { log( `[${event.type}] message ${claimMessageId} held by ${claim.holder} — ` + (repliesToThisSeat @@ -2008,10 +2011,12 @@ export const performRun = ({ // pull, rather than being silently consumed here. const buildInboxPrompt = (entries, inboxCount = entries.length) => { const BODY_LIMIT = batchLimit; - const bodies = entries.slice(0, BODY_LIMIT).map((entry, index) => { + const wakeEntries = entries.filter((entry) => entry.event.deliveryMode !== 'digest'); + const digestEntries = entries.filter((entry) => entry.event.deliveryMode === 'digest'); + const bodies = wakeEntries.slice(0, BODY_LIMIT).map((entry, index) => { const messageId = entry.event.payload?.messageId; const prefix = [ - `Event ${index + 1}/${entries.length}: ${entry.event.type}`, + `Event ${index + 1}/${wakeEntries.length}: ${entry.event.type}`, messageId ? `message ${messageId}` : null, ].filter(Boolean).join(' — '); const claimContext = entry.readOnly @@ -2021,11 +2026,21 @@ export const performRun = ({ const body = entry.prompt || '[No usable prompt was delivered for this item.]'; return `${prefix}\n${claimContext}${body}`; }); - const omitted = Math.max(0, inboxCount - bodies.length); + const digestLines = digestEntries.slice(-8).map((entry) => { + const who = String(entry.event.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); + const body = String(entry.event.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); + const id = entry.event.payload?.messageId; + return `- ${who}${id ? ` (message ${id})` : ''}: ${body}`; + }); + const omitted = Math.max(0, inboxCount - entries.length); return [ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. ` + 'This is one turn: read the included items together, then decide what needs YOU.]', ...bodies, + ...(digestEntries.length > 0 ? [ + `[Since you last looked: ${digestEntries.length} ambient update${digestEntries.length === 1 ? '' : 's'}; context only, no reply needed.] +${digestLines.join('\n')}${digestEntries.length > 8 ? `\n- ${digestEntries.length - 8} earlier updates omitted` : ''}`, + ] : []), ...(omitted > 0 ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`] : []), @@ -2172,7 +2187,9 @@ export const performRun = ({ const trigger = triggers.includes('human') ? 'human' : (triggers.includes('agent') ? 'agent' : 'unknown'); if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger); - const admissionEvent = activeEntries.find((entry) => ADDRESSED_EVENT_TYPES.has(entry.event.type)) + const admissionEvent = activeEntries.find((entry) => ( + ADDRESSED_EVENT_TYPES.has(entry.event.type) || entry.event.payload?.addressed === true + )) || activeEntries[0]; if (admissionEvent) { const admission = cascadeGovernor.admit( @@ -2190,7 +2207,8 @@ export const performRun = ({ } const bindingEntries = activeEntries.filter((entry) => ( - entry.event.type === 'message.posted' && entry.event.payload?.messageId + entry.event.deliveryMode !== 'digest' + && entry.event.type === 'message.posted' && entry.event.payload?.messageId )); if (bindingEntries.length > 0) { const yieldMs = claimHandicap.yieldDelayMs(eventPodId); @@ -2201,6 +2219,7 @@ export const performRun = ({ } for (const entry of activeEntries) { + if (entry.event.deliveryMode === 'digest') continue; const messageId = entry.event.payload?.messageId; if (!messageId || !CLAIMABLE_EVENT_TYPES.has(entry.event.type)) continue; const claimKeeper = createClaimKeeper(client, { @@ -2224,7 +2243,7 @@ export const performRun = ({ // `message.posted`. Preserve the existing ADR-018/TASK-058 // peer-aware exception; D3a otherwise partitions lost bindings as // read-only so a generic broadcast cannot widen itself after CAS. - if (entry.event.payload?.repliesToYourMessage === true) { + if (entry.event.payload?.repliesToYourMessage === true || entry.event.payload?.addressed === true) { entry.peerFrame = peerHoldsFrame(claim.holder, messageId); continue; } @@ -2392,6 +2411,12 @@ export const performRun = ({ } for (const group of eventGroups.values()) { if (!running) break; + // A current kernel never claims a digest-only page. Guard a mixed + // version rollout too: an ambient batch must not start a model turn. + if (group.every((event) => event.deliveryMode === 'digest')) { + log(`[inbox.batch] digest-only page (${group.length}) — no spawn or ack`); + continue; + } if ((group[0].podId || podId) && !PRIVATE_RESPONSE_EVENT_TYPES.has(group[0].type)) { // Nothing in this batch but the seat introducing itself: leave it // unacked for one interval so a person who mentions the agent in that From b84d2cce93e2aa58b62abe50b1fdd8f5ff020380 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:02 -0700 Subject: [PATCH 02/28] Add unread-only digest heartbeat and protect native dispatch Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.lifecycle.test.js | 17 +++++++ .../schedulerService.heartbeatLease.test.js | 29 +++++++++++ backend/models/AgentEvent.ts | 2 + backend/services/agentEventService.ts | 10 ++-- backend/services/schedulerService.ts | 49 ++++++++++++++++++- 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index cc5e1ab410..c19f10209c 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -448,6 +448,23 @@ describe('driver paths reach a terminal state', () => { await flush(); }; + test('a digest for a native installation remains pending without starting a turn', async () => { + AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve(nativeInstall) }); + AgentEvent.create.mockResolvedValue({ + _id: 'evt-digest', agentName: 'clerk', instanceId: 'default', podId: POD_P1, + type: 'message.posted', payload: {}, status: 'pending', deliveryMode: 'digest', + }); + + await AgentEventService.enqueue({ + agentName: 'clerk', instanceId: 'default', podId: POD_P1, + type: 'message.posted', payload: {}, deliveryMode: 'digest', + }); + + expect(AgentEvent.create).toHaveBeenCalledWith(expect.objectContaining({ deliveryMode: 'digest' })); + expect(AgentEvent.create.mock.calls[0][0]).not.toHaveProperty('status'); + expect(runAgent).not.toHaveBeenCalled(); + }); + test('a podId shaped like a query operator is never looked up or run in-process', async () => { AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve(nativeInstall) }); AgentEvent.create.mockResolvedValue({ _id: 'evt-x', agentName: 'clerk', instanceId: 'default', type: 'chat.mention', payload: {} }); diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index 5a5d57f324..9f7072dea8 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -11,6 +11,9 @@ const { } = require('../../utils/testUtils'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const SchedulerHeartbeatLease = require('../../../models/SchedulerHeartbeatLease'); +const AgentEvent = require('../../../models/AgentEvent'); +const AgentEventService = require('../../../services/agentEventService'); +const mongoose = require('mongoose'); const schedulerServiceInstance = require('../../../services/schedulerService'); const SchedulerService = schedulerServiceInstance.constructor; @@ -96,4 +99,30 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { }); expect(AgentInstallation.find).toHaveBeenCalledWith({ status: 'active' }); }); + + it('only wakes a pod with digest unread for 60 minutes and no recent or pending wake', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: new Date(NOW.getTime() - 61 * 60000) }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ + ...scope, type: 'heartbeat', payload: expect.objectContaining({ trigger: 'unread-digest' }), + })); + + enqueue.mockClear(); + await AgentEvent.collection.insertOne({ ...scope, type: 'chat.mention', status: 'pending', + deliveryMode: 'wake', attempts: 0, createdAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentEvent.deleteMany({ ...scope, type: 'chat.mention' }); + await AgentEvent.collection.insertOne({ ...scope, type: 'chat.mention', status: 'acked', + deliveryMode: 'wake', attempts: 1, deliveredAt: new Date(NOW.getTime() - 30 * 60000), createdAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/backend/models/AgentEvent.ts b/backend/models/AgentEvent.ts index 9ba0f3d1a5..08121a1967 100644 --- a/backend/models/AgentEvent.ts +++ b/backend/models/AgentEvent.ts @@ -143,6 +143,8 @@ const AgentEventSchema = new Schema( ); AgentEventSchema.index({ agentName: 1, instanceId: 1, status: 1, createdAt: 1 }); +AgentEventSchema.index({ status: 1, deliveryMode: 1, createdAt: 1 }); +AgentEventSchema.index({ agentName: 1, instanceId: 1, podId: 1, status: 1, deliveryMode: 1, createdAt: 1 }); // ADR-024 D3 reads one pod-scoped inbox page after finding the global head. // This keeps that bounded page and its true-count query from scanning pending // work in every other pod for the same agent installation. diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index f0144d44ec..84a164a626 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -926,6 +926,7 @@ class AgentEventService { const AgentRun = require('../models/AgentRun'); const stuck = await AgentEvent.find({ status: 'delivered', + deliveryMode: { $ne: 'digest' }, deliveredAt: { $lt: threshold }, $or: [{ attempts: { $lt: attemptCap } }, { attempts: { $exists: false } }], }).sort({ deliveredAt: 1 }).limit(STUCK_REDISPATCH_BATCH).lean() as EventDoc[]; @@ -1031,7 +1032,7 @@ class AgentEventService { const targetFilter = { $or: targets }; const ceiling = new Date(now.getTime() - STRANDED_PENDING_MAX_AGE_MS); const closedResult = await AgentEvent.updateMany( - { status: 'pending', createdAt: { $lt: ceiling }, ...targetFilter }, + { status: 'pending', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: ceiling }, ...targetFilter }, { $set: { status: 'failed', @@ -1042,6 +1043,7 @@ class AgentEventService { ) as { modifiedCount?: number }; const stranded = await AgentEvent.find({ status: 'pending', + deliveryMode: { $ne: 'digest' }, createdAt: { $lt: new Date(now.getTime() - STRANDED_PENDING_MIN_AGE_MS), $gte: ceiling, @@ -1439,7 +1441,9 @@ class AgentEventService { (lookupErr as Error).message, ); } - const routedToNative = Boolean(nativeInstallation); + // A digest is data for a later turn, not a turn on its own. Native + // installations must leave it pending just like external installations. + const routedToNative = Boolean(nativeInstallation) && deliveryMode === 'wake'; const event = await AgentEvent.create({ agentName: agentName.toLowerCase(), @@ -1617,7 +1621,7 @@ class AgentEventService { inboxCount = await AgentEvent.countDocuments(batchQuery); } - const wakeQuery = onePod ? { ...batchQuery, deliveryMode: { $ne: 'digest' } } : batchQuery; + const wakeQuery = { ...batchQuery, deliveryMode: { $ne: 'digest' } }; const wakeCandidates = await AgentEvent.find(wakeQuery) .sort({ createdAt: 1 }) .limit(limit) diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index b90bf8b011..a7c752cae2 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -1120,7 +1120,54 @@ class SchedulerService { } const result = await SchedulerService.dispatchAgentHeartbeats({ ...options, now }); - return { ...result, skippedByLease: false }; + const unreadWakes = await SchedulerService.dispatchUnreadDigestHeartbeats(now); + return { ...result, enqueued: result.enqueued + unreadWakes, skippedByLease: false }; + } + + /** One slow wake for a pod with unread digest, never for an empty inbox. */ + static async dispatchUnreadDigestHeartbeats(now: Date = new Date()): Promise { + const threshold = new Date(now.getTime() - 60 * 60 * 1000); + const groups = await AgentEvent.aggregate([ + { $match: { status: 'pending', deliveryMode: 'digest', createdAt: { $lte: threshold } } }, + { $group: { + _id: { agentName: '$agentName', instanceId: '$instanceId', podId: '$podId' }, + } }, + ]) as Array<{ _id: { agentName: string; instanceId: string; podId: unknown } }>; + let enqueued = 0; + for (const row of groups) { + const scope = { + agentName: row._id.agentName, + instanceId: row._id.instanceId || 'default', + podId: row._id.podId, + }; + // A retired projection has no listener. Its old digest remains for GC, + // but must not create a new wake every hour. + // eslint-disable-next-line no-await-in-loop + const active = await AgentInstallation.exists({ ...scope, status: 'active' }); + if (!active) continue; + // An existing wake will carry the digest. A recent completed wake says + // the seat has already had a turn inside this 60-minute window. + // eslint-disable-next-line no-await-in-loop + const [pendingWake, recentWake] = await Promise.all([ + AgentEvent.exists({ ...scope, status: 'pending', deliveryMode: { $ne: 'digest' } }), + AgentEvent.exists({ ...scope, status: { $in: ['delivered', 'acked'] }, + deliveryMode: { $ne: 'digest' }, deliveredAt: { $gte: threshold } }), + ]); + if (pendingWake || recentWake) continue; + // eslint-disable-next-line no-await-in-loop + await AgentEventService.enqueue({ + ...scope, + type: 'heartbeat', + payload: { + trigger: 'unread-digest', + podId: String(scope.podId), + generatedAt: now.toISOString(), + content: '[Unread pod activity is waiting. Read the digest in this inbox batch; respond only if useful.]', + }, + }); + enqueued += 1; + } + return enqueued; } static async dispatchAgentHeartbeats( From c74fe86b816895b7260a1f9ae178c110e3672ac0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:02:47 -0700 Subject: [PATCH 03/28] Carry ambient digest through native turns and preserve unread rows Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.lifecycle.test.js | 22 +++++++++ backend/services/agentEventService.ts | 46 ++++++++++++++++--- cli/src/commands/agent.js | 9 ++-- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index c19f10209c..9834655b44 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -333,6 +333,7 @@ describe('garbageCollect: the requeue predicate and its cap', () => { // Same instant, not merely "both long": a shorter pending horizon is exactly // what let the sweep outrun the retry lifecycle. expect(pending.createdAt.$lt.getTime()).toBe(settled.createdAt.$lt.getTime()); + expect(pending.deliveryMode).toEqual({ $ne: 'digest' }); }); test('no delete predicate can destroy a pending event inside the retry window', async () => { @@ -465,6 +466,27 @@ describe('driver paths reach a terminal state', () => { expect(runAgent).not.toHaveBeenCalled(); }); + test('a later native wake carries digest context and settles only included rows on success', async () => { + const digest = { _id: 'evt-d', payload: { username: 'alice', messageId: '42', content: 'daily update' } }; + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); + runAgent.mockResolvedValue({ status: 'succeeded' }); + + await enqueueNative(); + + expect(runAgent.mock.calls[0][1].payload.content).toContain('alice (message 42): daily update'); + expect(AgentEvent.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['evt-d'] }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked' } }, + ); + + jest.clearAllMocks(); + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + runAgent.mockResolvedValue({ status: 'failed' }); + await enqueueNative(); + expect(AgentEvent.updateMany).not.toHaveBeenCalled(); + }); + test('a podId shaped like a query operator is never looked up or run in-process', async () => { AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve(nativeInstall) }); AgentEvent.create.mockResolvedValue({ _id: 'evt-x', agentName: 'clerk', instanceId: 'default', type: 'chat.mention', payload: {} }); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 84a164a626..85044b60e0 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -882,7 +882,11 @@ class AgentEventService { // (routes/admin/agentEvents.ts), where "pending for over 30 minutes" is a // useful thing to look at — and is now actionable, because seeing it no // longer means the row is about to be destroyed. - AgentEvent.deleteMany({ status: 'pending', createdAt: { $lt: deliveredThreshold } }), + // Digest rows are unread state. Retiring them without folding into a + // durable per-seat summary would silently erase the notification. + AgentEvent.deleteMany({ + status: 'pending', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: deliveredThreshold }, + }), AgentEvent.deleteMany({ status: { $in: ['delivered', 'acked'] }, createdAt: { $lt: deliveredThreshold } }), AgentEvent.deleteMany({ status: 'failed', createdAt: { $lt: failedThreshold } }), ]) as Array<{ deletedCount?: number }>; @@ -1359,17 +1363,47 @@ class AgentEventService { // stayed 'delivered' forever and was structurally unackable. Doing it // here rather than inside runAgent keeps the terminal transition next // to the non-terminal state that created it. - Promise.resolve(runAgent(nativeInstallation, { - type, - eventId: eventIdStr, - payload: event.payload, + const runWithDigest = async () => { + let digest: EventDoc[] = []; + try { + digest = await AgentEvent.find({ + agentName: agentName.toLowerCase(), instanceId, podId: event.podId, + status: 'pending', deliveryMode: 'digest', + }).sort({ createdAt: 1 }).limit(8).lean() as EventDoc[]; + } catch (digestErr) { + console.warn('[native-runtime] digest lookup failed:', (digestErr as Error).message); + } + const lines = digest.map((item) => { + const who = String(item.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); + const content = String(item.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); + const messageId = item.payload?.messageId; + return `- ${who}${messageId ? ` (message ${messageId})` : ''}: ${content}`; + }); + const baseContent = String(event.payload?.content || event.payload?.prompt || event.payload?.text || ''); + const payload = lines.length ? { + ...event.payload, + content: `${baseContent}\n\n[Since you last looked: ${lines.length} ambient updates; context only.]\n${lines.join('\n')}`, + } : event.payload; + const result = await runAgent(nativeInstallation, { type, eventId: eventIdStr, payload }); + // Leave digest unread when the native turn fails. On a successful turn, + // mark only the rows actually included; older backlog remains pending. + if (result?.status === 'succeeded' && digest.length) { + await AgentEvent.updateMany( + { _id: { $in: digest.map((item) => item._id) }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked' } }, + ).catch((digestErr: Error) => { + console.warn('[native-runtime] digest settle failed:', digestErr.message); + }); + } + return result; + }; + Promise.resolve(runWithDigest()).then( // Two-argument .then, deliberately, NOT .then().catch(): a chained // .catch would also catch a rejection thrown by the success handler, // so a failing acknowledge() would fall through and mark a run that // actually succeeded as 'failed'. The rejection handler here is scoped // to runAgent alone. Both terminal calls swallow their own errors so // this stays fire-and-forget for the caller. - })).then( () => this.acknowledge( eventIdStr, agentName, diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index e5b7216522..fe6c6dd830 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2183,14 +2183,17 @@ ${digestLines.join('\n')}${digestEntries.length > 8 ? `\n- ${digestEntries.lengt ? new Set(preSpawn.map((message) => String(message._id || message.id))) : null; const activeEntries = entries.filter((entry) => !entry.result); - const triggers = activeEntries.map((entry) => classifyTrigger(entry.event, preSpawn)); + // Ambient digest entries provide context for this turn, but must never + // determine its admission or count as the trigger that woke the seat. + const wakingEntries = activeEntries.filter((entry) => entry.event.deliveryMode !== 'digest'); + const triggers = wakingEntries.map((entry) => classifyTrigger(entry.event, preSpawn)); const trigger = triggers.includes('human') ? 'human' : (triggers.includes('agent') ? 'agent' : 'unknown'); if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger); - const admissionEvent = activeEntries.find((entry) => ( + const admissionEvent = wakingEntries.find((entry) => ( ADDRESSED_EVENT_TYPES.has(entry.event.type) || entry.event.payload?.addressed === true )) - || activeEntries[0]; + || wakingEntries[0]; if (admissionEvent) { const admission = cascadeGovernor.admit( eventPodId, From d4a55a1ab38d11c31a704a955faa57038c5b5750 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:42 -0700 Subject: [PATCH 04/28] Fold expired digests into durable unread state Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../services/agentDigestFoldService.test.js | 126 +++++++++++++++++ .../agentEventService.deliveryNonce.test.js | 17 +++ .../agentEventService.lifecycle.test.js | 6 + .../unit/services/agentEventService.test.js | 20 ++- .../schedulerService.heartbeatLease.test.js | 14 ++ backend/models/AgentDigestState.ts | 29 ++++ backend/models/AgentEvent.ts | 8 +- backend/services/agentDigestFoldService.ts | 130 ++++++++++++++++++ backend/services/agentEventService.ts | 84 +++++++++-- backend/services/schedulerService.ts | 8 +- cli/__tests__/run-loop.test.mjs | 7 +- cli/src/commands/agent.js | 14 +- 12 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 backend/__tests__/unit/services/agentDigestFoldService.test.js create mode 100644 backend/models/AgentDigestState.ts create mode 100644 backend/services/agentDigestFoldService.ts diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js new file mode 100644 index 0000000000..1033c8ff7e --- /dev/null +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -0,0 +1,126 @@ +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); +const AgentEvent = require('../../../models/AgentEvent'); +const AgentDigestState = require('../../../models/AgentDigestState'); +const AgentEventService = require('../../../services/agentEventService'); +const { foldExpiredDigests, readFoldedDigest, settleFoldedDigest } = require('../../../services/agentDigestFoldService'); + +const NOW = new Date('2026-10-23T23:00:00.000Z'); +const THRESHOLD = new Date(NOW.getTime() - 60 * 60 * 1000); + +describe('expired digest fold', () => { + beforeAll(async () => { + await setupMongoDb(); + await AgentDigestState.init(); + }); + afterAll(closeMongoDb); + afterEach(async () => { + jest.restoreAllMocks(); + await clearMongoDb(); + }); + + const seed = async (createdAt, podId) => AgentEvent.collection.insertOne({ + agentName: 'flint', instanceId: 'default', podId, + type: 'message.posted', deliveryMode: 'digest', status: 'pending', + attempts: 0, createdAt, updatedAt: createdAt, + }); + + it('retains unread count and last IDs while deleting expired full events', async () => { + const podId = new mongoose.Types.ObjectId(); + const first = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const second = await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + const recent = await seed(NOW, podId); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(2); + expect(state.lastEventIds).toEqual([String(first.insertedId), String(second.insertedId)]); + expect(await AgentEvent.exists({ _id: recent.insertedId })).toBeTruthy(); + expect(await AgentEvent.countDocuments({ podId })).toBe(1); + }); + + it('does not count a row twice after a crash between state update and delete', async () => { + const podId = new mongoose.Types.ObjectId(); + const event = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const deleteOne = jest.spyOn(AgentEvent, 'deleteOne').mockRejectedValueOnce(new Error('lost connection')); + await expect(foldExpiredDigests(THRESHOLD)).rejects.toThrow('lost connection'); + deleteOne.mockRestore(); + expect(await AgentEvent.findById(event.insertedId).lean()).toMatchObject({ status: 'folding' }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(1); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(1); + expect(state.lastEventIds).toEqual([String(event.insertedId)]); + }); + + it('counts each expired row once when two GC workers overlap', async () => { + const podId = new mongoose.Types.ObjectId(); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + + const deleted = await Promise.all([foldExpiredDigests(THRESHOLD), foldExpiredDigests(THRESHOLD)]); + expect(deleted[0] + deleted[1]).toBe(2); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(2); + expect(await AgentEvent.countDocuments({ podId })).toBe(0); + }); + + it('does not advance the cursor past an older row another worker claimed', async () => { + const podId = new mongoose.Types.ObjectId(); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + const claim = jest.spyOn(AgentEvent, 'findOneAndUpdate').mockResolvedValueOnce(null); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(0); + expect(await AgentDigestState.findOne({ agentName: 'flint', podId })).toBeNull(); + claim.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + }); + + it('waits when an older ID has a newer retention timestamp', async () => { + const podId = new mongoose.Types.ObjectId(); + const olderId = await seed(NOW, podId); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(0); + expect(await AgentDigestState.findOne({ agentName: 'flint', podId })).toBeNull(); + await AgentEvent.collection.updateOne( + { _id: olderId.insertedId }, + { $set: { createdAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000) } }, + ); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + }); + + it('settles the exact seen revision while preserving a newer fold', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + const first = await readFoldedDigest(scope); + expect(first).toMatchObject({ unreadCount: 1, revision: 1 }); + + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + await settleFoldedDigest(scope, first.revision); + expect((await readFoldedDigest(scope)).unreadCount).toBe(2); + + const latest = await readFoldedDigest(scope); + await settleFoldedDigest(scope, latest.revision); + expect(await readFoldedDigest(scope)).toBeNull(); + expect((await AgentDigestState.findOne(scope).lean()).revision).toBe(3); + }); + + it('attaches the folded summary to a real wake and clears it on that wake ack', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + const wake = await AgentEvent.create({ ...scope, type: 'heartbeat', + status: 'delivered', deliveryMode: 'wake', attempts: 1, deliveryNonce: 'nonce-1' }); + + expect(await AgentEventService.attachFoldedDigest(wake)).toMatchObject({ unreadCount: 1 }); + expect((await AgentEvent.findById(wake._id).lean()).digestRollupRevision).toBe(1); + await AgentEventService.acknowledge(wake._id, 'flint', 'default', { outcome: 'no_action' }, 'nonce-1', 'cli'); + expect(await readFoldedDigest(scope)).toBeNull(); + }); +}); diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 69ace026ec..636722a73a 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -483,6 +483,13 @@ describe('the sweep re-runs a stuck in-process event', () => { const event = await AgentEventService.enqueue({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, type: 'chat.mention', payload: { text: 'hi' }, }); + // Native dispatch now gathers pending ambient digest before calling the + // runner. Wait for that asynchronous read so it cannot leak into the next + // test or make an immediate runAgent assertion race the database. + for (let i = 0; i < 50 && runAgent.mock.calls.length === 0; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } const born = await AgentEvent.findById(event._id).lean(); return { event, born, settles }; }; @@ -523,6 +530,11 @@ describe('the sweep re-runs a stuck in-process event', () => { AgentEventService.redispatchStuckInProcess(threshold, 3), ]); + for (let i = 0; i < 50 && runAgent.mock.calls.length < 2; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(results.reduce((sum, r) => sum + r.redispatched, 0)).toBe(1); expect(runAgent).toHaveBeenCalledTimes(2); // the original turn plus one re-run }); @@ -720,6 +732,11 @@ describe('the sweep runs a pending event stranded by a move in-process', () => { AgentEventService.rescueStrandedPendingInProcess(new Date(), 3), ]); + for (let i = 0; i < 50 && runAgent.mock.calls.length === 0; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(counts[0].rescued + counts[1].rescued).toBe(1); expect(runAgent).toHaveBeenCalledTimes(1); }); diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 9834655b44..56fe22dfb4 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -33,6 +33,12 @@ jest.mock('../../../models/AgentEvent', () => ({ deleteMany: jest.fn(), })); +jest.mock('../../../services/agentDigestFoldService', () => ({ + foldExpiredDigests: jest.fn().mockResolvedValue(0), + readFoldedDigest: jest.fn().mockResolvedValue(null), + settleFoldedDigest: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn(), updateOne: jest.fn(), diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 872a7e4da2..72919e5711 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -2,9 +2,16 @@ jest.mock('../../../models/AgentEvent', () => ({ create: jest.fn(), countDocuments: jest.fn(), findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), find: jest.fn(), })); +jest.mock('../../../services/agentDigestFoldService', () => ({ + foldExpiredDigests: jest.fn().mockResolvedValue(0), + readFoldedDigest: jest.fn().mockResolvedValue(null), + settleFoldedDigest: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn(), updateOne: jest.fn(), @@ -389,6 +396,7 @@ describe('AgentEventService', () => { test('digest-only inbox stays pending; a later wake claims it in the same pod', async () => { const AgentMemory = require('../../../models/AgentMemory'); + const { readFoldedDigest } = require('../../../services/agentDigestFoldService'); const query = (rows) => ({ sort: () => ({ limit: () => ({ select: () => ({ lean: jest.fn().mockResolvedValue(rows) }) }) }), }); @@ -407,15 +415,20 @@ describe('AgentEventService', () => { }); AgentEvent.findOneAndUpdate .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ - _id: 'wake', podId: 'pod-a', type: 'chat.mention', status: 'delivered', payload: {}, + _id: 'wake', agentName: 'inbox', instanceId: 'default', podId: 'pod-a', + type: 'chat.mention', status: 'delivered', payload: {}, }) }) .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ _id: 'digest', podId: 'pod-a', type: 'message.posted', deliveryMode: 'digest', status: 'delivered', payload: { messageId: '42' }, }) }); + readFoldedDigest.mockResolvedValueOnce({ unreadCount: 3, lastEventIds: ['older-1'], revision: 4 }); + AgentEvent.updateOne.mockResolvedValueOnce({ modifiedCount: 1 }); const page = await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' }); expect(page.events.map((event) => event._id)).toEqual(['wake', 'digest']); expect(page.inboxCount).toBe(2); + expect(page.events[0].payload.digestRollup).toEqual({ unreadCount: 3, lastEventIds: ['older-1'] }); + expect(page.events[1].payload.digestRollup).toBeUndefined(); expect(AgentEvent.find).toHaveBeenNthCalledWith(3, expect.objectContaining({ podId: 'pod-a', deliveryMode: { $ne: 'digest' }, })); @@ -569,6 +582,7 @@ describe('AgentEventService', () => { test('acknowledge: status-gated delivered → acked + bumps lastSeenRevision via $max', async () => { const AgentMemory = require('../../../models/AgentMemory'); + const { settleFoldedDigest } = require('../../../services/agentDigestFoldService'); AgentIdentityService.getAgentTypeConfig.mockReturnValue({ runtime: 'moltbot' }); AgentEvent.findOneAndUpdate.mockResolvedValue({ _id: 'evt-1', @@ -580,6 +594,7 @@ describe('AgentEventService', () => { status: 'acked', attempts: 1, memoryRevisionAtDelivery: 7, + digestRollupRevision: 4, }); await AgentEventService.acknowledge('evt-1', 'pixel', 'default', { outcome: 'no_action', reason: 'nothing-to-do' }); @@ -601,6 +616,9 @@ describe('AgentEventService', () => { { agentName: 'pixel', instanceId: 'default' }, { $max: { lastSeenRevision: 7 } }, ); + expect(settleFoldedDigest).toHaveBeenCalledWith( + { agentName: 'pixel', instanceId: 'default', podId: 'pod-1' }, 4, + ); }); test('acknowledge: dup ack returns null without bumping lastSeenRevision', async () => { diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index 9f7072dea8..c86361dbe7 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -12,6 +12,7 @@ const { const { AgentInstallation } = require('../../../models/AgentRegistry'); const SchedulerHeartbeatLease = require('../../../models/SchedulerHeartbeatLease'); const AgentEvent = require('../../../models/AgentEvent'); +const AgentDigestState = require('../../../models/AgentDigestState'); const AgentEventService = require('../../../services/agentEventService'); const mongoose = require('mongoose'); const schedulerServiceInstance = require('../../../services/schedulerService'); @@ -125,4 +126,17 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); expect(enqueue).not.toHaveBeenCalled(); }); + + it('also wakes for unread digest folded past full-event retention', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + await AgentDigestState.collection.insertOne({ ...scope, unreadCount: 2, revision: 2, + lastEventIds: ['old-1', 'old-2'], updatedAt: new Date(NOW.getTime() - 61 * 60000) }); + + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + expect(enqueue).toHaveBeenCalledTimes(1); + }); }); diff --git a/backend/models/AgentDigestState.ts b/backend/models/AgentDigestState.ts new file mode 100644 index 0000000000..2255207157 --- /dev/null +++ b/backend/models/AgentDigestState.ts @@ -0,0 +1,29 @@ +import mongoose, { Document, Model, Schema, Types } from 'mongoose'; + +/** Unread ambient events folded out of the full AgentEvent retention window. */ +export interface IAgentDigestState extends Document { + agentName: string; + instanceId: string; + podId: Types.ObjectId; + unreadCount: number; + revision: number; + lastEventIds: string[]; + foldedThroughId?: Types.ObjectId | null; + updatedAt: Date; +} + +const schema = new Schema({ + agentName: { type: String, required: true, lowercase: true }, + instanceId: { type: String, required: true, default: 'default' }, + podId: { type: Schema.Types.ObjectId, required: true, ref: 'Pod' }, + unreadCount: { type: Number, required: true, default: 0 }, + revision: { type: Number, required: true, default: 0 }, + lastEventIds: { type: [String], default: [] }, + foldedThroughId: { type: Schema.Types.ObjectId, default: null }, +}, { timestamps: true }); + +schema.index({ agentName: 1, instanceId: 1, podId: 1 }, { unique: true }); + +const AgentDigestState: Model = mongoose.model('AgentDigestState', schema); +export default AgentDigestState; +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/models/AgentEvent.ts b/backend/models/AgentEvent.ts index 08121a1967..1e6d5183ba 100644 --- a/backend/models/AgentEvent.ts +++ b/backend/models/AgentEvent.ts @@ -6,7 +6,7 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; // delivered → failed (on delivery-side terminal error; rare) // Existing rows without an `'acked'` value continue to read as `'delivered'` // indefinitely; the ack-gate below short-circuits if status is already `'acked'`. -export type AgentEventStatus = 'pending' | 'delivered' | 'acked' | 'failed'; +export type AgentEventStatus = 'pending' | 'folding' | 'delivered' | 'acked' | 'failed'; export type AgentEventDeliveryMode = 'wake' | 'digest'; export type DeliveryOutcome = 'acknowledged' | 'posted' | 'no_action' | 'skipped' | 'error'; @@ -110,6 +110,9 @@ export interface IAgentEvent extends Document { // superseded child's late ack fail instead of terminating the new child's // delivery. `null` for events claimed before D6 shipped. deliveryNonce?: string | null; + // Snapshot of the folded digest state included in this wake. Ack clears it + // only if no newer fold changed the state while the turn was running. + digestRollupRevision?: number | null; createdAt: Date; updatedAt: Date; } @@ -121,13 +124,14 @@ const AgentEventSchema = new Schema( podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true, index: true }, type: { type: String, required: true }, payload: Schema.Types.Mixed, - status: { type: String, enum: ['pending', 'delivered', 'acked', 'failed'], default: 'pending' }, + status: { type: String, enum: ['pending', 'folding', 'delivered', 'acked', 'failed'], default: 'pending' }, deliveryMode: { type: String, enum: ['wake', 'digest'], default: 'wake' }, attempts: { type: Number, default: 0 }, deliveredAt: Date, error: String, memoryRevisionAtDelivery: { type: Number, default: null }, deliveryNonce: { type: String, default: null }, + digestRollupRevision: { type: Number, default: null }, delivery: { outcome: { type: String, diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts new file mode 100644 index 0000000000..73323152b5 --- /dev/null +++ b/backend/services/agentDigestFoldService.ts @@ -0,0 +1,130 @@ +import AgentEvent from '../models/AgentEvent'; +import AgentDigestState from '../models/AgentDigestState'; + +const FOLD_BATCH_SIZE = 100; +const LAST_IDS_LIMIT = 8; + +type FoldCandidate = { + _id: unknown; + agentName: string; + instanceId?: string; + podId: unknown; + status: string; +}; + +/** + * Compact expired ambient events without losing unread state. Marking an + * event `folding` first excludes a concurrent poll claim. The monotonic + * per-seat cursor makes a crash after the state update, but before deletion, + * idempotent on the next GC pass. Each pass processes IDs in ascending order. + */ +export async function foldExpiredDigests(threshold: Date): Promise { + const candidates = await AgentEvent.find({ + deliveryMode: 'digest', status: { $in: ['pending', 'folding'] }, createdAt: { $lt: threshold }, + }).sort({ _id: 1 }).limit(FOLD_BATCH_SIZE).lean() as FoldCandidate[]; + let folded = 0; + const blockedScopes = new Set(); + for (const candidate of candidates) { + const id = candidate._id; + const scope = { + agentName: candidate.agentName, + instanceId: candidate.instanceId || 'default', + podId: candidate.podId, + }; + const scopeKey = `${scope.agentName}:${scope.instanceId}:${scope.podId}`; + if (blockedScopes.has(scopeKey)) continue; + // createdAt and ObjectId order can diverge for an imported row. Never + // advance this scope's cursor beyond an earlier unread ID, even if that + // earlier row has not reached the retention horizon yet. + // eslint-disable-next-line no-await-in-loop + const olderUnread = await AgentEvent.exists({ + ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding'] }, _id: { $lt: id }, + }); + if (olderUnread) { + blockedScopes.add(scopeKey); + continue; + } + if (candidate.status === 'pending') { + // eslint-disable-next-line no-await-in-loop + const claimed = await AgentEvent.findOneAndUpdate( + { _id: id, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'folding' } }, + { new: true }, + ); + if (!claimed) { + // Another GC worker may be folding this oldest row. If we advanced to + // a later ID first, its cursor would make that older row disappear + // without ever increasing unreadCount. + blockedScopes.add(scopeKey); + continue; + } + } + const idString = String(id); + let recorded = false; + for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const state = await AgentDigestState.findOne(scope).select('foldedThroughId').lean(); + const cursor = state?.foldedThroughId; + if (cursor && String(cursor) >= idString) { + recorded = true; + break; + } + if (!state) { + try { + // eslint-disable-next-line no-await-in-loop + await AgentDigestState.create({ + ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], foldedThroughId: id, + }); + recorded = true; + } catch (err) { + if ((err as { code?: number }).code !== 11000) throw err; + } + } else { + // eslint-disable-next-line no-await-in-loop + const result = await AgentDigestState.updateOne( + { ...scope, foldedThroughId: cursor ?? null }, + { + $inc: { unreadCount: 1, revision: 1 }, + $set: { foldedThroughId: id }, + $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, + }, + ); + recorded = result.modifiedCount === 1; + } + } + if (!recorded) throw new Error(`Digest fold state contention for ${idString}`); + // If this delete fails, the next pass sees `folding` and the cursor avoids + // incrementing a second time. A delivered/acked event never enters here. + // eslint-disable-next-line no-await-in-loop + const result = await AgentEvent.deleteOne({ _id: id, status: 'folding', deliveryMode: 'digest' }); + folded += result.deletedCount || 0; + } + return folded; +} + +export async function readFoldedDigest(scope: { + agentName: string; instanceId: string; podId: unknown; +}): Promise<{ unreadCount: number; lastEventIds: string[]; revision: number } | null> { + const state = await AgentDigestState.findOne({ ...scope, unreadCount: { $gt: 0 } }) + .select('unreadCount lastEventIds revision').lean(); + if (!state) return null; + return { + unreadCount: state.unreadCount, + lastEventIds: state.lastEventIds, + revision: state.revision, + }; +} + +export async function settleFoldedDigest( + scope: { agentName: string; instanceId: string; podId: unknown }, + revision: number, +): Promise { + // A newer fold increments revision. In that race leave the old count in place: + // the next turn may see some IDs twice, but never loses an unseen event. + await AgentDigestState.updateOne( + { ...scope, revision }, + { $set: { unreadCount: 0, lastEventIds: [] }, $inc: { revision: 1 } }, + ); +} + +export default { foldExpiredDigests, readFoldedDigest, settleFoldedDigest }; diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 85044b60e0..04004de778 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import { Types } from 'mongoose'; import Task from '../models/Task'; +import { foldExpiredDigests, readFoldedDigest, settleFoldedDigest } from './agentDigestFoldService'; import { postToPublicAddress } from './outboundUrlGuard'; import { hostedRunsNative, IN_PROCESS_RUNTIME_FILTER } from './hostedEngine'; @@ -127,6 +128,7 @@ interface EventDoc { delivery?: DeliveryMeta; memoryRevisionAtDelivery?: number | null; deliveryNonce?: string | null; + digestRollupRevision?: number | null; } interface InstallationDoc { @@ -165,6 +167,7 @@ interface GarbageCollectResult { deletedDelivered: number; deletedFailed: number; totalDeleted: number; + foldedDigest?: number; deliveredRetentionHours: number; failedRetentionHours: number; requeuedDelivered?: number; @@ -860,6 +863,14 @@ class AgentEventService { // and 'acked' is the new terminal state. Both age out on the same retention // schedule — once an event is past the `delivered` threshold it's stale // whether the agent ever explicitly acked or not. + let foldedDigest = 0; + try { + foldedDigest = await foldExpiredDigests(deliveredThreshold); + } catch (err) { + // The fold is fail-closed: its `folding` rows remain retryable and the + // pending delete below excludes digest. Other event retention can run. + console.warn('[agent-event] digest fold failed:', (err as Error).message); + } const [pendingResult, deliveredResult, failedResult] = await Promise.all([ // #993: this used to run on a 30-minute `stalePendingThreshold`, which put // it AHEAD of the retry lifecycle rather than after it. The requeue pass @@ -899,7 +910,8 @@ class AgentEventService { deletedPending, deletedDelivered, deletedFailed, - totalDeleted: deletedPending + deletedDelivered + deletedFailed, + totalDeleted: deletedPending + deletedDelivered + deletedFailed + foldedDigest, + foldedDigest, deliveredRetentionHours: Math.max(deliveredRetentionHours, 1), failedRetentionHours: Math.max(failedRetentionHours, 1), requeuedDelivered, @@ -1333,11 +1345,30 @@ class AgentEventService { return installationRuntimeType === 'native' || hostedRunsNative(installationDoc) ? installationDoc : null; } - /** - * Run a claimed in-process event and settle it on the outcome: success acks, - * failure records. Fire-and-forget, so neither enqueue nor the restart sweep - * (TASK-264) ever blocks on a turn. - */ + /** Persist the folded digest snapshot carried by this waking delivery. */ + static async attachFoldedDigest(event: EventDoc): Promise<{ + unreadCount: number; lastEventIds: string[]; + } | null> { + const scope = { + agentName: String(event.agentName || '').toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + if (!scope.agentName || !scope.podId || !event._id) return null; + const state = await readFoldedDigest(scope); + if (!state) return null; + // A retry may show the same rollup. Only the winning ack with this exact + // state snapshot clears it; a newer fold must remain unread. + const updated = await AgentEvent.updateOne( + { _id: event._id, status: 'delivered', deliveryMode: { $ne: 'digest' }, + ...(event.deliveryNonce ? { deliveryNonce: event.deliveryNonce } : {}) }, + { $set: { digestRollupRevision: state.revision } }, + ); + if (!updated.modifiedCount) return null; + return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds }; + } + + /** Run a claimed native event and settle it without blocking enqueue. */ static dispatchInProcess( event: EventDoc, nativeInstallation: InstallationDoc, @@ -1364,6 +1395,10 @@ class AgentEventService { // here rather than inside runAgent keeps the terminal transition next // to the non-terminal state that created it. const runWithDigest = async () => { + const folded = await this.attachFoldedDigest(event).catch((digestErr: Error) => { + console.warn('[native-runtime] folded digest lookup failed:', digestErr.message); + return null; + }); let digest: EventDoc[] = []; try { digest = await AgentEvent.find({ @@ -1380,9 +1415,13 @@ class AgentEventService { return `- ${who}${messageId ? ` (message ${messageId})` : ''}: ${content}`; }); const baseContent = String(event.payload?.content || event.payload?.prompt || event.payload?.text || ''); - const payload = lines.length ? { + const foldedLine = folded + ? `- ${folded.unreadCount} older updates (last event IDs: ${folded.lastEventIds.join(', ')})` + : null; + const allLines = foldedLine ? [foldedLine, ...lines] : lines; + const payload = allLines.length ? { ...event.payload, - content: `${baseContent}\n\n[Since you last looked: ${lines.length} ambient updates; context only.]\n${lines.join('\n')}`, + content: `${baseContent}\n\n[Since you last looked: ambient updates; context only.]\n${allLines.join('\n')}`, } : event.payload; const result = await runAgent(nativeInstallation, { type, eventId: eventIdStr, payload }); // Leave digest unread when the native turn fails. On a successful turn, @@ -1404,7 +1443,15 @@ class AgentEventService { // actually succeeded as 'failed'. The rejection handler here is scoped // to runAgent alone. Both terminal calls swallow their own errors so // this stays fire-and-forget for the caller. - () => this.acknowledge( + (runResult: { status?: string; error?: string }) => (runResult?.status === 'failed' + ? this.recordFailure( + eventIdStr, + agentName, + instanceId, + `native runtime failed: ${runResult.error || 'turn failed'}`, + nativeDeliveryId, + ).catch(() => undefined) + : this.acknowledge( eventIdStr, agentName, instanceId, @@ -1413,7 +1460,7 @@ class AgentEventService { 'native', ).catch((ackErr: Error) => { console.warn('[native-runtime] ack after successful run failed:', ackErr?.message || ackErr); - }), + })), (err: Error) => { console.error('[native-runtime] runAgent failed:', err?.message || err); // Terminal on failure too — a native event that errored must not @@ -1734,6 +1781,13 @@ class AgentEventService { if (updated) claimed.push(updated); } + const firstWake = claimed.find((event) => event.deliveryMode !== 'digest'); + const foldedRollup = firstWake + ? await this.attachFoldedDigest(firstWake).catch((err: Error) => { + console.warn('[agent-event] folded digest attachment failed:', err.message); + return null; + }) + : null; const events = claimed.map((event) => { const messageId = event?.payload?.messageId; const basePayload = event?.payload || {}; @@ -1743,6 +1797,7 @@ class AgentEventService { const enrichedPayload: Record = { ...basePayload, ...digestBundle, + ...(foldedRollup && event._id === firstWake?._id ? { digestRollup: foldedRollup } : {}), // ADR-026 D6: the driver echoes this back on ack. Additive — a driver // that ignores it still acks successfully during the migration window // described on acknowledge(). @@ -1832,6 +1887,15 @@ class AgentEventService { { new: true }, ) as EventDoc | null; + if (result?.digestRollupRevision && normalizedDelivery?.outcome !== 'error') { + await settleFoldedDigest( + { agentName: safeAgentName, instanceId: safeInstanceId, podId: result.podId }, + result.digestRollupRevision, + ).catch((err: Error) => { + console.warn('[agent-event] folded digest settle failed:', err.message); + }); + } + // ADR-012 §3: bump lastSeenRevision exactly once per event via $max. // First-ack only: the status-gate above ensures result is non-null only // on the winning ack. memoryRevisionAtDelivery was captured at fetch diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index a7c752cae2..91e84b6fcc 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -20,6 +20,8 @@ const { AgentInstallation } = require('../models/AgentRegistry'); // eslint-disable-next-line global-require const AgentEvent = require('../models/AgentEvent'); // eslint-disable-next-line global-require +const AgentDigestState = require('../models/AgentDigestState'); +// eslint-disable-next-line global-require const SchedulerHeartbeatLease = require('../models/SchedulerHeartbeatLease'); // eslint-disable-next-line global-require const AgentEnsembleService = require('./agentEnsembleService'); @@ -1127,12 +1129,16 @@ class SchedulerService { /** One slow wake for a pod with unread digest, never for an empty inbox. */ static async dispatchUnreadDigestHeartbeats(now: Date = new Date()): Promise { const threshold = new Date(now.getTime() - 60 * 60 * 1000); - const groups = await AgentEvent.aggregate([ + const rawGroups = await AgentEvent.aggregate([ { $match: { status: 'pending', deliveryMode: 'digest', createdAt: { $lte: threshold } } }, { $group: { _id: { agentName: '$agentName', instanceId: '$instanceId', podId: '$podId' }, } }, ]) as Array<{ _id: { agentName: string; instanceId: string; podId: unknown } }>; + const foldedGroups = await AgentDigestState.find({ unreadCount: { $gt: 0 }, updatedAt: { $lte: threshold } }) + .select('agentName instanceId podId').lean() as Array<{ agentName: string; instanceId: string; podId: unknown }>; + const groups = [...new Map([...rawGroups.map((row) => row._id), ...foldedGroups] + .map((scope) => [`${scope.agentName}:${scope.instanceId}:${scope.podId}`, { _id: scope }])).values()]; let enqueued = 0; for (const row of groups) { const scope = { diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 86c9f18081..84d061e075 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -304,7 +304,9 @@ describe('performRun', () => { test('a wake carries compact digest context without claiming ambient messages', async () => { const events = [ - makeEvent({ _id: 'evt-wake', type: 'chat.mention', payload: { content: 'please review' } }), + makeEvent({ _id: 'evt-wake', type: 'chat.mention', payload: { + content: 'please review', digestRollup: { unreadCount: 3, lastEventIds: ['old-1', 'old-2'] }, + } }), makeEvent({ _id: 'evt-digest', type: 'message.posted', deliveryMode: 'digest', payload: { content: 'daily update', username: 'alice', messageId: '42' } }), ]; @@ -324,7 +326,8 @@ describe('performRun', () => { await drainMicrotasks(); stop(); expect(spawn).toHaveBeenCalledTimes(1); - expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 1 ambient update'); + expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 4 ambient updates'); + expect(spawn.mock.calls[0][0]).toContain('3 older updates (last event IDs: old-1, old-2)'); expect(spawn.mock.calls[0][0]).toContain('- alice (message 42): daily update'); expect(spawn.mock.calls[0][0]).not.toContain('Event 2/2: message.posted'); expect(post.mock.calls.filter(([route]) => route.endsWith('/claim'))).toHaveLength(0); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index fe6c6dd830..45e2bd0606 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2026,20 +2026,26 @@ export const performRun = ({ const body = entry.prompt || '[No usable prompt was delivered for this item.]'; return `${prefix}\n${claimContext}${body}`; }); - const digestLines = digestEntries.slice(-8).map((entry) => { + const rollup = wakeEntries.find((entry) => entry.event.payload?.digestRollup)?.event.payload?.digestRollup; + const visibleRawLimit = rollup?.unreadCount > 0 ? 7 : 8; + const digestLines = digestEntries.slice(-visibleRawLimit).map((entry) => { const who = String(entry.event.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); const body = String(entry.event.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); const id = entry.event.payload?.messageId; return `- ${who}${id ? ` (message ${id})` : ''}: ${body}`; }); + if (rollup?.unreadCount > 0) { + const ids = Array.isArray(rollup.lastEventIds) ? rollup.lastEventIds.slice(-8) : []; + digestLines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${ids.join(', ')})`); + } const omitted = Math.max(0, inboxCount - entries.length); return [ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. ` + 'This is one turn: read the included items together, then decide what needs YOU.]', ...bodies, - ...(digestEntries.length > 0 ? [ - `[Since you last looked: ${digestEntries.length} ambient update${digestEntries.length === 1 ? '' : 's'}; context only, no reply needed.] -${digestLines.join('\n')}${digestEntries.length > 8 ? `\n- ${digestEntries.length - 8} earlier updates omitted` : ''}`, + ...(digestEntries.length > 0 || rollup?.unreadCount > 0 ? [ + `[Since you last looked: ${digestEntries.length + (rollup?.unreadCount || 0)} ambient update${digestEntries.length + (rollup?.unreadCount || 0) === 1 ? '' : 's'}; context only, no reply needed.] +${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${digestEntries.length - visibleRawLimit} earlier updates omitted` : ''}`, ] : []), ...(omitted > 0 ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`] From 80058386a02601ea3a4f105af625b26f484ec9b3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:11:41 -0700 Subject: [PATCH 05/28] Preserve ambient receipt through dampening and GC races Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/agentDigestFoldService.test.js | 11 +++++++++++ .../services/agentEventService.lifecycle.test.js | 2 +- .../agentMentionService.wakeOnMessage.test.js | 6 ++++-- .../services/messageDeliveryModeService.test.js | 2 +- backend/services/agentDigestFoldService.ts | 10 ++++++---- backend/services/agentEventService.ts | 15 +++++++++++---- backend/services/agentMentionService.ts | 13 +++++++++---- backend/services/messageDeliveryModeService.ts | 1 - 8 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js index 1033c8ff7e..45851410cf 100644 --- a/backend/__tests__/unit/services/agentDigestFoldService.test.js +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -39,6 +39,17 @@ describe('expired digest fold', () => { expect(await AgentEvent.countDocuments({ podId })).toBe(1); }); + it('folds old unacked deliveries and failures before ordinary GC can delete them', async () => { + const podId = new mongoose.Types.ObjectId(); + const delivered = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const failed = await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + await AgentEvent.collection.updateOne({ _id: delivered.insertedId }, { $set: { status: 'delivered' } }); + await AgentEvent.collection.updateOne({ _id: failed.insertedId }, { $set: { status: 'failed' } }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + expect((await AgentDigestState.findOne({ agentName: 'flint', podId }).lean()).unreadCount).toBe(2); + }); + it('does not count a row twice after a crash between state update and delete', async () => { const podId = new mongoose.Types.ObjectId(); const event = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 56fe22dfb4..82d451ef0e 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -332,7 +332,7 @@ describe('garbageCollect: the requeue predicate and its cap', () => { await AgentEventService.garbageCollect({}); const pending = deletes().find((f) => f.status === 'pending'); - const settled = deletes().find((f) => Array.isArray(f.status?.$in)); + const settled = deletes().find((f) => f.status === 'acked'); expect(pending).toBeDefined(); expect(settled).toBeDefined(); diff --git a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js index 07dd6703e2..ddb8faeda7 100644 --- a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js +++ b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js @@ -246,10 +246,10 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual([]); - expect(wakeCalls()).toHaveLength(0); + expect(wakeCalls()).toEqual([expect.objectContaining({ agentName: 'seat-b', deliveryMode: 'digest' })]); // The dampener counted the WAKE event type, not mentions. expect(AgentEvent.countDocuments).toHaveBeenCalledWith( - expect.objectContaining({ type: 'message.posted', agentName: 'seat-b' }), + expect.objectContaining({ type: 'message.posted', agentName: 'seat-b', deliveryMode: { $ne: 'digest' } }), ); }); @@ -291,6 +291,7 @@ describe('wake-on-message (ADR-018 D8)', () => { podId: 'pod-1', message: { content: 'Scout says welcome', id: 'msg-h2' }, userId: 'bot-1', username: 'scout', }); expect(res.woken).toEqual(['seat-b']); + expect(wakeCalls().find((call) => call.agentName === 'sam-claude').deliveryMode).toBe('digest'); }); test('an unknown sender is not a known human, so it doesn\'t wake either', async () => { @@ -300,6 +301,7 @@ describe('wake-on-message (ADR-018 D8)', () => { podId: 'pod-1', message: { content: 'who am I', id: 'msg-h3' }, userId: 'ghost', username: 'ghost', }); expect(res.woken).toEqual([]); + expect(wakeCalls().find((call) => call.agentName === 'sam-claude').deliveryMode).toBe('digest'); }); }); diff --git a/backend/__tests__/unit/services/messageDeliveryModeService.test.js b/backend/__tests__/unit/services/messageDeliveryModeService.test.js index 070a287cd8..ad82c0c035 100644 --- a/backend/__tests__/unit/services/messageDeliveryModeService.test.js +++ b/backend/__tests__/unit/services/messageDeliveryModeService.test.js @@ -32,7 +32,7 @@ describe('ambient addressed delivery', () => { test('an exact TASK reference addresses its assignee and a live claim holder', async () => { Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([ - { taskId: 'TASK-414', assignee: 'flint', status: 'pending' }, + { taskId: 'TASK-414', assignee: 'flint', status: 'done' }, { taskId: 'TASK-415', claimedBy: 'bot-sable', status: 'claimed', claimExpiresAt: new Date(Date.now() + 60000) }, ]) }) }); expect(await classify('TASK-414 and TASK-415')).toEqual(new Set(['flint:default', 'sable:default'])); diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts index 73323152b5..7b024cbcac 100644 --- a/backend/services/agentDigestFoldService.ts +++ b/backend/services/agentDigestFoldService.ts @@ -20,7 +20,8 @@ type FoldCandidate = { */ export async function foldExpiredDigests(threshold: Date): Promise { const candidates = await AgentEvent.find({ - deliveryMode: 'digest', status: { $in: ['pending', 'folding'] }, createdAt: { $lt: threshold }, + deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + createdAt: { $lt: threshold }, }).sort({ _id: 1 }).limit(FOLD_BATCH_SIZE).lean() as FoldCandidate[]; let folded = 0; const blockedScopes = new Set(); @@ -38,16 +39,17 @@ export async function foldExpiredDigests(threshold: Date): Promise { // earlier row has not reached the retention horizon yet. // eslint-disable-next-line no-await-in-loop const olderUnread = await AgentEvent.exists({ - ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding'] }, _id: { $lt: id }, + ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + _id: { $lt: id }, }); if (olderUnread) { blockedScopes.add(scopeKey); continue; } - if (candidate.status === 'pending') { + if (candidate.status !== 'folding') { // eslint-disable-next-line no-await-in-loop const claimed = await AgentEvent.findOneAndUpdate( - { _id: id, status: 'pending', deliveryMode: 'digest' }, + { _id: id, status: candidate.status, deliveryMode: 'digest' }, { $set: { status: 'folding' } }, { new: true }, ); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 04004de778..0a2f168166 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -871,7 +871,7 @@ class AgentEventService { // pending delete below excludes digest. Other event retention can run. console.warn('[agent-event] digest fold failed:', (err as Error).message); } - const [pendingResult, deliveredResult, failedResult] = await Promise.all([ + const [pendingResult, ackedResult, deliveredResult, failedResult] = await Promise.all([ // #993: this used to run on a 30-minute `stalePendingThreshold`, which put // it AHEAD of the retry lifecycle rather than after it. The requeue pass // above sets `status` but not `createdAt`, so an event it had just rescued @@ -898,12 +898,19 @@ class AgentEventService { AgentEvent.deleteMany({ status: 'pending', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: deliveredThreshold }, }), - AgentEvent.deleteMany({ status: { $in: ['delivered', 'acked'] }, createdAt: { $lt: deliveredThreshold } }), - AgentEvent.deleteMany({ status: 'failed', createdAt: { $lt: failedThreshold } }), + AgentEvent.deleteMany({ + status: 'acked', createdAt: { $lt: deliveredThreshold }, + }), + AgentEvent.deleteMany({ + status: 'delivered', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: deliveredThreshold }, + }), + AgentEvent.deleteMany({ + status: 'failed', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: failedThreshold }, + }), ]) as Array<{ deletedCount?: number }>; const deletedPending = pendingResult?.deletedCount || 0; - const deletedDelivered = deliveredResult?.deletedCount || 0; + const deletedDelivered = (ackedResult?.deletedCount || 0) + (deliveredResult?.deletedCount || 0); const deletedFailed = failedResult?.deletedCount || 0; return { diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index a16a7ccda5..11959f14ec 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -1200,6 +1200,7 @@ const isWakeLoopDampened = async ( instanceId: target.instanceId || 'default', podId, type: 'message.posted', + deliveryMode: { $ne: 'digest' }, createdAt: { $gte: new Date(Date.now() - MENTION_LOOP_WINDOW_MS) }, }); if (count > MENTION_LOOP_MAX) { @@ -1416,9 +1417,9 @@ const enqueueWakeOnMessage = async ({ // single User.find). let cachedBotUserIds: Map | undefined = resolvedBotUserIds; const needsOwnershipIds = Boolean(threadRootId) || /\bTASK-\d+\b|#\d+\b/i.test(rawContent); - if (needsOwnershipIds) cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); let addressedTargets: Set; try { + if (needsOwnershipIds) cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { addressedMessageTargets } = require('./messageDeliveryModeService'); addressedTargets = await addressedMessageTargets({ @@ -1428,6 +1429,7 @@ const enqueueWakeOnMessage = async ({ } catch (error) { console.warn('[message-delivery] addressed lookup failed (waking):', (error as Error).message); addressedTargets = new Set(targets.map(installKey)); + cachedBotUserIds = cachedBotUserIds ?? new Map(); } if (threadRootId) { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports @@ -1507,10 +1509,13 @@ const enqueueWakeOnMessage = async ({ const key = `${agentName}:${instanceId.toLowerCase()}`; if (key === senderKey) return; if (excludeKeys?.has(key)) return; - if (humanOnlyWake(inst) && sender?.isBot !== false) return; - if (senderKey && await isWakeLoopDampened({ agentName, instanceId }, podId)) return; const addressed = addressedTargets.has(key); - const deliveryMode = wakeOnMessageEnabled(inst) || addressed ? 'wake' : 'digest'; + let deliveryMode = addressed || (wakeOnMessageEnabled(inst) + && (!humanOnlyWake(inst) || sender?.isBot === false)) ? 'wake' : 'digest'; + // Loop controls suppress a model turn, not receipt. The update remains + // available in the next digest for this seat. + if (deliveryMode === 'wake' && senderKey + && await isWakeLoopDampened({ agentName, instanceId }, podId)) deliveryMode = 'digest'; const repliesToYou = !!(parentAuthorUserId && parentBotUserIds && parentBotUserIds.get(installKey(inst)) === parentAuthorUserId); diff --git a/backend/services/messageDeliveryModeService.ts b/backend/services/messageDeliveryModeService.ts index 49c4d5d287..f9b97f2f32 100644 --- a/backend/services/messageDeliveryModeService.ts +++ b/backend/services/messageDeliveryModeService.ts @@ -60,7 +60,6 @@ export async function addressedMessageTargets({ }); const rows = await Task.find({ podId, - status: { $in: ['pending', 'claimed'] }, $or: selectors, }).select('taskId assignee claimedBy claimExpiresAt status prUrl').lean() as Array<{ taskId: string; assignee?: string | null; claimedBy?: string | null; From d83b52a36709cffdd86022f3ce3ddd41fab4ae4d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:14:59 -0700 Subject: [PATCH 06/28] Carry ambient digest through legacy wake consumers Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 51 +++++++++ .../agentEventService.lifecycle.test.js | 31 +++++ .../unit/services/agentEventService.test.js | 10 +- backend/models/AgentEvent.ts | 4 + backend/services/agentEventService.ts | 108 ++++++++++++++++-- 5 files changed, 193 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 636722a73a..603513e7ef 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -39,6 +39,7 @@ jest.mock('../../../services/nativeRuntimeService', () => ({ runAgent: jest.fn() let mongod; let AgentEvent; let AgentMemory; +let AgentDigestState; let AgentEventService; const AGENT = 'pixel'; @@ -50,6 +51,7 @@ beforeAll(async () => { await mongoose.connect(mongod.getUri()); AgentEvent = require('../../../models/AgentEvent'); AgentMemory = require('../../../models/AgentMemory'); + AgentDigestState = require('../../../models/AgentDigestState'); AgentEventService = require('../../../services/agentEventService'); }); @@ -61,6 +63,55 @@ afterAll(async () => { beforeEach(async () => { await AgentEvent.deleteMany({}); await AgentMemory.deleteMany({}); + await AgentDigestState.deleteMany({}); +}); + +describe('legacy ambient digest bridge', () => { + test('a legacy poll carries the pending digest on its wake and settles it after ack', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', messageId: '42', content: 'ambient note' }, + }); + const wake = await seedEvent(); + + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(String(delivered._id)).toBe(String(wake._id)); + expect(delivered.payload.digestSummary).toContain('Alice (message 42): ambient note'); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action' }, delivered.payload.deliveryId); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('acked'); + }); + + test('a failed legacy wake leaves its digest unread for a later wake', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'later' }, + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'error' }, delivered.payload.deliveryId); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + }); + + test('a legacy wake carries a folded count and clears only the revision it showed', async () => { + await AgentDigestState.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + unreadCount: 3, revision: 1, lastEventIds: ['old-1', 'old-2', 'old-3'], + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(delivered.payload.digestSummary).toContain('3 older updates'); + await AgentDigestState.updateOne({ agentName: AGENT, podId: POD_ID }, + { $inc: { unreadCount: 1, revision: 1 } }); + + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action' }, delivered.payload.deliveryId); + expect((await AgentDigestState.findOne({ agentName: AGENT, podId: POD_ID }).lean()).unreadCount).toBe(4); + }); }); const seedEvent = () => AgentEvent.create({ diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 82d451ef0e..6e25626500 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -28,6 +28,7 @@ jest.mock('../../../models/AgentEvent', () => ({ create: jest.fn(), findOneAndUpdate: jest.fn(), findByIdAndUpdate: jest.fn(), + updateOne: jest.fn(), find: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn(), @@ -404,6 +405,36 @@ describe('driver paths reach a terminal state', () => { expect(update['delivery.outcome']).toBe('no_action'); }); + test('a webhook wake carries and settles ambient context only after accepted delivery', async () => { + const ambient = { + _id: 'ambient-1', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, + payload: { username: 'Alice', messageId: '42', content: 'ambient note' }, + }; + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [ambient] }) }) }); + AgentEvent.updateOne.mockResolvedValue({ matchedCount: 1 }); + AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); + AgentEvent.create.mockResolvedValue({ + _id: 'evt-digest-wake', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, + type: 'chat.mention', payload: {}, status: 'pending', attempts: 0, + }); + AgentInstallation.find.mockReturnValue({ + lean: async () => [{ config: { runtime: { webhookUrl: 'https://example.test/hook' } } }], + }); + postToPublicAddress.mockResolvedValueOnce({ ok: true, status: 200, text: '{"outcome":"no_action"}' }); + + await AgentEventService.enqueue({ + agentName: 'hookbot', instanceId: 'default', podId: POD_P1, type: 'chat.mention', payload: {}, + }); + await flush(); + + const body = JSON.parse(postToPublicAddress.mock.calls[0][1].body); + expect(body.payload.digestSummary).toContain('Alice (message 42): ambient note'); + expect(AgentEvent.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ _id: { $in: ['ambient-1'] }, deliveryMode: 'digest' }), + expect.objectContaining({ $set: expect.objectContaining({ status: 'acked' }) }), + ); + }); + test('a webhook URL the guard refuses gets no request, and the event is not acked', async () => { // The real guard runs here (no override): the address is link-local. // Fail instead of connecting, so even a broken guard cannot open a socket here. diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 72919e5711..7a1dca2e41 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -507,10 +507,16 @@ describe('AgentEventService', () => { agentName: 'inbox', instanceId: 'default', podIds: ['pod-a', 'pod-b'], limit: 10, }); - expect(AgentEvent.find).toHaveBeenCalledTimes(1); - expect(AgentEvent.find).toHaveBeenCalledWith(expect.objectContaining({ + expect(AgentEvent.find).toHaveBeenCalledTimes(3); + expect(AgentEvent.find).toHaveBeenNthCalledWith(1, expect.objectContaining({ podId: { $in: ['pod-a', 'pod-b'] }, })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(2, expect.objectContaining({ + podId: 'pod-a', deliveryMode: 'digest', + })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(3, expect.objectContaining({ + podId: 'pod-b', deliveryMode: 'digest', + })); expect(events.map((event) => event.podId)).toEqual(['pod-a', 'pod-b']); }); diff --git a/backend/models/AgentEvent.ts b/backend/models/AgentEvent.ts index 1e6d5183ba..cd5a399353 100644 --- a/backend/models/AgentEvent.ts +++ b/backend/models/AgentEvent.ts @@ -113,6 +113,9 @@ export interface IAgentEvent extends Document { // Snapshot of the folded digest state included in this wake. Ack clears it // only if no newer fold changed the state while the turn was running. digestRollupRevision?: number | null; + // Raw ambient rows presented inside a legacy wake or webhook delivery. + // They settle only when that wake succeeds. + digestEventIds?: Types.ObjectId[]; createdAt: Date; updatedAt: Date; } @@ -132,6 +135,7 @@ const AgentEventSchema = new Schema( memoryRevisionAtDelivery: { type: Number, default: null }, deliveryNonce: { type: String, default: null }, digestRollupRevision: { type: Number, default: null }, + digestEventIds: [{ type: Schema.Types.ObjectId, ref: 'AgentEvent' }], delivery: { outcome: { type: String, diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 0a2f168166..7f426ac302 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -129,6 +129,7 @@ interface EventDoc { memoryRevisionAtDelivery?: number | null; deliveryNonce?: string | null; digestRollupRevision?: number | null; + digestEventIds?: unknown[]; } interface InstallationDoc { @@ -260,6 +261,12 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even const { webhookUrl, webhookSecret } = runtimeConfig as { webhookUrl?: string; webhookSecret?: string }; if (!webhookUrl) return; + // Webhook drivers never poll the inbox. Carry ambient context on their next + // waking POST, then settle it only after the endpoint accepts that POST. + await AgentEventService.attachLegacyDigest(event).catch((err: Error) => { + console.warn('[webhook] digest attachment failed:', err.message); + }); + const payload = JSON.stringify({ _id: event._id, type: event.type, @@ -329,6 +336,11 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even 'delivery.outcome': outcome, 'delivery.updatedAt': new Date(), }); + if (outcome !== 'error') { + await AgentEventService.settleLegacyDigest(event).catch((err: Error) => { + console.warn('[webhook] digest settle failed:', err.message); + }); + } } catch (err) { if ((err as Error).name === 'AbortError') { console.warn(`[webhook] ${event.agentName} ${webhookUrl} timed out after 10s`); @@ -1375,6 +1387,67 @@ class AgentEventService { return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds }; } + /** Add one bounded ambient frame to a wake consumed by an older poller or webhook. */ + static async attachLegacyDigest(event: EventDoc): Promise { + if (!event._id || !event.podId || !event.agentName || event.deliveryMode === 'digest') return; + const scope = { + agentName: String(event.agentName).toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + const [raw, rollup] = await Promise.all([ + AgentEvent.find({ ...scope, status: 'pending', deliveryMode: 'digest' }) + .sort({ createdAt: 1 }).limit(8).lean() as Promise, + readFoldedDigest(scope), + ]); + if (!raw.length && !rollup?.unreadCount) return; + const ids = raw.map((row) => row._id); + const saved = await AgentEvent.updateOne( + { + _id: event._id, + status: { $in: ['pending', 'delivered'] }, + deliveryMode: { $ne: 'digest' }, + ...(event.deliveryNonce ? { deliveryNonce: event.deliveryNonce } : {}), + }, + { $set: { + digestEventIds: ids, + digestRollupRevision: rollup?.revision ?? null, + } }, + ); + if (!saved.matchedCount) return; + event.digestEventIds = ids; + event.digestRollupRevision = rollup?.revision ?? null; + const lines = raw.map((row) => { + const who = String(row.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); + const body = String(row.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); + const messageId = row.payload?.messageId; + return `- ${who}${messageId ? ` (message ${String(messageId)})` : ''}: ${body}`; + }); + if (rollup?.unreadCount) { + lines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${rollup.lastEventIds.join(', ')})`); + } + event.payload = { + ...(event.payload || {}), + digestSummary: `[Since you last looked: ${raw.length + (rollup?.unreadCount || 0)} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`, + }; + } + + static async settleLegacyDigest(event: EventDoc): Promise { + if (!event.agentName || !event.podId) return; + const scope = { + agentName: String(event.agentName).toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + if (event.digestEventIds?.length) { + await AgentEvent.updateMany( + { ...scope, _id: { $in: event.digestEventIds }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked', deliveredAt: new Date() } }, + ); + } + if (event.digestRollupRevision) await settleFoldedDigest(scope, event.digestRollupRevision); + } + /** Run a claimed native event and settle it without blocking enqueue. */ static dispatchInProcess( event: EventDoc, @@ -1639,7 +1712,18 @@ class AgentEventService { // Legacy consumers intentionally replay across every pod in their access // scope. The one-pod partition is opt-in through listInboxPage(), for any // HTTP poller whose batch becomes one pod-contextual model turn. - return (await this.claimPage(options)).events; + const events = (await this.claimPage(options)).events; + const seenPods = new Set(); + for (const event of events) { + const podKey = String(event.podId || ''); + if (!podKey || seenPods.has(podKey)) continue; + seenPods.add(podKey); + // eslint-disable-next-line no-await-in-loop -- attach once per distinct pod + await this.attachLegacyDigest(event).catch((err: Error) => { + console.warn('[agent-event] legacy digest attachment failed:', err.message); + }); + } + return events; } // The HTTP poller needs the true pending count for its inbox header even @@ -1789,7 +1873,7 @@ class AgentEventService { } const firstWake = claimed.find((event) => event.deliveryMode !== 'digest'); - const foldedRollup = firstWake + const foldedRollup = onePod && firstWake ? await this.attachFoldedDigest(firstWake).catch((err: Error) => { console.warn('[agent-event] folded digest attachment failed:', err.message); return null; @@ -1894,13 +1978,19 @@ class AgentEventService { { new: true }, ) as EventDoc | null; - if (result?.digestRollupRevision && normalizedDelivery?.outcome !== 'error') { - await settleFoldedDigest( - { agentName: safeAgentName, instanceId: safeInstanceId, podId: result.podId }, - result.digestRollupRevision, - ).catch((err: Error) => { - console.warn('[agent-event] folded digest settle failed:', err.message); - }); + if (result && normalizedDelivery?.outcome !== 'error') { + if (result.digestEventIds?.length) { + await this.settleLegacyDigest(result).catch((err: Error) => { + console.warn('[agent-event] legacy digest settle failed:', err.message); + }); + } else if (result.digestRollupRevision) { + await settleFoldedDigest( + { agentName: safeAgentName, instanceId: safeInstanceId, podId: result.podId }, + result.digestRollupRevision, + ).catch((err: Error) => { + console.warn('[agent-event] folded digest settle failed:', err.message); + }); + } } // ADR-012 §3: bump lastSeenRevision exactly once per event via $max. From 8654bfc954d3a0d58fa6dff7d18dd0578af98eb2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:30 -0700 Subject: [PATCH 07/28] Reconcile digest after missed wake hints and access changes Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 46 +++++++++++++++++++ .../unit/services/agentMentionService.test.js | 7 ++- backend/services/agentEventService.ts | 16 ++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 603513e7ef..c3377f2f7a 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -67,6 +67,50 @@ beforeEach(async () => { }); describe('legacy ambient digest bridge', () => { + test('a push hint does not consume durable inbox state before the reconciling poll', async () => { + const ws = require('../../../services/agentWebSocketService'); + const push = jest.spyOn(ws, 'pushEvent').mockReturnValue(true); + const digest = await AgentEventService.enqueue({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', content: 'context' }, + }); + const wake = await AgentEventService.enqueue({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'chat.mention', payload: { content: 'question' }, + }); + expect(push).toHaveBeenCalledTimes(1); + expect(String(push.mock.calls[0][0]._id)).toBe(String(wake._id)); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(wake._id).lean()).status).toBe('pending'); + + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + }); + expect(page.events.map((item) => String(item._id))).toEqual([String(wake._id), String(digest._id)]); + expect((await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + })).events).toEqual([]); + push.mockRestore(); + }); + + test('a poll after pod access is removed cannot drain a queued wake or digest', async () => { + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'private update' }, + }); + const wake = await seedEvent(); + + expect(await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [], + })).toEqual({ events: [], inboxCount: 0 }); + expect(await AgentEventService.list({ + agentName: AGENT, instanceId: INSTANCE, podIds: [], + })).toEqual([]); + expect((await AgentEvent.findById(wake._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + }); + test('a legacy poll carries the pending digest on its wake and settles it after ack', async () => { const ambient = await AgentEvent.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, @@ -74,10 +118,12 @@ describe('legacy ambient digest bridge', () => { payload: { username: 'Alice', messageId: '42', content: 'ambient note' }, }); const wake = await seedEvent(); + await AgentEvent.updateOne({ _id: wake._id }, { $set: { payload: { content: 'question' } } }); const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); expect(String(delivered._id)).toBe(String(wake._id)); expect(delivered.payload.digestSummary).toContain('Alice (message 42): ambient note'); + expect(delivered.payload.content).toContain('Alice (message 42): ambient note'); expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, diff --git a/backend/__tests__/unit/services/agentMentionService.test.js b/backend/__tests__/unit/services/agentMentionService.test.js index d9993af438..bd9ae5335f 100644 --- a/backend/__tests__/unit/services/agentMentionService.test.js +++ b/backend/__tests__/unit/services/agentMentionService.test.js @@ -1446,7 +1446,7 @@ describe('AgentMentionService', () => { expect(res.skipped).toEqual([]); }); - test('bot -> bot OVER threshold is suppressed (loop dampened)', async () => { + test('bot -> bot OVER threshold does not wake, but remains received as digest', async () => { setupTargetAgent(); asBotSender(); // 4 prior mentions > MENTION_LOOP_MAX (3) → treat as a loop. @@ -1459,7 +1459,10 @@ describe('AgentMentionService', () => { username: 'theo', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1); + expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({ + deliveryMode: 'digest', type: 'message.posted', + })); expect(res.enqueued).toEqual([]); expect(res.skipped).toEqual(['openclaw:loop-dampened']); }); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 7f426ac302..5ce2b4beca 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -731,6 +731,7 @@ class AgentEventService { `id=${details.eventId || 'n/a'}`, ]; if (details.trigger) parts.push(`trigger=${details.trigger}`); + if (details.deliveryMode) parts.push(`deliveryMode=${details.deliveryMode}`); if (details.status) parts.push(`status=${details.status}`); if (typeof details.attempts === 'number') parts.push(`attempts=${details.attempts}`); if (details.error) parts.push(`error="${details.error}"`); @@ -1426,9 +1427,14 @@ class AgentEventService { if (rollup?.unreadCount) { lines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${rollup.lastEventIds.join(', ')})`); } + const summary = `[Since you last looked: ${raw.length + (rollup?.unreadCount || 0)} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`; event.payload = { ...(event.payload || {}), - digestSummary: `[Since you last looked: ${raw.length + (rollup?.unreadCount || 0)} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`, + // Existing drivers consume content; the additive field also lets a new + // driver render the frame separately without parsing the wake text. + ...(typeof event.payload?.content === 'string' + ? { content: `${event.payload.content}\n\n${summary}` } : {}), + digestSummary: summary, }; } @@ -1645,6 +1651,7 @@ class AgentEventService { instanceId: event.instanceId || '', podId: String((event.podId as { toString?: () => string })?.toString?.() || ''), type: event.type || '', + deliveryMode: event.deliveryMode || 'wake', trigger: event.payload?.trigger, status: event.status, attempts: event.attempts, @@ -1743,6 +1750,12 @@ class AgentEventService { // sanitizer; the String() casts are that sanitizer. const safeAgentName = String(agentName || '').toLowerCase(); const safeInstanceId = String(instanceId || 'default'); + // An explicit empty access set means the caller has no currently visible + // pods. It must not fall through to an identity-wide inbox query after an + // installation or pod membership was removed between enqueue and drain. + if (Array.isArray(podIds) && podIds.length === 0 && !podId) { + return { events: [], inboxCount: 0 }; + } const query: Record = { agentName: safeAgentName, instanceId: safeInstanceId, @@ -2021,6 +2034,7 @@ class AgentEventService { instanceId, podId: String((result?.podId as { toString?: () => string })?.toString?.() || ''), type: result?.type || '', + deliveryMode: result?.deliveryMode || 'wake', trigger: result?.payload?.trigger, status: result?.status || 'acked', attempts: result?.attempts, From 46cbe9917b0bd6d30b9d9e18c7f6b1523f37539f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:19:20 -0700 Subject: [PATCH 08/28] Keep digest unread when waking turn stands down Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/run-loop.test.mjs | 56 +++++++++++++++++++++++++++++++++ cli/src/commands/agent.js | 15 +++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 84d061e075..67a218daf3 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -352,6 +352,62 @@ describe('performRun', () => { expect(ackPosts(post)).toHaveLength(0); }); + test('a digest companion stays unread when the sole wake loses its binding claim', async () => { + const events = [ + makeEvent({ _id: 'evt-held', type: 'message.posted', + payload: { content: 'peer owns this', messageId: 'msg-held' } }), + makeEvent({ _id: 'evt-ambient', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'background', messageId: 'msg-ambient' } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: 2 }; + }); + const post = jest.fn(async (route) => ( + route.endsWith('/claim') ? { claimed: false, claimedBy: 'nova' } : {} + )); + createClient.mockReturnValue({ get, post, del: jest.fn() }); + const spawn = jest.fn(async () => ({ text: 'must not run' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).not.toHaveBeenCalled(); + expect(ackPosts(post)).toEqual([[ + '/api/agents/runtime/events/evt-held/ack', + { result: { outcome: 'no_action', reason: 'claim-held' } }, + ]]); + }); + + test('a provider refusal leaves digest unread even though the wake is acknowledged', async () => { + const events = [ + makeEvent({ _id: 'evt-refused', payload: { content: 'question' } }), + makeEvent({ _id: 'evt-ambient', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'background' } }), + ]; + const get = jest.fn(async (route) => ( + route.endsWith('/messages') ? { messages: [] } : { events, inboxCount: 2 } + )); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: '', upstream: { status: 429, detail: 'budget' } })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(ackPosts(post).map(([route]) => route)).toEqual(['/api/agents/runtime/events/evt-refused/ack']); + }); + test('a lost binding item stays read-only context while another batch item may run', async () => { const events = [ makeEvent({ diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 45e2bd0606..bc7f9e4cd1 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2208,7 +2208,7 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges admissionEvent.event.payload, ); if (!admission.allowed) { - for (const entry of activeEntries) { + for (const entry of wakingEntries) { entry.result = batchAdmissionResult(entry.event, admission); } return { entries }; @@ -2269,7 +2269,7 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges } } - const actionEntries = activeEntries.filter((entry) => !entry.result); + const actionEntries = wakingEntries.filter((entry) => !entry.result); if (actionEntries.length === 0) return { entries }; const claimKeepers = actionEntries @@ -2328,6 +2328,17 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges trigger, }); for (const entry of actionEntries) entry.result = turnResult; + // A digest was context in this successful waking turn, never an + // independent action. If the waking work stood down or the provider + // refused the batch, leave it unread for the next real turn. + if (turnResult?.outcome === 'posted' + || (turnResult?.outcome === 'no_action' && !turnResult?.reason)) { + for (const entry of activeEntries) { + if (entry.event.deliveryMode === 'digest') { + entry.result = { outcome: 'no_action', reason: 'digest-read' }; + } + } + } } catch (err) { if (!isContentRefusal(err)) throw err; if (actionEntries.length === 1) { From e20bff3f675499bfa4961b4778e9cf09de8e7318 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:22:59 -0700 Subject: [PATCH 09/28] Keep unread digest after refused delivery and sanitize webhook lookup Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 18 +++++++++++++++ backend/services/agentEventService.ts | 23 +++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index c3377f2f7a..931050ae02 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -143,6 +143,24 @@ describe('legacy ambient digest bridge', () => { expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); }); + test('a claim stand-down does not consume raw or folded digest', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'later' }, + }); + await AgentDigestState.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + unreadCount: 2, revision: 1, lastEventIds: ['old-1', 'old-2'], + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action', reason: 'claim-held' }, delivered.payload.deliveryId); + + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + expect((await AgentDigestState.findOne({ agentName: AGENT, podId: POD_ID }).lean()).unreadCount).toBe(2); + }); + test('a legacy wake carries a folded count and clears only the revision it showed', async () => { await AgentDigestState.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 5ce2b4beca..83fda19751 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -303,7 +303,9 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even let response: Record = {}; try { response = JSON.parse(res.text) as Record; } catch (_) { /* no body is fine */ } - const { outcome = 'acknowledged', content } = response as { outcome?: string; content?: string }; + const { outcome = 'acknowledged', content, reason } = response as { + outcome?: string; content?: string; reason?: string; + }; if (outcome === 'posted' && content) { try { @@ -336,7 +338,7 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even 'delivery.outcome': outcome, 'delivery.updatedAt': new Date(), }); - if (outcome !== 'error') { + if (outcome === 'posted' || outcome === 'acknowledged' || (outcome === 'no_action' && !reason)) { await AgentEventService.settleLegacyDigest(event).catch((err: Error) => { console.warn('[webhook] digest settle failed:', err.message); }); @@ -1684,10 +1686,13 @@ class AgentEventService { // if the gateway is dead." Typing_stop is still emitted when // AgentMessageService.postMessage runs.) - if (deliveryMode === 'wake') AgentInstallation.find({ - agentName: event.agentName, - instanceId, - podId, + // Query from the persisted event and coerce the identifier to a scalar + // before it reaches Mongo. Callers of enqueue may supply a runtime payload. + const webhookPodId = String(event.podId || ''); + if (deliveryMode === 'wake' && Types.ObjectId.isValid(webhookPodId)) AgentInstallation.find({ + agentName: String(event.agentName || ''), + instanceId: String(event.instanceId || 'default'), + podId: new Types.ObjectId(webhookPodId), status: 'active', }).lean().then((installations: InstallationDoc[]) => { for (const inst of installations) { @@ -1991,7 +1996,11 @@ class AgentEventService { { new: true }, ) as EventDoc | null; - if (result && normalizedDelivery?.outcome !== 'error') { + const consumedDigest = !normalizedDelivery + || normalizedDelivery.outcome === 'posted' + || normalizedDelivery.outcome === 'acknowledged' + || (normalizedDelivery.outcome === 'no_action' && !normalizedDelivery.reason); + if (result && consumedDigest) { if (result.digestEventIds?.length) { await this.settleLegacyDigest(result).catch((err: Error) => { console.warn('[agent-event] legacy digest settle failed:', err.message); From e5e889cb185f3c2e5fe30b85f143d1c97c390555 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:27:16 -0700 Subject: [PATCH 10/28] Keep connect poller digests quiet until a waking event Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/lib.test.mjs | 66 ++++++++++++++++++++++++++++++++++++++ cli/src/lib/poller.js | 56 ++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index d3daef68f4..8cd5854600 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -538,6 +538,72 @@ describe('poller.js', () => { ); }); + test('keeps a digest-only page quiet and attaches its frame to the next wake', async () => { + const digest = { + ...makeEvent('digest-1'), deliveryMode: 'digest', + type: 'message.posted', payload: { content: 'ambient update', username: 'Ava', deliveryId: 'digest-nonce' }, + }; + const wake = { + ...makeEvent('wake-1'), deliveryMode: 'wake', + type: 'chat.mention', payload: { content: '@my-agent please help', deliveryId: 'wake-nonce' }, + }; + const mockGet = jest.fn() + .mockResolvedValueOnce({ events: [digest] }) + .mockResolvedValueOnce({ events: [wake, digest] }) + .mockResolvedValue({ events: [] }); + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue({ outcome: 'acknowledged' }); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 20, onEvent, + }); + await new Promise((r) => setTimeout(r, 75)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ + _id: 'wake-1', + payload: expect.objectContaining({ + content: expect.stringContaining('ambient update'), + digestSummary: expect.stringContaining('ambient update'), + }), + })); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + '/api/agents/runtime/events/digest-1/ack', + ]); + expect(mockPost).toHaveBeenCalledWith('/api/agents/runtime/events/digest-1/ack', { + result: { outcome: 'no_action', reason: 'digest-read' }, deliveryId: 'digest-nonce', + }); + }); + + test.each([ + { outcome: 'error', reason: 'webhook failed' }, + { outcome: 'no_action', reason: 'upstream-refused' }, + ])('leaves digest unread when the waking handler returns $outcome with a reason', async (result) => { + const events = [ + { ...makeEvent('wake-1'), deliveryMode: 'wake' }, + { ...makeEvent('digest-1'), deliveryMode: 'digest', payload: { content: 'ambient' } }, + ]; + const mockGet = jest.fn().mockResolvedValue({ events }); + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue(result); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + ]); + }); + test('stops after stale_delivery instead of polling against the replacement', async () => { const events = [{ ...makeEvent('stale-1'), payload: { deliveryId: 'e'.repeat(32) } }]; const stale = Object.assign(new Error('This delivery was superseded'), { diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index 6c7efae7ac..3001fb7162 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -28,6 +28,16 @@ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => return null; }; +const digestFrame = (events) => { + const lines = events.slice(-8).map((event) => { + const who = String(event.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); + const body = String(event.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); + const id = event.payload?.messageId; + return `- ${who}${id ? ` (message ${id})` : ''}: ${body}`; + }); + return `[Since you last looked: ${events.length} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`; +}; + export const startPoller = ({ instanceUrl, token, @@ -57,10 +67,27 @@ export const startPoller = ({ limit: 10, }); - for (const event of events) { + // A digest is received context, not a webhook invocation. The runtime + // inbox claims it only alongside a wake from the same pod. + const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest'); + const digestEvents = events.filter((event) => event.deliveryMode === 'digest'); + const frame = digestEvents.length > 0 ? digestFrame(digestEvents) : null; + let digestConsumed = false; + for (const [index, event] of wakeEvents.entries()) { + const attachedDigest = index === 0 && digestEvents.length > 0; + const forwarded = attachedDigest ? { + ...event, + payload: { + ...(event.payload || {}), + digestSummary: frame, + ...(typeof event.payload?.content === 'string' ? { + content: `${event.payload.content}\n\n${frame}`, + } : {}), + }, + } : event; let result = { outcome: 'no_action' }; try { - result = (await onEvent(event)) || { outcome: 'no_action' }; + result = (await onEvent(forwarded)) || { outcome: 'no_action' }; } catch (handlerErr) { result = { outcome: 'error', reason: handlerErr.message }; } @@ -72,6 +99,11 @@ export const startPoller = ({ result, ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), }); + if (attachedDigest && (result.outcome === 'posted' + || result.outcome === 'acknowledged' + || (result.outcome === 'no_action' && !result.reason))) { + digestConsumed = true; + } } catch (ackErr) { const terminalError = terminalDeliveryAckError(ackErr, event._id); if (terminalError) { @@ -84,6 +116,26 @@ export const startPoller = ({ } } + if (digestConsumed) { + for (const event of digestEvents) { + try { + const deliveryId = event.payload?.deliveryId; + await client.post(`/api/agents/runtime/events/${event._id}/ack`, { + result: { outcome: 'no_action', reason: 'digest-read' }, + ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), + }); + } catch (ackErr) { + const terminalError = terminalDeliveryAckError(ackErr, event._id); + if (terminalError) { + running = false; + onError?.(terminalError); + return; + } + onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`)); + } + } + } + consecutiveErrors = 0; consecutiveAuthErrors = 0; } catch (err) { From f04e17faffdc23be2254f400003d609d4a6a0450 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:29:11 -0700 Subject: [PATCH 11/28] Align delivery tests with digest receipt semantics Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/agentEventService.test.js | 2 +- ...ntMentionService.humansAreNotWoken.test.js | 25 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 7a1dca2e41..412617ac6e 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -603,7 +603,7 @@ describe('AgentEventService', () => { digestRollupRevision: 4, }); - await AgentEventService.acknowledge('evt-1', 'pixel', 'default', { outcome: 'no_action', reason: 'nothing-to-do' }); + await AgentEventService.acknowledge('evt-1', 'pixel', 'default', { outcome: 'no_action' }); expect(AgentEvent.findOneAndUpdate).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js b/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js index 061c95fa6d..ed450d74e6 100644 --- a/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js +++ b/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js @@ -13,9 +13,10 @@ * * Two mechanisms, stated separately because they can break separately: * - * 1. A handle that resolves to a human enqueues no `AgentEvent` of any type. - * `enqueueMentions` has no human delivery branch at all — the handle is - * filtered into `humanMentionHandles` and never reaches an enqueue. The + * 1. A handle that resolves to a human enqueues no addressed `AgentEvent`. + * Ambient activity is now received as a digest by installed agents, but + * the human handle must not turn that receipt into a model wake. The + * handle is filtered into `humanMentionHandles` and never reaches an address. The * realistic future edit is TASK-070b (should a bare name route?): an * implementer who answers "yes, and push it" makes the cue a lie taught on * every wake, fleet-wide, and nothing in the suite objects. @@ -48,6 +49,13 @@ jest.mock('../../../models/pg/Message', () => ({ findById: jest.fn(async () => n jest.mock('../../../models/pg/ThreadUserState', () => ({ followByParticipation: jest.fn().mockResolvedValue(true), })); +jest.mock('../../../services/threadWakeScopeService', () => ({ + threadAuthorIds: jest.fn().mockResolvedValue(new Set()), + narrowToThread: jest.fn().mockResolvedValue([]), +})); +jest.mock('../../../services/messageClaimService', () => ({ + liveClaimantsInThread: jest.fn().mockResolvedValue(new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -104,12 +112,13 @@ beforeEach(() => { }); describe('a human handle is not a delivery target', () => { - test('@handle for a human enqueues no event of any kind', async () => { + test('@handle for a human is ambient receipt, never an addressed wake', async () => { await send({ id: 'm-1', content: 'can you decide this @sam' }); - // Not "no chat.mention" — no event at all. A future human-push branch is - // as likely to invent a type as to reuse this one. - expect(enqueued()).toHaveLength(0); + expect(enqueued()).toEqual([expect.objectContaining({ + agentName: 'seat-a', type: 'message.posted', deliveryMode: 'digest', + })]); + expect(mentions()).toHaveLength(0); }); test('CONTROL: the same sentence addressed to an installed agent DOES enqueue', async () => { @@ -149,7 +158,7 @@ describe('the thread-follow half is guarded by threadRootId', () => { expect(ThreadUserState.followByParticipation).toHaveBeenCalledWith(101, 'user-sam', 'pod-1'); }); - test('a follow is not a wake — the threaded case still enqueues no event', async () => { + test('a human follow does not add the agent to the threaded wake set', async () => { // Both halves of the cue's ceiling in one assertion: the handle bought a // pull-surface row, and nothing was pushed. await send({ From ce9cfa8e1383dac300911ac52c72630aba7b0141 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:30:37 -0700 Subject: [PATCH 12/28] Scope local digest frames to their waking pod Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/lib.test.mjs | 23 +++++++++++++++++++++++ cli/src/lib/poller.js | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index 8cd5854600..3d96ca4f2e 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -604,6 +604,29 @@ describe('poller.js', () => { ]); }); + test('does not forward or acknowledge another pod\'s digest with a wake', async () => { + const events = [ + { ...makeEvent('wake-1'), deliveryMode: 'wake', payload: { content: 'pod A' } }, + { ...makeEvent('digest-2'), podId: 'pod-other', deliveryMode: 'digest', payload: { content: 'private pod B' } }, + ]; + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue({ outcome: 'acknowledged' }); + createClient.mockReturnValue({ get: jest.fn().mockResolvedValue({ events }), post: mockPost }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ + payload: { content: 'pod A' }, + })); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + ]); + }); + test('stops after stale_delivery instead of polling against the replacement', async () => { const events = [{ ...makeEvent('stale-1'), payload: { deliveryId: 'e'.repeat(32) } }]; const stale = Object.assign(new Error('This delivery was superseded'), { diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index 3001fb7162..a52abcbea5 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -70,7 +70,8 @@ export const startPoller = ({ // A digest is received context, not a webhook invocation. The runtime // inbox claims it only alongside a wake from the same pod. const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest'); - const digestEvents = events.filter((event) => event.deliveryMode === 'digest'); + const digestEvents = events.filter((event) => event.deliveryMode === 'digest' + && wakeEvents[0] && String(event.podId) === String(wakeEvents[0].podId)); const frame = digestEvents.length > 0 ? digestFrame(digestEvents) : null; let digestConsumed = false; for (const [index, event] of wakeEvents.entries()) { From dbf8d2fe842134332a8f681b0c9e51b4ff62072a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:32:49 -0700 Subject: [PATCH 13/28] Keep first unread time across digest folds Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/agentDigestFoldService.test.js | 9 +++++++++ .../schedulerService.heartbeatLease.test.js | 3 ++- backend/models/AgentDigestState.ts | 3 +++ backend/services/agentDigestFoldService.ts | 15 ++++++++++----- backend/services/schedulerService.ts | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js index 45851410cf..bacecf6e63 100644 --- a/backend/__tests__/unit/services/agentDigestFoldService.test.js +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -34,6 +34,7 @@ describe('expired digest fold', () => { expect(await foldExpiredDigests(THRESHOLD)).toBe(2); const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); expect(state.unreadCount).toBe(2); + expect(state.unreadSince).toEqual(new Date(NOW.getTime() - 2 * 60 * 60 * 1000)); expect(state.lastEventIds).toEqual([String(first.insertedId), String(second.insertedId)]); expect(await AgentEvent.exists({ _id: recent.insertedId })).toBeTruthy(); expect(await AgentEvent.countDocuments({ podId })).toBe(1); @@ -119,6 +120,14 @@ describe('expired digest fold', () => { await settleFoldedDigest(scope, latest.revision); expect(await readFoldedDigest(scope)).toBeNull(); expect((await AgentDigestState.findOne(scope).lean()).revision).toBe(3); + expect((await AgentDigestState.findOne(scope).lean()).unreadSince).toBeNull(); + + const nextUnreadAt = new Date(NOW.getTime() - 70 * 60 * 1000); + await seed(nextUnreadAt, podId); + await foldExpiredDigests(THRESHOLD); + const nextState = await AgentDigestState.findOne(scope).lean(); + expect(nextState.unreadCount).toBe(1); + expect(nextState.unreadSince).toEqual(nextUnreadAt); }); it('attaches the folded summary to a real wake and clears it on that wake ack', async () => { diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index c86361dbe7..9a8406123d 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -134,7 +134,8 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { await AgentInstallation.collection.insertOne({ ...scope, status: 'active', version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); await AgentDigestState.collection.insertOne({ ...scope, unreadCount: 2, revision: 2, - lastEventIds: ['old-1', 'old-2'], updatedAt: new Date(NOW.getTime() - 61 * 60000) }); + lastEventIds: ['old-1', 'old-2'], + unreadSince: new Date(NOW.getTime() - 61 * 60000), updatedAt: NOW }); expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); expect(enqueue).toHaveBeenCalledTimes(1); diff --git a/backend/models/AgentDigestState.ts b/backend/models/AgentDigestState.ts index 2255207157..a9ca575f0b 100644 --- a/backend/models/AgentDigestState.ts +++ b/backend/models/AgentDigestState.ts @@ -8,6 +8,7 @@ export interface IAgentDigestState extends Document { unreadCount: number; revision: number; lastEventIds: string[]; + unreadSince?: Date | null; foldedThroughId?: Types.ObjectId | null; updatedAt: Date; } @@ -19,10 +20,12 @@ const schema = new Schema({ unreadCount: { type: Number, required: true, default: 0 }, revision: { type: Number, required: true, default: 0 }, lastEventIds: { type: [String], default: [] }, + unreadSince: { type: Date, default: null }, foldedThroughId: { type: Schema.Types.ObjectId, default: null }, }, { timestamps: true }); schema.index({ agentName: 1, instanceId: 1, podId: 1 }, { unique: true }); +schema.index({ unreadCount: 1, unreadSince: 1 }); const AgentDigestState: Model = mongoose.model('AgentDigestState', schema); export default AgentDigestState; diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts index 7b024cbcac..027d5e6605 100644 --- a/backend/services/agentDigestFoldService.ts +++ b/backend/services/agentDigestFoldService.ts @@ -10,6 +10,7 @@ type FoldCandidate = { instanceId?: string; podId: unknown; status: string; + createdAt?: Date; }; /** @@ -65,7 +66,7 @@ export async function foldExpiredDigests(threshold: Date): Promise { let recorded = false; for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { // eslint-disable-next-line no-await-in-loop - const state = await AgentDigestState.findOne(scope).select('foldedThroughId').lean(); + const state = await AgentDigestState.findOne(scope).select('foldedThroughId unreadCount').lean(); const cursor = state?.foldedThroughId; if (cursor && String(cursor) >= idString) { recorded = true; @@ -75,7 +76,8 @@ export async function foldExpiredDigests(threshold: Date): Promise { try { // eslint-disable-next-line no-await-in-loop await AgentDigestState.create({ - ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], foldedThroughId: id, + ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], + unreadSince: candidate.createdAt || new Date(), foldedThroughId: id, }); recorded = true; } catch (err) { @@ -84,10 +86,13 @@ export async function foldExpiredDigests(threshold: Date): Promise { } else { // eslint-disable-next-line no-await-in-loop const result = await AgentDigestState.updateOne( - { ...scope, foldedThroughId: cursor ?? null }, + { ...scope, foldedThroughId: cursor ?? null, unreadCount: state.unreadCount }, { $inc: { unreadCount: 1, revision: 1 }, - $set: { foldedThroughId: id }, + $set: { + foldedThroughId: id, + ...(state.unreadCount === 0 ? { unreadSince: candidate.createdAt || new Date() } : {}), + }, $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, }, ); @@ -125,7 +130,7 @@ export async function settleFoldedDigest( // the next turn may see some IDs twice, but never loses an unseen event. await AgentDigestState.updateOne( { ...scope, revision }, - { $set: { unreadCount: 0, lastEventIds: [] }, $inc: { revision: 1 } }, + { $set: { unreadCount: 0, lastEventIds: [], unreadSince: null }, $inc: { revision: 1 } }, ); } diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index 91e84b6fcc..a0f03f9249 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -1135,7 +1135,7 @@ class SchedulerService { _id: { agentName: '$agentName', instanceId: '$instanceId', podId: '$podId' }, } }, ]) as Array<{ _id: { agentName: string; instanceId: string; podId: unknown } }>; - const foldedGroups = await AgentDigestState.find({ unreadCount: { $gt: 0 }, updatedAt: { $lte: threshold } }) + const foldedGroups = await AgentDigestState.find({ unreadCount: { $gt: 0 }, unreadSince: { $lte: threshold } }) .select('agentName instanceId podId').lean() as Array<{ agentName: string; instanceId: string; podId: unknown }>; const groups = [...new Map([...rawGroups.map((row) => row._id), ...foldedGroups] .map((scope) => [`${scope.agentName}:${scope.instanceId}:${scope.podId}`, { _id: scope }])).values()]; From 67463156f101c24064ce6632ac820557d987191c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:35:11 -0700 Subject: [PATCH 14/28] Expose catch-up context on contentless legacy wakes Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../services/agentEventService.deliveryNonce.test.js | 12 ++++++++++++ backend/services/agentEventService.ts | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 931050ae02..bd25220508 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -143,6 +143,18 @@ describe('legacy ambient digest bridge', () => { expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); }); + test('a legacy wake without content still exposes digest to the consumer', async () => { + await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', content: 'ambient note' }, + }); + await seedEvent(); // payload.text only; older consumers read payload.content. + + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(delivered.payload.content).toContain('Alice: ambient note'); + }); + test('a claim stand-down does not consume raw or folded digest', async () => { const ambient = await AgentEvent.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 83fda19751..336dea3f58 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1430,12 +1430,14 @@ class AgentEventService { lines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${rollup.lastEventIds.join(', ')})`); } const summary = `[Since you last looked: ${raw.length + (rollup?.unreadCount || 0)} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`; + const baseContent = typeof event.payload?.content === 'string' ? event.payload.content : ''; event.payload = { ...(event.payload || {}), // Existing drivers consume content; the additive field also lets a new // driver render the frame separately without parsing the wake text. - ...(typeof event.payload?.content === 'string' - ? { content: `${event.payload.content}\n\n${summary}` } : {}), + content: baseContent ? `${baseContent}\n\n${summary}` : summary, + ...(typeof event.payload?.prompt === 'string' + ? { prompt: `${event.payload.prompt}\n\n${summary}` } : {}), digestSummary: summary, }; } From 08bdf49cf319f4279164bec0712ca67fd0c40abe Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:38:11 -0700 Subject: [PATCH 15/28] Preserve digest across refused and no-turn wakes Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 24 +++++++++++++++++++ .../agentEventService.lifecycle.test.js | 15 +++++++++++- .../agentMentionService.wakeOnMessage.test.js | 21 ++++++++++++++++ .../schedulerService.heartbeatLease.test.js | 14 +++++++++++ backend/services/agentEventService.ts | 18 ++++++++++---- backend/services/agentMentionService.ts | 6 +++-- backend/services/schedulerService.ts | 4 +++- 7 files changed, 93 insertions(+), 9 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index bd25220508..dfc21be35c 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -88,6 +88,8 @@ describe('legacy ambient digest bridge', () => { agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, }); expect(page.events.map((item) => String(item._id))).toEqual([String(wake._id), String(digest._id)]); + expect((await AgentEvent.findById(wake._id).lean()).attempts).toBe(1); + expect((await AgentEvent.findById(digest._id).lean()).attempts).toBe(0); expect((await AgentEventService.listInboxPage({ agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, })).events).toEqual([]); @@ -173,6 +175,28 @@ describe('legacy ambient digest bridge', () => { expect((await AgentDigestState.findOne({ agentName: AGENT, podId: POD_ID }).lean()).unreadCount).toBe(2); }); + test('repeated refused wakes do not exhaust the digest delivery cap', async () => { + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'still unread' }, + }); + for (let index = 0; index < 4; index += 1) { + const wake = await seedEvent(); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(wake._id), String(digest._id)]); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action', reason: 'claim-held' }, page.events[0].deliveryNonce); + await AgentEvent.updateOne({ _id: digest._id }, { + $set: { status: 'pending', deliveryNonce: null, deliveredAt: null }, + }); + } + expect((await AgentEvent.findById(digest._id).lean()).attempts).toBe(0); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + }); + test('a legacy wake carries a folded count and clears only the revision it showed', async () => { await AgentDigestState.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 6e25626500..6fe35c4f2c 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -507,7 +507,7 @@ describe('driver paths reach a terminal state', () => { const digest = { _id: 'evt-d', payload: { username: 'alice', messageId: '42', content: 'daily update' } }; AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); - runAgent.mockResolvedValue({ status: 'succeeded' }); + runAgent.mockResolvedValue({ status: 'succeeded', runId: 'run-1', totalTurns: 1 }); await enqueueNative(); @@ -522,6 +522,19 @@ describe('driver paths reach a terminal state', () => { runAgent.mockResolvedValue({ status: 'failed' }); await enqueueNative(); expect(AgentEvent.updateMany).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + runAgent.mockResolvedValue({ status: 'succeeded', runId: '', totalTurns: 0 }); + const acknowledge = jest.spyOn(AgentEventService, 'acknowledge').mockResolvedValue({}); + await enqueueNative(); + expect(AgentEvent.updateMany).not.toHaveBeenCalled(); + expect(acknowledge).toHaveBeenCalledWith( + 'evt-n', 'clerk', 'default', + { outcome: 'no_action', reason: 'native-no-turn' }, + null, 'native', + ); + acknowledge.mockRestore(); }); test('a podId shaped like a query operator is never looked up or run in-process', async () => { diff --git a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js index ddb8faeda7..cc5bb847ac 100644 --- a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js +++ b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js @@ -69,6 +69,7 @@ const AgentProfile = require('../../../models/AgentProfile'); const Pod = require('../../../models/Pod'); const User = require('../../../models/User'); const AgentEvent = require('../../../models/AgentEvent'); +const messageDeliveryModeService = require('../../../services/messageDeliveryModeService'); const install = (agentName, { optIn = false, instanceId = 'default' } = {}) => ({ agentName, @@ -195,6 +196,26 @@ describe('wake-on-message (ADR-018 D8)', () => { expect(wakeCalls()[0].payload.content).toBe('nobody asked anyone anything'); }); + test('an addressed lookup outage keeps ambient receipt without waking default-off seats', async () => { + mockInstallations([install('seat-a'), install('seat-b', { optIn: true })]); + const lookup = jest.spyOn(messageDeliveryModeService, 'addressedMessageTargets') + .mockRejectedValue(new Error('lookup unavailable')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const res = await AgentMentionService.enqueueMentions({ + podId: 'pod-1', + message: { content: 'TASK-414 update', id: 'msg-outage' }, + userId: 'user-1', username: 'alice', + }); + expect(res.woken).toEqual(['seat-b']); + expect(wakeCalls().map((call) => [call.agentName, call.deliveryMode])) + .toEqual([['seat-a', 'digest'], ['seat-b', 'wake']]); + } finally { + lookup.mockRestore(); + warn.mockRestore(); + } + }); + test('a mention-enqueued target is not double-delivered; other opt-ins still wake', async () => { mockInstallations([ install('seat-a', { optIn: true }), diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index 9a8406123d..a21f1959f7 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -140,4 +140,18 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); expect(enqueue).toHaveBeenCalledTimes(1); }); + + it('does not wake an installation whose autonomy is disabled', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId(), + config: { autonomy: { enabled: false } } }); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: new Date(NOW.getTime() - 61 * 60000) }); + + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 336dea3f58..dc04ca8d1a 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1516,7 +1516,9 @@ class AgentEventService { const result = await runAgent(nativeInstallation, { type, eventId: eventIdStr, payload }); // Leave digest unread when the native turn fails. On a successful turn, // mark only the rows actually included; older backlog remains pending. - if (result?.status === 'succeeded' && digest.length) { + const modelTurnRan = result?.status === 'succeeded' + && Boolean(result.runId) && Number(result.totalTurns) > 0; + if (modelTurnRan && digest.length) { await AgentEvent.updateMany( { _id: { $in: digest.map((item) => item._id) }, status: 'pending', deliveryMode: 'digest' }, { $set: { status: 'acked' } }, @@ -1533,7 +1535,7 @@ class AgentEventService { // actually succeeded as 'failed'. The rejection handler here is scoped // to runAgent alone. Both terminal calls swallow their own errors so // this stays fire-and-forget for the caller. - (runResult: { status?: string; error?: string }) => (runResult?.status === 'failed' + (runResult: { status?: string; error?: string; runId?: string; totalTurns?: number }) => (runResult?.status === 'failed' ? this.recordFailure( eventIdStr, agentName, @@ -1545,7 +1547,9 @@ class AgentEventService { eventIdStr, agentName, instanceId, - { outcome: 'acknowledged', reason: 'native-runtime-completed' }, + runResult?.runId && Number(runResult.totalTurns) > 0 + ? { outcome: 'acknowledged', reason: 'native-runtime-completed' } + : { outcome: 'no_action', reason: 'native-no-turn' }, nativeDeliveryId, 'native', ).catch((ackErr: Error) => { @@ -1885,7 +1889,10 @@ class AgentEventService { // this delivery belongs to is the only one whose ack can land. deliveryNonce: mintDeliveryNonce(), }, - $inc: { attempts: 1 }, + // Digest is context for another turn. A refused wake can attach the + // same digest repeatedly without consuming it, so charging those + // attachments against the poison-event attempt cap would lose it. + ...(index < wakeCandidates.length ? { $inc: { attempts: 1 } } : {}), }, { new: true }, ).lean() as EventDoc | null; @@ -2023,7 +2030,8 @@ class AgentEventService { // time (`list()`); this update brings the agent's read-checkpoint up // to that revision. $max makes it monotone — out-of-order acks across // events still converge correctly. - if (result && typeof result.memoryRevisionAtDelivery === 'number' && result.memoryRevisionAtDelivery > 0) { + if (result && consumedDigest + && typeof result.memoryRevisionAtDelivery === 'number' && result.memoryRevisionAtDelivery > 0) { try { await AgentMemory.updateOne( { agentName: safeAgentName, instanceId: safeInstanceId }, diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 11959f14ec..06bbbbfd4e 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -1427,8 +1427,10 @@ const enqueueWakeOnMessage = async ({ botUserIds: cachedBotUserIds ?? new Map(), }); } catch (error) { - console.warn('[message-delivery] addressed lookup failed (waking):', (error as Error).message); - addressedTargets = new Set(targets.map(installKey)); + console.warn('[message-delivery] addressed lookup failed (ambient fallback):', (error as Error).message); + // Keep receiving the message. A lookup outage cannot justify waking every + // installed seat; explicit mentions have already taken their own path. + addressedTargets = new Set(); cachedBotUserIds = cachedBotUserIds ?? new Map(); } if (threadRootId) { diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index a0f03f9249..3ea442bf2f 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -1149,7 +1149,9 @@ class SchedulerService { // A retired projection has no listener. Its old digest remains for GC, // but must not create a new wake every hour. // eslint-disable-next-line no-await-in-loop - const active = await AgentInstallation.exists({ ...scope, status: 'active' }); + const active = await AgentInstallation.exists({ + ...scope, status: 'active', 'config.autonomy.enabled': { $ne: false }, + }); if (!active) continue; // An existing wake will carry the digest. A recent completed wake says // the seat has already had a turn inside this 60-minute window. From 3563f1da6a4a0687f759d731289a08af7a033fc7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:39:31 -0700 Subject: [PATCH 16/28] Record receive and wake policy with no-turn regression Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.lifecycle.test.js | 15 +++++++++++++++ docs/adr/ADR-018-agent-attention-claims.md | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 6fe35c4f2c..a0868da669 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -83,6 +83,7 @@ const Task = require('../../../models/Task'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const AgentEventService = require('../../../services/agentEventService'); const { runAgent } = require('../../../services/nativeRuntimeService'); +const { settleFoldedDigest } = require('../../../services/agentDigestFoldService'); const { postToPublicAddress } = require('../../../services/outboundUrlGuard'); // Let fire-and-forget promise chains (webhook delivery, native settle) run. @@ -142,6 +143,20 @@ describe('attempts is a delivery counter with exactly one writer', () => { expect(update.$inc).toBeUndefined(); }); + test('a no-turn ack leaves folded digest and the memory read cursor unread', async () => { + AgentEvent.findOneAndUpdate.mockResolvedValue({ + _id: 'evt-1', agentName: 'pixel', instanceId: 'default', podId: POD_P1, + type: 'heartbeat', status: 'acked', digestRollupRevision: 3, + memoryRevisionAtDelivery: 7, + }); + + await AgentEventService.acknowledge('evt-1', 'pixel', 'default', + { outcome: 'no_action', reason: 'native-no-turn' }); + + expect(settleFoldedDigest).not.toHaveBeenCalled(); + expect(AgentMemory.updateOne).not.toHaveBeenCalled(); + }); + test('recordFailure() does NOT increment either', async () => { AgentEvent.findOneAndUpdate.mockResolvedValue({ _id: 'evt-1', agentName: 'pixel', instanceId: 'default', podId: POD_P1, diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index c9973a89a6..1b067872a8 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -418,6 +418,14 @@ D8's "default **off**" becomes **on for every agent a person brings into a pod** Implementation: `backend/services/wakeDefaultService.ts` (the rule), the registry install route (where it is applied), and `agentMentionService.humanOnlyWake` (the human-only gate). +Receiving a message and waking for it are separate operations. An installation +with wake-on-message disabled still receives the message as durable digest +context. It does not run a turn for that message alone. An addressed reply, +claim, owned task or PR reference, explicit mention, or existing board wake can +start a turn and carry the unread digest. Existing wake +defaults remain as specified above; this amendment changes neither install +defaults nor the owner's ability to switch wake-on-message off. + > **Scope boundary.** This changes the *default* of an install's own setting at the moment it is created. It does **not** gate any wake on pod size, member count or workspace shape. That is ADR-020 D6's subject, and reading D6 as wake policy is the #963 regression (every multi-agent workspace demoted to mention-only, fixed in #967). Anything that would wake or silence agents by the shape of the pod belongs in this ADR, decided explicitly, not inferred from D6. ### D8.2 — AMENDMENT (2026-10-23): a board change wakes only the seat whose work changed @@ -440,6 +448,15 @@ pickup of work nobody asked for** — the stalled task at 3am. Those are different jobs; collapsing them would quietly delete the second, which is the one that makes agents feel alive rather than reactive. +### D9.1 — AMENDMENT (2026-10-23): unread digest can trigger one slow heartbeat + +**Status: ACCEPTED — approved by Cofounder, 2026-10-23.** A digest-only +delivery does not start a model turn. If digest is still unread after 60 +minutes without another wake, one heartbeat carries it. No unread digest means +no digest heartbeat. An existing pending wake or a recent completed wake +prevents a duplicate; a failed or refused turn leaves digest unread. This +conditional read opportunity is distinct from D9's autonomous pickup of work. + ### D10 — Pod-level shared memory is a separate ADR, provider-pluggable Raised in the same conversation, deliberately not designed here: coordination From 01567192a95d42553728881b59e7dd8266fb06d2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:14:31 -0700 Subject: [PATCH 17/28] Cover claimed dependencies and completed-row note routing Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../taskEventService.notifyPodAgents.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js index 062df0f29e..65f3ea3c4d 100644 --- a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js +++ b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js @@ -422,6 +422,31 @@ describe('notifyPodAgents', () => { expect(AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName)).toEqual(['sprint-impl']); }); + it('wakes a claimed dependent row through the bot principal', async () => { + mockInstalls([install('scout', HUMAN_ID), install('sprint-impl', HUMAN_ID)]); + Task.find.mockReturnValueOnce({ lean: async () => [{ assignee: null, claimedBy: AGENT_ID }] }); + User.findById.mockReturnValue({ select: () => ({ lean: async () => ({ + isBot: true, botMetadata: { agentName: 'sprint-impl', instanceId: 'default' }, + }) }) }); + + await notifyPodAgents(POD_ID, task({ assignee: null, status: 'done' }), 'updated', + { userId: HUMAN_ID, isAgent: false }, { status: 'claimed' }); + + const bySeat = Object.fromEntries(AgentEventService.enqueue.mock.calls.map(([arg]) => [arg.agentName, arg])); + expect(bySeat['sprint-impl'].deliveryMode).toBeUndefined(); + expect(bySeat.scout.deliveryMode).toBe('digest'); + }); + + it('does not repeat dependency wakes for a later note on an already done row', async () => { + mockInstalls([install('sprint-impl', HUMAN_ID)]); + + await notifyPodAgents(POD_ID, task({ assignee: null, status: 'done' }), 'updated', + { userId: HUMAN_ID, isAgent: false }); + + expect(Task.find).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls[0][0].deliveryMode).toBe('digest'); + }); + it('stays quiet on delete, where the wake would spend a turn finding nothing', async () => { mockInstalls([install('scout', HUMAN_ID)]); From 6dd3fa3c8721b2a91ead85f922f5dfbc007d7bbf Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:07 -0700 Subject: [PATCH 18/28] Keep unassigned work due after digest-only board delivery Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../service/taskEventService.offeredAt.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/__tests__/service/taskEventService.offeredAt.test.js b/backend/__tests__/service/taskEventService.offeredAt.test.js index a996a8f234..0f83ba3f45 100644 --- a/backend/__tests__/service/taskEventService.offeredAt.test.js +++ b/backend/__tests__/service/taskEventService.offeredAt.test.js @@ -119,6 +119,20 @@ afterAll(async () => { }); describe('kernel sweep offeredAt filter', () => { + it('still nudges unassigned work after its board change reached only digest', async () => { + const row = await createTask({ taskId: 'TASK-UNASSIGNED', updatedAt: new Date(NOW.getTime() - 30000) }); + + await notifyPodAgents(POD_ID, row, 'created', { isAgent: false }); + + expect(AgentEventService.enqueue.mock.calls[0][0].deliveryMode).toBe('digest'); + expect((await Task.findById(row._id).lean()).offeredAt).toBeUndefined(); + const sweep = await KernelWorkSweepService.wakeForFoundWork(NOW); + expect(sweep.woken).toBe(1); + expect(AgentEventService.enqueue.mock.calls[1][0].payload.foundWorkAt).toEqual(NOW); + const after = await Task.findById(row._id).lean(); + expect(after.offeredAt).toEqual(after.updatedAt); + }); + it('still offers a revision deferred past the ten-minute scan window by the hourly cap', async () => { const old = await createTask({ taskId: 'TASK-DEFERRED', updatedAt: new Date(NOW.getTime() - 45 * 60 * 1000), From 206ae2d54584c3a5770e24e4824de23addc928f6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:56:16 -0700 Subject: [PATCH 19/28] Summarize ambient digest as unread conversations Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 11 ++-- .../agentEventService.lifecycle.test.js | 14 +++-- .../agentMentionService.threadScoping.test.js | 2 + backend/services/agentEventService.ts | 58 ++++++++++++------- backend/services/agentMentionService.ts | 1 + cli/__tests__/run-loop.test.mjs | 43 +++++++++++++- cli/src/commands/agent.js | 45 +++++++------- docs/adr/ADR-018-agent-attention-claims.md | 10 ++++ 8 files changed, 130 insertions(+), 54 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index dfc21be35c..44a00e8b6d 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -124,8 +124,10 @@ describe('legacy ambient digest bridge', () => { const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); expect(String(delivered._id)).toBe(String(wake._id)); - expect(delivered.payload.digestSummary).toContain('Alice (message 42): ambient note'); - expect(delivered.payload.content).toContain('Alice (message 42): ambient note'); + expect(delivered.payload.digestSummary).toContain('pod conversation: 1 unread'); + expect(delivered.payload.digestSummary).not.toContain('ambient note'); + expect(delivered.payload.content).toContain('pod conversation: 1 unread'); + expect(delivered.payload.content).not.toContain('ambient note'); expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, @@ -154,7 +156,8 @@ describe('legacy ambient digest bridge', () => { await seedEvent(); // payload.text only; older consumers read payload.content. const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); - expect(delivered.payload.content).toContain('Alice: ambient note'); + expect(delivered.payload.content).toContain('pod conversation: 1 unread'); + expect(delivered.payload.content).not.toContain('ambient note'); }); test('a claim stand-down does not consume raw or folded digest', async () => { @@ -204,7 +207,7 @@ describe('legacy ambient digest bridge', () => { }); const wake = await seedEvent(); const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); - expect(delivered.payload.digestSummary).toContain('3 older updates'); + expect(delivered.payload.digestSummary).toContain('older pod activity: 3 unread'); await AgentDigestState.updateOne({ agentName: AGENT, podId: POD_ID }, { $inc: { unreadCount: 1, revision: 1 } }); diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index a0868da669..71789ab44d 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -423,9 +423,10 @@ describe('driver paths reach a terminal state', () => { test('a webhook wake carries and settles ambient context only after accepted delivery', async () => { const ambient = { _id: 'ambient-1', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, - payload: { username: 'Alice', messageId: '42', content: 'ambient note' }, + payload: { username: 'Alice', messageId: '42', threadRootId: 40, content: 'ambient note' }, }; - AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [ambient] }) }) }); + const duplicate = { ...ambient, _id: 'ambient-2' }; + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [ambient, duplicate] }) }) }); AgentEvent.updateOne.mockResolvedValue({ matchedCount: 1 }); AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); AgentEvent.create.mockResolvedValue({ @@ -443,9 +444,11 @@ describe('driver paths reach a terminal state', () => { await flush(); const body = JSON.parse(postToPublicAddress.mock.calls[0][1].body); - expect(body.payload.digestSummary).toContain('Alice (message 42): ambient note'); + expect(body.payload.digestSummary).toContain('thread 40: 1 unread'); + expect(body.payload.digestSummary).toContain('1 ambient update in 1 conversation'); + expect(body.payload.digestSummary).not.toContain('ambient note'); expect(AgentEvent.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ _id: { $in: ['ambient-1'] }, deliveryMode: 'digest' }), + expect.objectContaining({ _id: { $in: ['ambient-1', 'ambient-2'] }, deliveryMode: 'digest' }), expect.objectContaining({ $set: expect.objectContaining({ status: 'acked' }) }), ); }); @@ -526,7 +529,8 @@ describe('driver paths reach a terminal state', () => { await enqueueNative(); - expect(runAgent.mock.calls[0][1].payload.content).toContain('alice (message 42): daily update'); + expect(runAgent.mock.calls[0][1].payload.content).toContain('pod conversation: 1 unread'); + expect(runAgent.mock.calls[0][1].payload.content).not.toContain('daily update'); expect(AgentEvent.updateMany).toHaveBeenCalledWith( { _id: { $in: ['evt-d'] }, status: 'pending', deliveryMode: 'digest' }, { $set: { status: 'acked' } }, diff --git a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js index c101199566..3dacf4ddd4 100644 --- a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js +++ b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js @@ -200,6 +200,8 @@ describe('the hook fires only for threaded messages', () => { test('the camelCase spelling works too — callers pass either', async () => { await send({ id: 'm3', content: 'in a thread', threadRootId: 101 }); expect(narrowToThread).toHaveBeenCalledTimes(1); + expect(byType('message.posted')).not.toHaveLength(0); + expect(byType('message.posted').every((event) => event.payload.threadRootId === 101)).toBe(true); }); }); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index dc04ca8d1a..3f51c8bd0b 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -52,6 +52,37 @@ const mintDeliveryNonce = (): string => crypto.randomBytes(16).toString('hex'); const OBJECT_ID_HEX = /^[a-f\d]{24}$/i; +// A wake advertises unread conversations, not message text. The seat can +// choose which threads to pull with commonly_get_messages after it wakes. +const digestConversationFrame = ( + rows: Array<{ payload?: Record }>, + olderCount = 0, +): string => { + const counts = new Map(); + if (olderCount > 0) counts.set('older pod activity', olderCount); + const seen = new Set(); + for (const row of rows) { + const payload = row.payload || {}; + const sourceId = payload.messageId != null ? `message:${payload.messageId}` + : (typeof payload.taskId === 'string' + ? `task:${payload.taskId}:${String(payload.taskKind || '')}:${String(payload.content || '')}` : null); + if (sourceId && seen.has(sourceId)) continue; + if (sourceId) seen.add(sourceId); + const thread = payload.threadRootId; + const task = payload.taskId; + const label = typeof thread === 'number' && Number.isInteger(thread) && thread > 0 + ? `thread ${thread}` + : (typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'); + counts.set(label, (counts.get(label) || 0) + 1); + } + const lines = [...counts].slice(0, 8).map(([label, count]) => `- ${label}: ${count} unread`); + const omitted = Math.max(0, counts.size - 8); + if (omitted) lines.push(`- ${omitted} more conversations omitted`); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + return `[Since you last looked: ${total} ambient update${total === 1 ? '' : 's'} in ${counts.size} conversation${counts.size === 1 ? '' : 's'}; context only, no reply needed.]\n` + + `${lines.join('\n')}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`; +}; + // TASK-264: how many stuck in-process events one sweep re-runs, and how long a // 'queued'/'running' AgentRun may sit before it counts as abandoned. const STUCK_REDISPATCH_BATCH = 50; @@ -1420,16 +1451,7 @@ class AgentEventService { if (!saved.matchedCount) return; event.digestEventIds = ids; event.digestRollupRevision = rollup?.revision ?? null; - const lines = raw.map((row) => { - const who = String(row.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); - const body = String(row.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); - const messageId = row.payload?.messageId; - return `- ${who}${messageId ? ` (message ${String(messageId)})` : ''}: ${body}`; - }); - if (rollup?.unreadCount) { - lines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${rollup.lastEventIds.join(', ')})`); - } - const summary = `[Since you last looked: ${raw.length + (rollup?.unreadCount || 0)} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`; + const summary = digestConversationFrame(raw, rollup?.unreadCount || 0); const baseContent = typeof event.payload?.content === 'string' ? event.payload.content : ''; event.payload = { ...(event.payload || {}), @@ -1498,20 +1520,12 @@ class AgentEventService { } catch (digestErr) { console.warn('[native-runtime] digest lookup failed:', (digestErr as Error).message); } - const lines = digest.map((item) => { - const who = String(item.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); - const content = String(item.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); - const messageId = item.payload?.messageId; - return `- ${who}${messageId ? ` (message ${messageId})` : ''}: ${content}`; - }); const baseContent = String(event.payload?.content || event.payload?.prompt || event.payload?.text || ''); - const foldedLine = folded - ? `- ${folded.unreadCount} older updates (last event IDs: ${folded.lastEventIds.join(', ')})` - : null; - const allLines = foldedLine ? [foldedLine, ...lines] : lines; - const payload = allLines.length ? { + const summary = digest.length || folded?.unreadCount + ? digestConversationFrame(digest, folded?.unreadCount || 0) : null; + const payload = summary ? { ...event.payload, - content: `${baseContent}\n\n[Since you last looked: ambient updates; context only.]\n${allLines.join('\n')}`, + content: `${baseContent}\n\n${summary}`, } : event.payload; const result = await runAgent(nativeInstallation, { type, eventId: eventIdStr, payload }); // Leave digest unread when the native turn fails. On a successful turn, diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 06bbbbfd4e..dbb1c6ed3a 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -1540,6 +1540,7 @@ const enqueueWakeOnMessage = async ({ deliveryMode, payload: { messageId: authorFrame.messageId, + ...(threadRootId ? { threadRootId } : {}), // Inline cue, not only metadata — the flag below is for the // wrapper's claim decision; the model reads the content. content: repliesToYou ? `${REPLIES_TO_YOU_FRAME}\n${built}` : built, diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 67a218daf3..a5a1d705a4 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -327,13 +327,50 @@ describe('performRun', () => { stop(); expect(spawn).toHaveBeenCalledTimes(1); expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 4 ambient updates'); - expect(spawn.mock.calls[0][0]).toContain('3 older updates (last event IDs: old-1, old-2)'); - expect(spawn.mock.calls[0][0]).toContain('- alice (message 42): daily update'); + expect(spawn.mock.calls[0][0]).toContain('older pod activity: 3 unread'); + expect(spawn.mock.calls[0][0]).toContain('pod conversation: 1 unread'); + expect(spawn.mock.calls[0][0]).toContain('8 conversations, 5 messages each, 20 messages total'); + expect(spawn.mock.calls[0][0]).not.toContain('daily update'); expect(spawn.mock.calls[0][0]).not.toContain('Event 2/2: message.posted'); expect(post.mock.calls.filter(([route]) => route.endsWith('/claim'))).toHaveLength(0); expect(ackPosts(post)).toHaveLength(2); }); + test('digest notices group unread threads, dedupe one source, and batch the next read for three seconds', async () => { + const events = [ + makeEvent({ _id: 'wake', type: 'chat.mention', payload: { content: 'review this' } }), + makeEvent({ _id: 'ambient-a', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '41', threadRootId: 40, content: 'secret first body' } }), + makeEvent({ _id: 'ambient-a-copy', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '41', threadRootId: 40, content: 'secret first body' } }), + makeEvent({ _id: 'ambient-b', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '42', threadRootId: 40, content: 'secret second body' } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: events.length }; + }); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'handled' })); + const schedule = jest.fn(); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', intervalMs: 100, setTimeoutImpl: schedule, + }); + await drainMicrotasks(); + stop(); + expect(spawn).toHaveBeenCalledTimes(1); + const prompt = spawn.mock.calls[0][0]; + expect(prompt).toContain('2 ambient updates in 1 conversation'); + expect(prompt).toContain('thread 40: 2 unread'); + expect(prompt).not.toContain('secret first body'); + expect(prompt).not.toContain('secret second body'); + expect(schedule).toHaveBeenCalledWith(expect.any(Function), 3000); + }); + test('a digest-only page never starts a turn or acknowledges the ambient event', async () => { const event = makeEvent({ deliveryMode: 'digest', type: 'message.posted', payload: { content: 'ambient', messageId: '42' } }); @@ -1599,7 +1636,7 @@ describe('performRun', () => { }; await runNext(5000); // first failure → second probe await runNext(10000); // second failure → recovery probe - await runNext(0); // success → look again at once (TASK-343), not after the interval + await runNext(3000); // success → mid-turn notice batch window handle.stop(); expect(spawn).toHaveBeenCalledTimes(4); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index bc7f9e4cd1..3fd6e72500 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2027,25 +2027,31 @@ export const performRun = ({ return `${prefix}\n${claimContext}${body}`; }); const rollup = wakeEntries.find((entry) => entry.event.payload?.digestRollup)?.event.payload?.digestRollup; - const visibleRawLimit = rollup?.unreadCount > 0 ? 7 : 8; - const digestLines = digestEntries.slice(-visibleRawLimit).map((entry) => { - const who = String(entry.event.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); - const body = String(entry.event.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); - const id = entry.event.payload?.messageId; - return `- ${who}${id ? ` (message ${id})` : ''}: ${body}`; - }); - if (rollup?.unreadCount > 0) { - const ids = Array.isArray(rollup.lastEventIds) ? rollup.lastEventIds.slice(-8) : []; - digestLines.unshift(`- ${rollup.unreadCount} older updates (last event IDs: ${ids.join(', ')})`); + const conversationCounts = new Map(); + if (rollup?.unreadCount > 0) conversationCounts.set('older pod activity', rollup.unreadCount); + const seenDigestSources = new Set(); + for (const entry of digestEntries) { + const { threadRootId, taskId, taskKind, messageId, content } = entry.event.payload || {}; + const sourceId = messageId != null ? `message:${messageId}` + : (typeof taskId === 'string' ? `task:${taskId}:${taskKind || ''}:${content || ''}` : null); + if (sourceId && seenDigestSources.has(sourceId)) continue; + if (sourceId) seenDigestSources.add(sourceId); + const label = Number.isInteger(Number(threadRootId)) && Number(threadRootId) > 0 + ? `thread ${threadRootId}` + : (/^TASK-\d+$/.test(String(taskId || '')) ? taskId : 'pod conversation'); + conversationCounts.set(label, (conversationCounts.get(label) || 0) + 1); } + const digestLines = [...conversationCounts].slice(0, 8) + .map(([label, count]) => `- ${label}: ${count} unread`); + const digestCount = [...conversationCounts.values()].reduce((sum, count) => sum + count, 0); const omitted = Math.max(0, inboxCount - entries.length); return [ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. ` + 'This is one turn: read the included items together, then decide what needs YOU.]', ...bodies, ...(digestEntries.length > 0 || rollup?.unreadCount > 0 ? [ - `[Since you last looked: ${digestEntries.length + (rollup?.unreadCount || 0)} ambient update${digestEntries.length + (rollup?.unreadCount || 0) === 1 ? '' : 's'}; context only, no reply needed.] -${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${digestEntries.length - visibleRawLimit} earlier updates omitted` : ''}`, + `[Since you last looked: ${digestCount} ambient update${digestCount === 1 ? '' : 's'} in ${conversationCounts.size} conversation${conversationCounts.size === 1 ? '' : 's'}; context only, no reply needed.] +${digestLines.join('\n')}${conversationCounts.size > 8 ? `\n- ${conversationCounts.size - 8} more conversations omitted` : ''}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`, ] : []), ...(omitted > 0 ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`] @@ -2391,6 +2397,7 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges const tick = async () => { if (!running) return; let nextPollDelayMs = intervalMs; + let completedModelTurn = false; try { // ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The // backend returns one pod's oldest pending batch, so every delivered row @@ -2504,13 +2511,9 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges )); if (batchSpawned) { consecutiveSpawnFailures = 0; - // A turn is exactly when events pile up: whoever typed while the - // adapter was running is already waiting. Sleeping the interval on - // top of the turn charges them for it twice (TASK-343). Only a - // batch that actually spawned skips the wait; an idle poll and a - // batch that spawned nothing keep the interval, so this cannot - // become a spin. - nextPollDelayMs = 0; + // Notices arriving mid-turn get one bounded collection window. + // Idle polls and batches with no model turn keep their cadence. + completedModelTurn = true; } for (const entry of batch.entries) { const event = entry.event; @@ -2688,7 +2691,9 @@ ${digestLines.join('\n')}${digestEntries.length > visibleRawLimit ? `\n- ${diges } onError?.(err); } - if (running) setTimeoutImpl(tick, nextPollDelayMs); + // Notices landing during a turn share one three-second collection window + // before the next inbox read, independent of the idle polling cadence. + if (running) setTimeoutImpl(tick, completedModelTurn ? 3000 : nextPollDelayMs); }; tick(); diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index 1b067872a8..7d17f11bd1 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -457,6 +457,16 @@ no digest heartbeat. An existing pending wake or a recent completed wake prevents a duplicate; a failed or refused turn leaves digest unread. This conditional read opportunity is distinct from D9's autonomous pickup of work. +### D9.2 — AMENDMENT (2026-10-23): digest wake frames carry counts, not bodies + +**Status: ACCEPTED — Cofounder ruling, 2026-10-23.** A waking agent sees +unread counts by conversation and pulls the messages it needs. The frame +contains no ambient message bodies. Resume catch-up starts with the oldest +unread conversation and stops after eight conversations, five messages per +conversation, or twenty messages total. Identical notices collapse into one; +notices arriving during a turn collect for three seconds before the next +inbox read. D8's receive/wake boundary still applies. + ### D10 — Pod-level shared memory is a separate ADR, provider-pluggable Raised in the same conversation, deliberately not designed here: coordination From 428e1ac6f46338ef15256c26897828e5ba5231a9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:00:01 -0700 Subject: [PATCH 20/28] Keep conversation counts when digest events expire Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../services/agentDigestFoldService.test.js | 26 ++++++++++++-- backend/models/AgentDigestState.ts | 4 +++ backend/services/agentDigestFoldService.ts | 36 ++++++++++++++++--- backend/services/agentEventService.ts | 17 ++++++--- cli/__tests__/run-loop.test.mjs | 9 ++--- cli/src/commands/agent.js | 12 ++++++- 6 files changed, 89 insertions(+), 15 deletions(-) diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js index bacecf6e63..7ffccd0c57 100644 --- a/backend/__tests__/unit/services/agentDigestFoldService.test.js +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -19,9 +19,10 @@ describe('expired digest fold', () => { await clearMongoDb(); }); - const seed = async (createdAt, podId) => AgentEvent.collection.insertOne({ + const seed = async (createdAt, podId, payload = {}) => AgentEvent.collection.insertOne({ agentName: 'flint', instanceId: 'default', podId, type: 'message.posted', deliveryMode: 'digest', status: 'pending', + payload, attempts: 0, createdAt, updatedAt: createdAt, }); @@ -36,10 +37,29 @@ describe('expired digest fold', () => { expect(state.unreadCount).toBe(2); expect(state.unreadSince).toEqual(new Date(NOW.getTime() - 2 * 60 * 60 * 1000)); expect(state.lastEventIds).toEqual([String(first.insertedId), String(second.insertedId)]); + expect(state.conversations).toEqual([{ label: 'pod conversation', count: 2 }]); expect(await AgentEvent.exists({ _id: recent.insertedId })).toBeTruthy(); expect(await AgentEvent.countDocuments({ podId })).toBe(1); }); + it('retains conversation counts and order after full event bodies expire', async () => { + const podId = new mongoose.Types.ObjectId(); + const old = new Date(NOW.getTime() - 2 * 60 * 60 * 1000); + await seed(old, podId, { threadRootId: 40, content: 'first body' }); + await seed(old, podId, { threadRootId: 40, content: 'second body' }); + await seed(old, podId, { taskId: 'TASK-7', content: 'board text' }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(3); + const state = await readFoldedDigest({ agentName: 'flint', instanceId: 'default', podId }); + expect(state.conversations).toEqual([ + { label: 'thread 40', count: 2 }, + { label: 'TASK-7', count: 1 }, + ]); + expect(state.otherCount).toBe(0); + expect(JSON.stringify(state)).not.toContain('body'); + expect(JSON.stringify(state)).not.toContain('board text'); + }); + it('folds old unacked deliveries and failures before ordinary GC can delete them', async () => { const podId = new mongoose.Types.ObjectId(); const delivered = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); @@ -138,7 +158,9 @@ describe('expired digest fold', () => { const wake = await AgentEvent.create({ ...scope, type: 'heartbeat', status: 'delivered', deliveryMode: 'wake', attempts: 1, deliveryNonce: 'nonce-1' }); - expect(await AgentEventService.attachFoldedDigest(wake)).toMatchObject({ unreadCount: 1 }); + expect(await AgentEventService.attachFoldedDigest(wake)).toMatchObject({ + unreadCount: 1, conversations: [{ label: 'pod conversation', count: 1 }], + }); expect((await AgentEvent.findById(wake._id).lean()).digestRollupRevision).toBe(1); await AgentEventService.acknowledge(wake._id, 'flint', 'default', { outcome: 'no_action' }, 'nonce-1', 'cli'); expect(await readFoldedDigest(scope)).toBeNull(); diff --git a/backend/models/AgentDigestState.ts b/backend/models/AgentDigestState.ts index a9ca575f0b..9bf5db97eb 100644 --- a/backend/models/AgentDigestState.ts +++ b/backend/models/AgentDigestState.ts @@ -8,6 +8,8 @@ export interface IAgentDigestState extends Document { unreadCount: number; revision: number; lastEventIds: string[]; + conversations: Array<{ label: string; count: number }>; + otherCount: number; unreadSince?: Date | null; foldedThroughId?: Types.ObjectId | null; updatedAt: Date; @@ -20,6 +22,8 @@ const schema = new Schema({ unreadCount: { type: Number, required: true, default: 0 }, revision: { type: Number, required: true, default: 0 }, lastEventIds: { type: [String], default: [] }, + conversations: { type: [{ label: String, count: Number, _id: false }], default: [] }, + otherCount: { type: Number, default: 0 }, unreadSince: { type: Date, default: null }, foldedThroughId: { type: Schema.Types.ObjectId, default: null }, }, { timestamps: true }); diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts index 027d5e6605..b305d4e46a 100644 --- a/backend/services/agentDigestFoldService.ts +++ b/backend/services/agentDigestFoldService.ts @@ -3,6 +3,14 @@ import AgentDigestState from '../models/AgentDigestState'; const FOLD_BATCH_SIZE = 100; const LAST_IDS_LIMIT = 8; +const CONVERSATION_LIMIT = 8; + +const conversationLabel = (payload?: Record): string => { + const thread = Number(payload?.threadRootId); + if (Number.isInteger(thread) && thread > 0) return `thread ${thread}`; + const task = payload?.taskId; + return typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'; +}; type FoldCandidate = { _id: unknown; @@ -11,6 +19,7 @@ type FoldCandidate = { podId: unknown; status: string; createdAt?: Date; + payload?: Record; }; /** @@ -63,10 +72,12 @@ export async function foldExpiredDigests(threshold: Date): Promise { } } const idString = String(id); + const label = conversationLabel(candidate.payload); let recorded = false; for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { // eslint-disable-next-line no-await-in-loop - const state = await AgentDigestState.findOne(scope).select('foldedThroughId unreadCount').lean(); + const state = await AgentDigestState.findOne(scope) + .select('foldedThroughId unreadCount conversations otherCount').lean(); const cursor = state?.foldedThroughId; if (cursor && String(cursor) >= idString) { recorded = true; @@ -77,6 +88,7 @@ export async function foldExpiredDigests(threshold: Date): Promise { // eslint-disable-next-line no-await-in-loop await AgentDigestState.create({ ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], + conversations: [{ label, count: 1 }], otherCount: 0, unreadSince: candidate.createdAt || new Date(), foldedThroughId: id, }); recorded = true; @@ -84,6 +96,15 @@ export async function foldExpiredDigests(threshold: Date): Promise { if ((err as { code?: number }).code !== 11000) throw err; } } else { + const conversations = [...(state.conversations || [])].map((item) => ({ + label: item.label, count: item.count, + })); + let otherCount = Math.max(state.otherCount || 0, + state.unreadCount - conversations.reduce((sum, item) => sum + item.count, 0)); + const existing = conversations.find((item) => item.label === label); + if (existing) existing.count += 1; + else if (conversations.length < CONVERSATION_LIMIT) conversations.push({ label, count: 1 }); + else otherCount += 1; // eslint-disable-next-line no-await-in-loop const result = await AgentDigestState.updateOne( { ...scope, foldedThroughId: cursor ?? null, unreadCount: state.unreadCount }, @@ -91,6 +112,8 @@ export async function foldExpiredDigests(threshold: Date): Promise { $inc: { unreadCount: 1, revision: 1 }, $set: { foldedThroughId: id, + conversations, + otherCount, ...(state.unreadCount === 0 ? { unreadSince: candidate.createdAt || new Date() } : {}), }, $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, @@ -111,14 +134,18 @@ export async function foldExpiredDigests(threshold: Date): Promise { export async function readFoldedDigest(scope: { agentName: string; instanceId: string; podId: unknown; -}): Promise<{ unreadCount: number; lastEventIds: string[]; revision: number } | null> { +}): Promise<{ unreadCount: number; lastEventIds: string[]; revision: number; + conversations: Array<{ label: string; count: number }>; otherCount: number } | null> { const state = await AgentDigestState.findOne({ ...scope, unreadCount: { $gt: 0 } }) - .select('unreadCount lastEventIds revision').lean(); + .select('unreadCount lastEventIds revision conversations otherCount').lean(); if (!state) return null; return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds, revision: state.revision, + conversations: state.conversations || [], + otherCount: Math.max(state.otherCount || 0, + state.unreadCount - (state.conversations || []).reduce((sum, item) => sum + item.count, 0)), }; } @@ -130,7 +157,8 @@ export async function settleFoldedDigest( // the next turn may see some IDs twice, but never loses an unseen event. await AgentDigestState.updateOne( { ...scope, revision }, - { $set: { unreadCount: 0, lastEventIds: [], unreadSince: null }, $inc: { revision: 1 } }, + { $set: { unreadCount: 0, lastEventIds: [], conversations: [], otherCount: 0, unreadSince: null }, + $inc: { revision: 1 } }, ); } diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 3f51c8bd0b..ab63ba024f 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -57,9 +57,16 @@ const OBJECT_ID_HEX = /^[a-f\d]{24}$/i; const digestConversationFrame = ( rows: Array<{ payload?: Record }>, olderCount = 0, + olderConversations: Array<{ label: string; count: number }> = [], ): string => { const counts = new Map(); - if (olderCount > 0) counts.set('older pod activity', olderCount); + let knownOlder = 0; + for (const conversation of olderConversations) { + if (!conversation.label || !Number.isInteger(conversation.count) || conversation.count < 1) continue; + counts.set(conversation.label, (counts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (olderCount > knownOlder) counts.set('older pod activity', olderCount - knownOlder); const seen = new Set(); for (const row of rows) { const payload = row.payload || {}; @@ -1401,6 +1408,7 @@ class AgentEventService { /** Persist the folded digest snapshot carried by this waking delivery. */ static async attachFoldedDigest(event: EventDoc): Promise<{ unreadCount: number; lastEventIds: string[]; + conversations: Array<{ label: string; count: number }>; } | null> { const scope = { agentName: String(event.agentName || '').toLowerCase(), @@ -1418,7 +1426,8 @@ class AgentEventService { { $set: { digestRollupRevision: state.revision } }, ); if (!updated.modifiedCount) return null; - return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds }; + return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds, + conversations: state.conversations }; } /** Add one bounded ambient frame to a wake consumed by an older poller or webhook. */ @@ -1451,7 +1460,7 @@ class AgentEventService { if (!saved.matchedCount) return; event.digestEventIds = ids; event.digestRollupRevision = rollup?.revision ?? null; - const summary = digestConversationFrame(raw, rollup?.unreadCount || 0); + const summary = digestConversationFrame(raw, rollup?.unreadCount || 0, rollup?.conversations); const baseContent = typeof event.payload?.content === 'string' ? event.payload.content : ''; event.payload = { ...(event.payload || {}), @@ -1522,7 +1531,7 @@ class AgentEventService { } const baseContent = String(event.payload?.content || event.payload?.prompt || event.payload?.text || ''); const summary = digest.length || folded?.unreadCount - ? digestConversationFrame(digest, folded?.unreadCount || 0) : null; + ? digestConversationFrame(digest, folded?.unreadCount || 0, folded?.conversations) : null; const payload = summary ? { ...event.payload, content: `${baseContent}\n\n${summary}`, diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index a5a1d705a4..27e06b7bf2 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -305,10 +305,11 @@ describe('performRun', () => { test('a wake carries compact digest context without claiming ambient messages', async () => { const events = [ makeEvent({ _id: 'evt-wake', type: 'chat.mention', payload: { - content: 'please review', digestRollup: { unreadCount: 3, lastEventIds: ['old-1', 'old-2'] }, + content: 'please review', digestRollup: { unreadCount: 3, lastEventIds: ['old-1', 'old-2'], + conversations: [{ label: 'thread 40', count: 3 }] }, } }), makeEvent({ _id: 'evt-digest', type: 'message.posted', deliveryMode: 'digest', - payload: { content: 'daily update', username: 'alice', messageId: '42' } }), + payload: { content: 'daily update', username: 'alice', messageId: '42', threadRootId: 40 } }), ]; const get = jest.fn(async (route) => { if (route.endsWith('/messages')) return { messages: [] }; @@ -327,8 +328,8 @@ describe('performRun', () => { stop(); expect(spawn).toHaveBeenCalledTimes(1); expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 4 ambient updates'); - expect(spawn.mock.calls[0][0]).toContain('older pod activity: 3 unread'); - expect(spawn.mock.calls[0][0]).toContain('pod conversation: 1 unread'); + expect(spawn.mock.calls[0][0]).toContain('4 ambient updates in 1 conversation'); + expect(spawn.mock.calls[0][0]).toContain('thread 40: 4 unread'); expect(spawn.mock.calls[0][0]).toContain('8 conversations, 5 messages each, 20 messages total'); expect(spawn.mock.calls[0][0]).not.toContain('daily update'); expect(spawn.mock.calls[0][0]).not.toContain('Event 2/2: message.posted'); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 3fd6e72500..cc9fe42781 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2028,7 +2028,17 @@ export const performRun = ({ }); const rollup = wakeEntries.find((entry) => entry.event.payload?.digestRollup)?.event.payload?.digestRollup; const conversationCounts = new Map(); - if (rollup?.unreadCount > 0) conversationCounts.set('older pod activity', rollup.unreadCount); + let knownOlder = 0; + for (const conversation of Array.isArray(rollup?.conversations) ? rollup.conversations : []) { + if (typeof conversation.label !== 'string' || !Number.isInteger(conversation.count) + || conversation.count < 1) continue; + conversationCounts.set(conversation.label, + (conversationCounts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (rollup?.unreadCount > knownOlder) { + conversationCounts.set('older pod activity', rollup.unreadCount - knownOlder); + } const seenDigestSources = new Set(); for (const entry of digestEntries) { const { threadRootId, taskId, taskKind, messageId, content } = entry.event.payload || {}; From c33dc291ed96cc03e9c75bef458fd873b8bcf266 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:06:02 -0700 Subject: [PATCH 21/28] Align post-turn poll test with three-second batching Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/poll-after-turn.test.mjs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/cli/__tests__/poll-after-turn.test.mjs b/cli/__tests__/poll-after-turn.test.mjs index a62e081c76..ca08b51d0c 100644 --- a/cli/__tests__/poll-after-turn.test.mjs +++ b/cli/__tests__/poll-after-turn.test.mjs @@ -1,17 +1,12 @@ /** * poll-after-turn.test.mjs — TASK-343 * - * A turn is exactly when new events pile up: the person who typed while the - * adapter was running is already waiting by the time it finishes. The loop - * nonetheless slept a full poll interval after every turn before looking - * again, so that person paid the turn plus the interval. + * A turn is when new events pile up. Poll shortly after it ends, while + * collecting notices that arrive mid-turn into one inbox read. * - * Carved out of TASK-289, which traded one interval of greeting latency to put - * a waiting mention first; this removes the interval it was trading. - * - * The rule is narrow on purpose: only a batch that actually spawned looks - * again at once. An idle poll, or a batch that spawned nothing, keeps the - * interval — otherwise the loop spins. + * A successful turn waits three seconds before reading again so notices that + * arrived mid-turn can be collected in one inbox read. An idle poll, or a + * batch that spawned nothing, keeps the normal interval. */ import { jest } from '@jest/globals'; @@ -46,6 +41,7 @@ const { performRun } = await import('../src/commands/agent.js'); const stubAdapter = (await import('../src/lib/adapters/stub.js')).default; const INTERVAL = 5000; +const POST_TURN_BATCH_MS = 3000; const mention = (overrides = {}) => ({ _id: 'evt-mention', @@ -91,13 +87,13 @@ const delayAfterOneTick = async ({ page, spawn }) => { return { delays, spawn, mockPost }; }; -describe('the loop looks again as soon as a turn ends', () => { +describe('the loop batches notices after a turn', () => { beforeEach(() => { jest.clearAllMocks(); fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true }); }); - test('a turn that answered somebody is followed by an immediate poll', async () => { + test('a turn that answered somebody waits three seconds before polling', async () => { const spawn = jest.fn(async () => ({ text: 'looking now' })); const { delays } = await delayAfterOneTick({ page: { events: [mention()], inboxCount: 1 }, @@ -105,7 +101,7 @@ describe('the loop looks again as soon as a turn ends', () => { }); expect(spawn).toHaveBeenCalledTimes(1); - expect(delays).toEqual([0]); + expect(delays).toEqual([POST_TURN_BATCH_MS]); }); test('an idle poll still waits the interval', async () => { From ce38ce5627bdfbdfa3b68b417366d798374f601d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:06:50 -0700 Subject: [PATCH 22/28] Keep digest frames counts-only and reserve inbox slot Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../agentEventService.deliveryNonce.test.js | 29 +++++++++++++++++++ backend/services/agentEventService.ts | 12 +++++--- cli/__tests__/lib.test.mjs | 11 +++++-- cli/src/lib/poller.js | 27 ++++++++++++----- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 44a00e8b6d..d42d20ab9a 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -96,6 +96,35 @@ describe('legacy ambient digest bridge', () => { push.mockRestore(); }); + test('a full wake page reserves one slot for unread digest from the same pod', async () => { + const firstWake = await seedEvent(); + const secondWake = await seedEvent(); + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '42', threadRootId: 21, content: 'private ambient body' }, + }); + + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 2, + }); + expect(page.inboxCount).toBe(3); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(firstWake._id), String(digest._id)]); + expect((await AgentEvent.findById(secondWake._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('delivered'); + }); + + test('a full wake page keeps every wake when there is no digest', async () => { + const firstWake = await seedEvent(); + const secondWake = await seedEvent(); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 2, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(firstWake._id), String(secondWake._id)]); + }); + test('a poll after pod access is removed cannot drain a queued wake or digest', async () => { const digest = await AgentEvent.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index ab63ba024f..ee85478e3a 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1846,15 +1846,19 @@ class AgentEventService { .limit(limit) .select({ _id: 1 }) .lean() as Array<{ _id: unknown }>; - // Digest rows are only claimed alongside a wake in the same pod. Reserve - // a slot for wake work so a long digest backlog cannot starve the turn. - const digestCandidates = onePod && wakeCandidates.length > 0 && wakeCandidates.length < limit + // Digest rows are only claimed alongside a wake in the same pod. + // A full wake page still needs to tell the driver about unread ambient + // context. Probe one digest on full pages and reserve its slot only when + // one exists; otherwise keep the entire wake page. + const digestSlots = limit - wakeCandidates.length || (limit > 1 ? 1 : 0); + const digestCandidates = onePod && wakeCandidates.length > 0 && digestSlots > 0 ? await AgentEvent.find({ ...batchQuery, deliveryMode: 'digest' }) .sort({ createdAt: 1 }) - .limit(limit - wakeCandidates.length) + .limit(digestSlots) .select({ _id: 1 }) .lean() as Array<{ _id: unknown }> : []; + if (wakeCandidates.length === limit && digestCandidates.length > 0) wakeCandidates.pop(); const candidates = [...wakeCandidates, ...digestCandidates]; // A sibling poller can claim every candidate after our pre-claim count. diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index 3d96ca4f2e..8be216661b 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -541,7 +541,10 @@ describe('poller.js', () => { test('keeps a digest-only page quiet and attaches its frame to the next wake', async () => { const digest = { ...makeEvent('digest-1'), deliveryMode: 'digest', - type: 'message.posted', payload: { content: 'ambient update', username: 'Ava', deliveryId: 'digest-nonce' }, + type: 'message.posted', payload: { + content: 'private ambient body', username: 'Ava', messageId: '42', threadRootId: 21, + deliveryId: 'digest-nonce', + }, }; const wake = { ...makeEvent('wake-1'), deliveryMode: 'wake', @@ -566,10 +569,12 @@ describe('poller.js', () => { expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ _id: 'wake-1', payload: expect.objectContaining({ - content: expect.stringContaining('ambient update'), - digestSummary: expect.stringContaining('ambient update'), + content: expect.stringContaining('thread 21: 1 unread'), + digestSummary: expect.stringContaining('thread 21: 1 unread'), }), })); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private ambient body'); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('Ava'); expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ '/api/agents/runtime/events/wake-1/ack', '/api/agents/runtime/events/digest-1/ack', diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index a52abcbea5..50b4bfd66f 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -29,13 +29,26 @@ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => }; const digestFrame = (events) => { - const lines = events.slice(-8).map((event) => { - const who = String(event.payload?.username || 'member').replace(/\s+/g, ' ').slice(0, 40); - const body = String(event.payload?.content || '').replace(/\s+/g, ' ').slice(0, 160); - const id = event.payload?.messageId; - return `- ${who}${id ? ` (message ${id})` : ''}: ${body}`; - }); - return `[Since you last looked: ${events.length} ambient updates; context only, no reply needed.]\n${lines.join('\n')}`; + const counts = new Map(); + const seen = new Set(); + for (const event of events) { + const payload = event.payload || {}; + const sourceId = payload.messageId != null ? `message:${payload.messageId}` + : (typeof payload.taskId === 'string' + ? `task:${payload.taskId}:${String(payload.taskKind || '')}:${String(payload.content || '')}` : null); + if (sourceId && seen.has(sourceId)) continue; + if (sourceId) seen.add(sourceId); + const thread = payload.threadRootId; + const task = payload.taskId; + const label = Number.isInteger(thread) && thread > 0 ? `thread ${thread}` + : (typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'); + counts.set(label, (counts.get(label) || 0) + 1); + } + const lines = [...counts].slice(0, 8).map(([label, count]) => `- ${label}: ${count} unread`); + if (counts.size > 8) lines.push(`- ${counts.size - 8} more conversations omitted`); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + return `[Since you last looked: ${total} ambient update${total === 1 ? '' : 's'} in ${counts.size} conversation${counts.size === 1 ? '' : 's'}; context only, no reply needed.]\n` + + `${lines.join('\n')}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`; }; export const startPoller = ({ From 32fb0346d633b839923423a87116715bb8230c72 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:10:00 -0700 Subject: [PATCH 23/28] Keep board digest delivery after own-row merge Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .../taskEventService.offeredAt.test.js | 15 ++++++++----- .../agentEventService.deliveryNonce.test.js | 13 +++++++++++ .../taskEventService.notifyPodAgents.test.js | 22 +++++++++++++------ backend/services/agentEventService.ts | 6 +++-- backend/services/taskEventService.ts | 13 ++++++++++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/backend/__tests__/service/taskEventService.offeredAt.test.js b/backend/__tests__/service/taskEventService.offeredAt.test.js index 0f83ba3f45..ea75e544f6 100644 --- a/backend/__tests__/service/taskEventService.offeredAt.test.js +++ b/backend/__tests__/service/taskEventService.offeredAt.test.js @@ -301,7 +301,8 @@ describe('board wake assigned work', () => { expect(scout.payload.content).toContain('[Your assigned rows (pending/claimed; newest first):]'); expect(scout.payload.content).toContain('TASK-ASSIGN — TASK-ASSIGN [pending] — Lily updated: reassigned to scout'); expect(scout.payload.content).not.toContain('TASK-FOREIGN'); - expect(peer).toBeUndefined(); + expect(peer.deliveryMode).toBe('digest'); + expect(peer.payload.content).toBe('TASK-ASSIGN updated: TASK-ASSIGN'); }); it('keeps the assigned list to five rows, newest first, after a folded change', async () => { @@ -352,12 +353,14 @@ describe('board wake assigned work', () => { expect(anvil.payload.content).toContain('TASK-INSTANCE — Instance Do it now [pending]'); expect(anvil.payload.content).not.toContain('Instance\nDo it now'); expect(anvil.payload.content).not.toContain('TASK-SCOUT'); - expect(scout).toBeUndefined(); + expect(scout.deliveryMode).toBe('digest'); + expect(scout.payload.content).toBe('TASK-INSTANCE updated: Instance Do it now'); }); }); describe('own-row board wake routing', () => { - const wakeNames = () => AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName); + const wakeNames = () => AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName); beforeEach(() => setInstalls([install('scout'), install('sprint-impl')])); @@ -395,7 +398,8 @@ describe('own-row board wake routing', () => { await notifyPodAgents(POD_ID, commented, 'updated', { isAgent: false }); expect(wakeNames()).toEqual(['scout']); - expect(AgentEventService.enqueue.mock.calls[0][0].payload.content).toContain('Peer commented'); + expect(AgentEventService.enqueue.mock.calls + .find(([event]) => event.deliveryMode !== 'digest')[0].payload.content).toContain('Peer commented'); }); it.each(['native', 'durable-object'])('sends a hosted %s owner a type its engine runs', async (engine) => { @@ -408,7 +412,8 @@ describe('own-row board wake routing', () => { await notifyPodAgents(POD_ID, changed, 'updated', { isAgent: false }); expect(wakeNames()).toEqual(['maya-agent']); - expect(AgentEventService.enqueue.mock.calls[0][0].type).toBe('chat.mention'); + expect(AgentEventService.enqueue.mock.calls + .find(([event]) => event.deliveryMode !== 'digest')[0].type).toBe('chat.mention'); }); it('inherits hosted runtime for a mention-created pod projection', async () => { diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index d42d20ab9a..a496ecbfb7 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -125,6 +125,19 @@ describe('legacy ambient digest bridge', () => { .toEqual([String(firstWake._id), String(secondWake._id)]); }); + test('a one-wake page still carries its unread digest companion', async () => { + const wake = await seedEvent(); + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { messageId: '42' }, + }); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 1, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(wake._id), String(digest._id)]); + }); + test('a poll after pod access is removed cannot drain a queued wake or digest', async () => { const digest = await AgentEvent.create({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, diff --git a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js index 65f3ea3c4d..e23294a08a 100644 --- a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js +++ b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js @@ -311,7 +311,8 @@ describe('notifyPodAgents', () => { userId: AGENT_ID, isAgent: true, agentName: 'scout', instanceId: 'u0da521ab41', }); - const woken = AgentEventService.enqueue.mock.calls.map((c) => c[0].instanceId); + const woken = AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.instanceId); expect(woken).toEqual(['ucc4035c51b']); }); @@ -324,7 +325,8 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'openclaw' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls).toHaveLength(2); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('still wakes an agent when the HUMAN who installed it edits the board', async () => { @@ -377,8 +379,10 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'commonly-bot' }), 'created', { userId: HUMAN_ID, isAgent: false }); - const woken = AgentEventService.enqueue.mock.calls.map((c) => c[0].agentName); + const woken = AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName); expect(woken).toEqual(['commonly-bot']); + expect(AgentEventService.enqueue.mock.calls.filter(([event]) => event.deliveryMode === 'digest')).toHaveLength(2); }); it('resolves a claimed row through the bot principal, not its installer', async () => { @@ -390,7 +394,9 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: null, claimedBy: AGENT_ID }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName)).toEqual(['sprint-impl']); + expect(AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName)) + .toEqual(['sprint-impl']); }); it('does not treat an ambiguous default claim key as every default seat', async () => { @@ -399,7 +405,7 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: null, claimedBy: 'default' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('does not treat an ambiguous default assignee as every default seat', async () => { @@ -408,7 +414,7 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'default' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('wakes a claimed dependent row when its exact dependency clears', async () => { @@ -419,7 +425,9 @@ describe('notifyPodAgents', () => { { userId: HUMAN_ID, isAgent: false }, { status: 'claimed' }); expect(Task.find.mock.calls[0][0]).toMatchObject({ podId: POD_ID, dep: 'TASK-042' }); - expect(AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName)).toEqual(['sprint-impl']); + expect(AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName)) + .toEqual(['sprint-impl']); }); it('wakes a claimed dependent row through the bot principal', async () => { diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index ee85478e3a..0ac0239830 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1850,7 +1850,9 @@ class AgentEventService { // A full wake page still needs to tell the driver about unread ambient // context. Probe one digest on full pages and reserve its slot only when // one exists; otherwise keep the entire wake page. - const digestSlots = limit - wakeCandidates.length || (limit > 1 ? 1 : 0); + // At limit=1 the digest is a companion to the single wake, not another + // runnable item. Return the pair so even that page exposes unread context. + const digestSlots = limit - wakeCandidates.length || 1; const digestCandidates = onePod && wakeCandidates.length > 0 && digestSlots > 0 ? await AgentEvent.find({ ...batchQuery, deliveryMode: 'digest' }) .sort({ createdAt: 1 }) @@ -1858,7 +1860,7 @@ class AgentEventService { .select({ _id: 1 }) .lean() as Array<{ _id: unknown }> : []; - if (wakeCandidates.length === limit && digestCandidates.length > 0) wakeCandidates.pop(); + if (limit > 1 && wakeCandidates.length === limit && digestCandidates.length > 0) wakeCandidates.pop(); const candidates = [...wakeCandidates, ...digestCandidates]; // A sibling poller can claim every candidate after our pre-claim count. diff --git a/backend/services/taskEventService.ts b/backend/services/taskEventService.ts index 2fa5f411d1..86292ec009 100644 --- a/backend/services/taskEventService.ts +++ b/backend/services/taskEventService.ts @@ -248,6 +248,7 @@ export async function notifyPodAgents( }).lean(); // Ownership addresses a seat directly, independent of ambient wake opt-in. + // Other seats still receive durable board context without a model turn. const installs = active || []; if (!installs.length) return; @@ -334,8 +335,18 @@ export async function notifyPodAgents( || seatHoldsClaim(install, previousHolder) || dependentRows.some((row) => seatMatchesAssignee(install, row.assignee)) || dependentHolders.some((holder) => seatHoldsClaim(install, holder)); - if (!ownRow) return null; try { + if (!ownRow) { + await AgentEventService.enqueue({ + agentName, instanceId, podId: normalizedPodId, type: 'message.posted', + deliveryMode: 'digest', + payload: { + podId: normalizedPodId, boardDigest: true, taskId, taskKind: kind, + content: `${taskId} ${kind}: ${title}`, + }, + }); + return true; + } // Both hosted engines answer chat.mention; they acknowledge a generic // message.posted without a turn. Other drivers use message.posted. const eventType = await usesHostedBoardWake(install, AgentInstallation) From 9b8bb24e45189160196b700220d73de39e71c10b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:16:22 -0700 Subject: [PATCH 24/28] fix(cli): include folded digest counts in agent connect Co-authored-by: flint Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/lib.test.mjs | 42 +++++++++++++++++++++++++++++++++++++- cli/src/lib/poller.js | 19 ++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index 8be216661b..740cde2fa3 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -548,7 +548,10 @@ describe('poller.js', () => { }; const wake = { ...makeEvent('wake-1'), deliveryMode: 'wake', - type: 'chat.mention', payload: { content: '@my-agent please help', deliveryId: 'wake-nonce' }, + type: 'chat.mention', payload: { + content: '@my-agent please help', deliveryId: 'wake-nonce', + digestRollup: { unreadCount: 2, conversations: [{ label: 'TASK-7', count: 2 }] }, + }, }; const mockGet = jest.fn() .mockResolvedValueOnce({ events: [digest] }) @@ -573,6 +576,8 @@ describe('poller.js', () => { digestSummary: expect.stringContaining('thread 21: 1 unread'), }), })); + expect(onEvent.mock.calls[0][0].payload.digestSummary).toContain('TASK-7: 2 unread'); + expect(onEvent.mock.calls[0][0].payload.digestSummary).toContain('3 ambient updates in 2 conversations'); expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private ambient body'); expect(JSON.stringify(onEvent.mock.calls)).not.toContain('Ava'); expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ @@ -584,6 +589,41 @@ describe('poller.js', () => { }); }); + test('shows folded-only unread counts to agent connect before acknowledging the wake', async () => { + const wake = { + ...makeEvent('wake-folded'), deliveryMode: 'wake', + payload: { + content: '@my-agent please help', deliveryId: 'wake-nonce', + digestRollup: { + unreadCount: 3, + conversations: [{ label: 'thread 21', count: 2 }], + }, + }, + }; + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ events: [wake] }), post: mockPost, + }); + const onEvent = jest.fn(async (event) => { + expect(mockPost).not.toHaveBeenCalled(); + expect(event.payload.content).toContain('thread 21: 2 unread'); + expect(event.payload.digestSummary).toContain('older pod activity: 1 unread'); + expect(event.payload.digestSummary).toContain('3 ambient updates in 2 conversations'); + return { outcome: 'acknowledged' }; + }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-folded/ack', + ]); + }); + test.each([ { outcome: 'error', reason: 'webhook failed' }, { outcome: 'no_action', reason: 'upstream-refused' }, diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index 50b4bfd66f..90e19d2f56 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -28,8 +28,19 @@ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => return null; }; -const digestFrame = (events) => { +const digestFrame = (events, rollup) => { const counts = new Map(); + let knownOlder = 0; + for (const conversation of Array.isArray(rollup?.conversations) ? rollup.conversations : []) { + if (typeof conversation.label !== 'string' || !Number.isInteger(conversation.count) + || conversation.count < 1) continue; + counts.set(conversation.label, (counts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (rollup?.unreadCount > knownOlder) { + counts.set('older pod activity', + (counts.get('older pod activity') || 0) + rollup.unreadCount - knownOlder); + } const seen = new Set(); for (const event of events) { const payload = event.payload || {}; @@ -85,10 +96,12 @@ export const startPoller = ({ const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest'); const digestEvents = events.filter((event) => event.deliveryMode === 'digest' && wakeEvents[0] && String(event.podId) === String(wakeEvents[0].podId)); - const frame = digestEvents.length > 0 ? digestFrame(digestEvents) : null; + const rollup = wakeEvents[0]?.payload?.digestRollup; + const frame = digestEvents.length > 0 || rollup?.unreadCount > 0 + ? digestFrame(digestEvents, rollup) : null; let digestConsumed = false; for (const [index, event] of wakeEvents.entries()) { - const attachedDigest = index === 0 && digestEvents.length > 0; + const attachedDigest = index === 0 && frame !== null; const forwarded = attachedDigest ? { ...event, payload: { From 73f4f413004b61ff80ae2247ec55d818fa951c11 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:16:20 -0700 Subject: [PATCH 25/28] chore(cli): reserve 0.1.153 for digest delivery Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 0f0d3a089a..b4f281e375 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 777a407bf5..599df7858d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From 5c1996c840c6e72519b9fff117c9bea1990fed85 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:18:42 -0700 Subject: [PATCH 26/28] fix: drain digest retention batches within a time budget Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- .../services/agentDigestFoldService.test.js | 49 +++++ .../taskEventService.notifyPodAgents.test.js | 3 +- backend/services/agentDigestFoldService.ts | 199 ++++++++++-------- docs/adr/ADR-018-agent-attention-claims.md | 4 + 4 files changed, 165 insertions(+), 90 deletions(-) diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js index 7ffccd0c57..9ada0768c8 100644 --- a/backend/__tests__/unit/services/agentDigestFoldService.test.js +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -26,6 +26,55 @@ describe('expired digest fold', () => { attempts: 0, createdAt, updatedAt: createdAt, }); + it('drains multiple batches within one GC pass', async () => { + const podId = new mongoose.Types.ObjectId(); + await Promise.all(Array.from({ length: 205 }, () => seed(new Date(THRESHOLD.getTime() - 1000), podId))); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(205); + + expect(await AgentEvent.countDocuments({ podId })).toBe(0); + expect((await AgentDigestState.findOne({ podId }).lean()).unreadCount).toBe(205); + }); + + it('stops at its time budget and leaves remaining rows due for the next pass', async () => { + const podId = new mongoose.Types.ObjectId(); + await Promise.all(Array.from({ length: 4 }, () => seed(new Date(THRESHOLD.getTime() - 1000), podId))); + let elapsed = 0; + const realDelete = AgentEvent.deleteOne.bind(AgentEvent); + const deletion = jest.spyOn(AgentEvent, 'deleteOne').mockImplementation(async (...args) => { + const result = await realDelete(...args); + elapsed += 5000; + return result; + }); + + expect(await foldExpiredDigests(THRESHOLD, () => elapsed)).toBe(2); + expect(await AgentEvent.countDocuments({ podId })).toBe(2); + deletion.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + expect((await AgentDigestState.findOne({ podId }).lean()).unreadCount).toBe(4); + }); + + it('defers a contended scope without starving another scope beyond the first batch', async () => { + const blockedPod = new mongoose.Types.ObjectId(); + const healthyPod = new mongoose.Types.ObjectId(); + const old = new Date(THRESHOLD.getTime() - 1000); + await Promise.all(Array.from({ length: 102 }, () => seed(old, blockedPod))); + await seed(old, healthyPod); + await AgentDigestState.create({ agentName: 'flint', instanceId: 'default', podId: blockedPod }); + const realUpdate = AgentDigestState.updateOne.bind(AgentDigestState); + const contention = jest.spyOn(AgentDigestState, 'updateOne').mockImplementation((filter, ...args) => ( + String(filter.podId) === String(blockedPod) + ? Promise.resolve({ modifiedCount: 0 }) : realUpdate(filter, ...args) + )); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(1); + expect(await AgentEvent.countDocuments({ podId: blockedPod })).toBe(102); + expect((await AgentDigestState.findOne({ podId: healthyPod }).lean()).unreadCount).toBe(1); + contention.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(102); + expect((await AgentDigestState.findOne({ podId: blockedPod }).lean()).unreadCount).toBe(102); + }); + it('retains unread count and last IDs while deleting expired full events', async () => { const podId = new mongoose.Types.ObjectId(); const first = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); diff --git a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js index e23294a08a..6569a9a9bd 100644 --- a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js +++ b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js @@ -167,7 +167,8 @@ describe('notifyPodAgents', () => { updatedAt: new Date('2026-08-20T20:00:00.000Z'), }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1); + expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({ deliveryMode: 'digest' })); expect(mockTaskUpdateOne).not.toHaveBeenCalled(); }); diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts index b305d4e46a..00ff1e3b8e 100644 --- a/backend/services/agentDigestFoldService.ts +++ b/backend/services/agentDigestFoldService.ts @@ -2,6 +2,7 @@ import AgentEvent from '../models/AgentEvent'; import AgentDigestState from '../models/AgentDigestState'; const FOLD_BATCH_SIZE = 100; +export const FOLD_TIME_BUDGET_MS = 10_000; const LAST_IDS_LIMIT = 8; const CONVERSATION_LIMIT = 8; @@ -28,106 +29,126 @@ type FoldCandidate = { * per-seat cursor makes a crash after the state update, but before deletion, * idempotent on the next GC pass. Each pass processes IDs in ascending order. */ -export async function foldExpiredDigests(threshold: Date): Promise { - const candidates = await AgentEvent.find({ - deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, - createdAt: { $lt: threshold }, - }).sort({ _id: 1 }).limit(FOLD_BATCH_SIZE).lean() as FoldCandidate[]; +export async function foldExpiredDigests(threshold: Date, now: () => number = Date.now): Promise { + const deadline = now() + FOLD_TIME_BUDGET_MS; let folded = 0; - const blockedScopes = new Set(); - for (const candidate of candidates) { - const id = candidate._id; - const scope = { - agentName: candidate.agentName, - instanceId: candidate.instanceId || 'default', - podId: candidate.podId, - }; - const scopeKey = `${scope.agentName}:${scope.instanceId}:${scope.podId}`; - if (blockedScopes.has(scopeKey)) continue; - // createdAt and ObjectId order can diverge for an imported row. Never - // advance this scope's cursor beyond an earlier unread ID, even if that - // earlier row has not reached the retention horizon yet. + let afterId: unknown; + const blockedScopes = new Map(); + while (now() < deadline) { + // Page past already inspected IDs, including scopes deferred by another + // worker. Exclude blocked scopes so a large busy scope cannot fill every + // subsequent page and starve healthy scopes within this run's budget. // eslint-disable-next-line no-await-in-loop - const olderUnread = await AgentEvent.exists({ - ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, - _id: { $lt: id }, - }); - if (olderUnread) { - blockedScopes.add(scopeKey); - continue; - } - if (candidate.status !== 'folding') { + const candidates = await AgentEvent.find({ + deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + createdAt: { $lt: threshold }, + ...(afterId ? { _id: { $gt: afterId } } : {}), + ...(blockedScopes.size ? { $nor: [...blockedScopes.values()] } : {}), + }).sort({ _id: 1 }).limit(FOLD_BATCH_SIZE).lean() as FoldCandidate[]; + if (!candidates.length) break; + for (const candidate of candidates) { + if (now() >= deadline) return folded; + afterId = candidate._id; + const id = candidate._id; + const scope = { + agentName: candidate.agentName, + instanceId: candidate.instanceId || 'default', + podId: candidate.podId, + }; + const scopeKey = `${scope.agentName}:${scope.instanceId}:${scope.podId}`; + if (blockedScopes.has(scopeKey)) continue; + // createdAt and ObjectId order can diverge for an imported row. Never + // advance this scope's cursor beyond an earlier unread ID, even if that + // earlier row has not reached the retention horizon yet. // eslint-disable-next-line no-await-in-loop - const claimed = await AgentEvent.findOneAndUpdate( - { _id: id, status: candidate.status, deliveryMode: 'digest' }, - { $set: { status: 'folding' } }, - { new: true }, - ); - if (!claimed) { - // Another GC worker may be folding this oldest row. If we advanced to - // a later ID first, its cursor would make that older row disappear - // without ever increasing unreadCount. - blockedScopes.add(scopeKey); + const olderUnread = await AgentEvent.exists({ + ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + _id: { $lt: id }, + }); + if (olderUnread) { + blockedScopes.set(scopeKey, scope); continue; } - } - const idString = String(id); - const label = conversationLabel(candidate.payload); - let recorded = false; - for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { - // eslint-disable-next-line no-await-in-loop - const state = await AgentDigestState.findOne(scope) - .select('foldedThroughId unreadCount conversations otherCount').lean(); - const cursor = state?.foldedThroughId; - if (cursor && String(cursor) >= idString) { - recorded = true; - break; + if (candidate.status !== 'folding') { + // eslint-disable-next-line no-await-in-loop + const claimed = await AgentEvent.findOneAndUpdate( + { _id: id, status: candidate.status, deliveryMode: 'digest' }, + { $set: { status: 'folding' } }, + { new: true }, + ); + if (!claimed) { + // Another GC worker may be folding this oldest row. If we advanced to + // a later ID first, its cursor would make that older row disappear + // without ever increasing unreadCount. + blockedScopes.set(scopeKey, scope); + continue; + } } - if (!state) { - try { - // eslint-disable-next-line no-await-in-loop - await AgentDigestState.create({ - ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], - conversations: [{ label, count: 1 }], otherCount: 0, - unreadSince: candidate.createdAt || new Date(), foldedThroughId: id, - }); + const idString = String(id); + const label = conversationLabel(candidate.payload); + let recorded = false; + for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const state = await AgentDigestState.findOne(scope) + .select('foldedThroughId unreadCount conversations otherCount').lean(); + const cursor = state?.foldedThroughId; + if (cursor && String(cursor) >= idString) { recorded = true; - } catch (err) { - if ((err as { code?: number }).code !== 11000) throw err; + break; } - } else { - const conversations = [...(state.conversations || [])].map((item) => ({ - label: item.label, count: item.count, - })); - let otherCount = Math.max(state.otherCount || 0, - state.unreadCount - conversations.reduce((sum, item) => sum + item.count, 0)); - const existing = conversations.find((item) => item.label === label); - if (existing) existing.count += 1; - else if (conversations.length < CONVERSATION_LIMIT) conversations.push({ label, count: 1 }); - else otherCount += 1; - // eslint-disable-next-line no-await-in-loop - const result = await AgentDigestState.updateOne( - { ...scope, foldedThroughId: cursor ?? null, unreadCount: state.unreadCount }, - { - $inc: { unreadCount: 1, revision: 1 }, - $set: { - foldedThroughId: id, - conversations, - otherCount, - ...(state.unreadCount === 0 ? { unreadSince: candidate.createdAt || new Date() } : {}), + if (!state) { + try { + // eslint-disable-next-line no-await-in-loop + await AgentDigestState.create({ + ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], + conversations: [{ label, count: 1 }], otherCount: 0, + unreadSince: candidate.createdAt || new Date(), foldedThroughId: id, + }); + recorded = true; + } catch (err) { + if ((err as { code?: number }).code !== 11000) throw err; + } + } else { + const conversations = [...(state.conversations || [])].map((item) => ({ + label: item.label, count: item.count, + })); + let otherCount = Math.max(state.otherCount || 0, + state.unreadCount - conversations.reduce((sum, item) => sum + item.count, 0)); + const existing = conversations.find((item) => item.label === label); + if (existing) existing.count += 1; + else if (conversations.length < CONVERSATION_LIMIT) conversations.push({ label, count: 1 }); + else otherCount += 1; + // eslint-disable-next-line no-await-in-loop + const result = await AgentDigestState.updateOne( + { ...scope, foldedThroughId: cursor ?? null, unreadCount: state.unreadCount }, + { + $inc: { unreadCount: 1, revision: 1 }, + $set: { + foldedThroughId: id, + conversations, + otherCount, + ...(state.unreadCount === 0 ? { unreadSince: candidate.createdAt || new Date() } : {}), + }, + $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, }, - $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, - }, - ); - recorded = result.modifiedCount === 1; + ); + recorded = result.modifiedCount === 1; + } } + if (!recorded) { + // Keep this scope's unread order and retry it next pass. A busy seat + // must not prevent unrelated seats from compacting their old events. + blockedScopes.set(scopeKey, scope); + console.warn(`[agent-digest] fold state contention for ${idString}; deferring scope`); + continue; + } + // If this delete fails, the next pass sees `folding` and the cursor avoids + // incrementing a second time. A delivered/acked event never enters here. + // eslint-disable-next-line no-await-in-loop + const result = await AgentEvent.deleteOne({ _id: id, status: 'folding', deliveryMode: 'digest' }); + folded += result.deletedCount || 0; } - if (!recorded) throw new Error(`Digest fold state contention for ${idString}`); - // If this delete fails, the next pass sees `folding` and the cursor avoids - // incrementing a second time. A delivered/acked event never enters here. - // eslint-disable-next-line no-await-in-loop - const result = await AgentEvent.deleteOne({ _id: id, status: 'folding', deliveryMode: 'digest' }); - folded += result.deletedCount || 0; + if (candidates.length < FOLD_BATCH_SIZE) break; } return folded; } diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index 7d17f11bd1..8a626144e7 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -491,6 +491,10 @@ footnote. - Drivers: wrapper claims before acting on `chat.mention` / wake-on-message; stands down on CAS loss. - Tasks: add `claimExpiresAt` to `Task`, honoured by the existing claim path. +- Digest retention: compact expired bodies into per-seat, per-pod unread + counters before deleting them. GC reads 100-row pages within a ten-second + budget, retaining unread order and retrying deferred rows next pass. A + contended scope does not stop other scopes from compacting. ## Out of scope From 81cd17e7a69ab81c622c4e748e2e88d351e091ab Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:22:16 -0700 Subject: [PATCH 27/28] fix(events): retain fallback wake text in digest frames Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/agentEventService.lifecycle.test.js | 5 +++-- backend/services/agentEventService.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index 71789ab44d..d5da555da4 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -420,7 +420,7 @@ describe('driver paths reach a terminal state', () => { expect(update['delivery.outcome']).toBe('no_action'); }); - test('a webhook wake carries and settles ambient context only after accepted delivery', async () => { + test.each([{ text: 'original text' }, { prompt: 'original prompt' }])('a webhook wake preserves fallback text and settles context after accepted delivery: %j', async (wakePayload) => { const ambient = { _id: 'ambient-1', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, payload: { username: 'Alice', messageId: '42', threadRootId: 40, content: 'ambient note' }, @@ -431,7 +431,7 @@ describe('driver paths reach a terminal state', () => { AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); AgentEvent.create.mockResolvedValue({ _id: 'evt-digest-wake', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, - type: 'chat.mention', payload: {}, status: 'pending', attempts: 0, + type: 'chat.mention', payload: wakePayload, status: 'pending', attempts: 0, }); AgentInstallation.find.mockReturnValue({ lean: async () => [{ config: { runtime: { webhookUrl: 'https://example.test/hook' } } }], @@ -444,6 +444,7 @@ describe('driver paths reach a terminal state', () => { await flush(); const body = JSON.parse(postToPublicAddress.mock.calls[0][1].body); + expect(body.payload.content).toContain(wakePayload.prompt || wakePayload.text); expect(body.payload.digestSummary).toContain('thread 40: 1 unread'); expect(body.payload.digestSummary).toContain('1 ambient update in 1 conversation'); expect(body.payload.digestSummary).not.toContain('ambient note'); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 0ac0239830..6181686d62 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1461,7 +1461,8 @@ class AgentEventService { event.digestEventIds = ids; event.digestRollupRevision = rollup?.revision ?? null; const summary = digestConversationFrame(raw, rollup?.unreadCount || 0, rollup?.conversations); - const baseContent = typeof event.payload?.content === 'string' ? event.payload.content : ''; + const baseContent = [event.payload?.content, event.payload?.prompt, event.payload?.text] + .find((value): value is string => typeof value === 'string' && value.length > 0) || ''; event.payload = { ...(event.payload || {}), // Existing drivers consume content; the additive field also lets a new From a2804868463d86c9341a330ab313406fd5654c48 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:27:04 -0700 Subject: [PATCH 28/28] fix(events): cool down unread wakes after a failed turn Co-authored-by: flint Agent-Model: codex/gpt-6.1-sol/medium --- .../schedulerService.heartbeatLease.test.js | 16 ++++++++++++++++ backend/services/schedulerService.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index a21f1959f7..867107354b 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -127,6 +127,22 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { expect(enqueue).not.toHaveBeenCalled(); }); + it('waits an hour after a wake fails, even if it was delivered earlier', async () => { + const scope = { agentName: 'flint', instanceId: 'default', podId: new mongoose.Types.ObjectId() }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + const old = new Date(NOW.getTime() - 61 * 60000); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: old }); + const failed = await AgentEvent.collection.insertOne({ ...scope, type: 'heartbeat', status: 'failed', + deliveryMode: 'wake', attempts: 1, createdAt: old, deliveredAt: old, updatedAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + await AgentEvent.collection.updateOne({ _id: failed.insertedId }, { $set: { updatedAt: old } }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + }); + it('also wakes for unread digest folded past full-event retention', async () => { const podId = new mongoose.Types.ObjectId(); const scope = { agentName: 'flint', instanceId: 'default', podId }; diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index 3ea442bf2f..a4cb69b2f0 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -1158,8 +1158,12 @@ class SchedulerService { // eslint-disable-next-line no-await-in-loop const [pendingWake, recentWake] = await Promise.all([ AgentEvent.exists({ ...scope, status: 'pending', deliveryMode: { $ne: 'digest' } }), - AgentEvent.exists({ ...scope, status: { $in: ['delivered', 'acked'] }, - deliveryMode: { $ne: 'digest' }, deliveredAt: { $gte: threshold } }), + AgentEvent.exists({ ...scope, deliveryMode: { $ne: 'digest' }, $or: [ + { status: { $in: ['delivered', 'acked'] }, deliveredAt: { $gte: threshold } }, + // AgentEvent timestamps update on every failed transition, including + // failures before delivery and failures after an older delivery. + { status: 'failed', updatedAt: { $gte: threshold } }, + ] }), ]); if (pendingWake || recentWake) continue; // eslint-disable-next-line no-await-in-loop