Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions backend/__tests__/service/taskEventService.nudgeThrottle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* eslint-disable import/no-unresolved, import/extensions */
const mongoose = require('mongoose');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');

jest.mock('../../services/agentEventService', () => ({ enqueue: jest.fn().mockResolvedValue({}) }));
jest.mock('../../models/AgentEvent', () => ({ findOneAndUpdate: jest.fn().mockResolvedValue(null) }));

const { AgentInstallation } = require('../../models/AgentRegistry');
const Task = require('../../models/Task');
const User = require('../../models/User');
const AgentEventService = require('../../services/agentEventService');
const { notifyFoundWork } = require('../../services/taskEventService');

const podId = new mongoose.Types.ObjectId();
const now = new Date('2026-08-20T20:00:00.000Z');
const work = [{
_id: new mongoose.Types.ObjectId(), taskId: 'TASK-UNCLAIMED', title: 'Unclaimed work', updatedAt: now,
}];

beforeAll(setupMongoDb);
beforeEach(async () => {
await clearMongoDb();
jest.clearAllMocks();
await AgentInstallation.create({
agentName: 'scout',
instanceId: 'default',
podId,
status: 'active',
version: '1.0.0',
installedBy: new mongoose.Types.ObjectId(),
config: { boardWake: { enabled: true }, workDiscovery: { enabled: true, role: 'implementer' } },
});
});
afterAll(async () => {
await clearMongoDb();
await closeMongoDb();
});

it('atomically permits one nudge per seat and pod in a rolling one-hour window', async () => {
const [a, b] = await Promise.all([
notifyFoundWork(podId, work, 1, now),
notifyFoundWork(podId, work, 1, now),
]);
expect(a.woken + b.woken).toBe(1);
expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1);

const withinWindow = await notifyFoundWork(podId, work, 1, new Date(now.getTime() + 59 * 60 * 1000));
expect(withinWindow.woken).toBe(0);
expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1);

const nextWindow = await notifyFoundWork(podId, work, 1, new Date(now.getTime() + 60 * 60 * 1000));
expect(nextWindow.woken).toBe(1);
expect(AgentEventService.enqueue).toHaveBeenCalledTimes(2);
const install = await AgentInstallation.findOne({ podId, agentName: 'scout' }).lean();
expect(install.lastFoundWorkAt).toEqual(new Date(now.getTime() + 60 * 60 * 1000));
});

it('still nudges an idle uppercase DEFAULT installation using its stored identity', async () => {
await AgentInstallation.updateOne({ podId, agentName: 'scout' }, { $set: { instanceId: 'DEFAULT' } });

expect((await notifyFoundWork(podId, work, 1, now)).woken).toBe(1);

expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({
agentName: 'scout', instanceId: 'DEFAULT',
}));
const install = await AgentInstallation.findOne({ podId, agentName: 'scout' }).lean();
expect(install.lastFoundWorkAt).toEqual(now);
});

it.each([
['assigned', { assignee: 'scout', status: 'pending' }, 'default'],
['mixed-case assignment', { assignee: 'Scout', status: 'pending' }, 'default'],
['claimed', { claimedBy: 'scout', status: 'claimed' }, 'default'],
['mixed-case claim', { claimedBy: 'Scout', status: 'claimed' }, 'default'],
['assignment with uppercase DEFAULT', { assignee: 'scout', status: 'pending' }, 'DEFAULT'],
['claim with uppercase DEFAULT', { claimedBy: 'Scout', status: 'claimed' }, 'DEFAULT'],
])('does not reserve a slot for a reviewer or an implementer with %s', async (_kind, ownership, instanceId) => {
if (instanceId) await AgentInstallation.updateOne({ podId, agentName: 'scout' }, { $set: { instanceId } });
await AgentInstallation.create({
agentName: 'sprint-review', instanceId: 'default', podId, status: 'active',
version: '1.0.0', installedBy: new mongoose.Types.ObjectId(),
config: { boardWake: { enabled: true }, workDiscovery: { enabled: true, role: 'reviewer' } },
});
await Task.create({
podId, taskNum: 1, taskId: 'TASK-BUSY', title: 'Already owned',
...ownership,
});

const result = await notifyFoundWork(podId, work, 1, now);

expect(result.woken).toBe(0);
expect(AgentEventService.enqueue).not.toHaveBeenCalled();
const installs = await AgentInstallation.find({ podId }).lean();
expect(installs.every((install) => install.lastFoundWorkAt == null)).toBe(true);
});

it.each(['default', 'DEFAULT'])('treats a bot-principal ObjectId claim as occupied capacity for %s', async (instanceId) => {
await AgentInstallation.updateOne({ podId, agentName: 'scout' }, { $set: { instanceId } });
const principalId = new mongoose.Types.ObjectId();
await User.collection.insertOne({
_id: principalId, isBot: true, botMetadata: { agentName: 'Scout', instanceId: 'DeFaUlT' },
});
await Task.create({
podId, taskNum: 2, taskId: 'TASK-PRINCIPAL', title: 'Claimed by bot principal',
status: 'claimed', claimedBy: String(principalId),
});

const result = await notifyFoundWork(podId, work, 1, now);

expect(result.woken).toBe(0);
expect(AgentEventService.enqueue).not.toHaveBeenCalled();
const install = await AgentInstallation.findOne({ podId, agentName: 'scout' }).lean();
expect(install.lastFoundWorkAt).toBeNull();
});
36 changes: 27 additions & 9 deletions backend/__tests__/service/taskEventService.offeredAt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jest.mock('../../models/AgentRegistry', () => ({
AgentInstallation: {
find: jest.fn(),
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
updateOne: jest.fn(),
},
}));

Expand All @@ -41,14 +43,16 @@ const KernelWorkSweepService = require('../../services/kernelWorkSweepService');

const POD_ID = new mongoose.Types.ObjectId();
const NOW = new Date('2026-08-20T20:00:00.000Z');
const SINCE = new Date(NOW.getTime() - 10 * 60 * 1000);

const install = (agentName = 'scout') => ({
agentName,
instanceId: 'default',
podId: POD_ID.toString(),
status: 'active',
config: { runtime: { runtimeType: 'wrapper' }, boardWake: { enabled: true } },
config: {
runtime: { runtimeType: 'wrapper' }, boardWake: { enabled: true },
workDiscovery: { enabled: true, role: 'implementer' },
},
});

const setInstalls = (installs) => {
Expand Down Expand Up @@ -97,6 +101,8 @@ beforeEach(async () => {
taskNum = 0;
setInstalls([install()]);
AgentEvent.findOneAndUpdate.mockResolvedValue(null);
AgentInstallation.findOneAndUpdate.mockResolvedValue({});
AgentInstallation.updateOne.mockResolvedValue({ matchedCount: 1 });
AgentEvent.updateOne.mockResolvedValue({});
AgentEventService.enqueue.mockResolvedValue({});
mockBoardWakeEnabled.mockReturnValue(true);
Expand All @@ -113,6 +119,19 @@ afterAll(async () => {
});

describe('kernel sweep offeredAt filter', () => {
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),
});

const sweep = await KernelWorkSweepService.wakeForFoundWork(NOW);

expect(sweep.woken).toBe(1);
expect(AgentEventService.enqueue.mock.calls[0][0].payload.content).toContain('TASK-DEFERRED');
const after = await Task.findById(old._id).lean();
expect(after.offeredAt).toEqual(after.updatedAt);
});

it('keeps starter rows due until a Guide installation can receive them', async () => {
const starter = await createTask({
taskId: 'TASK-STARTER',
Expand All @@ -135,7 +154,7 @@ describe('kernel sweep offeredAt filter', () => {
expect(afterOffer.offeredAt).toEqual(afterOffer.updatedAt);
});

it('retries a swallowed enqueue while the unchanged revision remains in the scan window', async () => {
it('retries a swallowed enqueue while the unchanged revision remains due', async () => {
const task = await createTask({
taskId: 'TASK-RETRY',
updatedAt: new Date(NOW.getTime() - 30000),
Expand All @@ -158,7 +177,7 @@ describe('kernel sweep offeredAt filter', () => {
expect(afterRetry.offeredAt).toEqual(afterRetry.updatedAt);
});

it('offers missing/rewritten revisions, skips offered/out-of-window rows, and offers each once', async () => {
it('offers missing, rewritten and old due revisions, but skips offered and assigned rows', async () => {
const missing = await createTask({
taskId: 'TASK-MISSING',
updatedAt: new Date(NOW.getTime() - 30000),
Expand All @@ -173,9 +192,9 @@ describe('kernel sweep offeredAt filter', () => {
updatedAt: new Date(NOW.getTime() - 20000),
offeredAt: new Date(NOW.getTime() - 40000),
});
const outsideWindow = await createTask({
const olderDue = await createTask({
taskId: 'TASK-OLD',
updatedAt: new Date(SINCE.getTime() - 1),
updatedAt: new Date(NOW.getTime() - 11 * 60 * 1000),
});
const assigned = await createTask({
taskId: 'TASK-ASSIGNED',
Expand All @@ -191,7 +210,7 @@ describe('kernel sweep offeredAt filter', () => {
expect(content).toContain('TASK-MISSING');
expect(content).toContain('TASK-REWRITTEN');
expect(content).not.toContain('TASK-OFFERED');
expect(content).not.toContain('TASK-OLD');
expect(content).toContain('TASK-OLD');
expect(content).not.toContain('TASK-ASSIGNED');

const offeredMissing = await Task.findById(missing._id).lean();
Expand All @@ -200,7 +219,7 @@ describe('kernel sweep offeredAt filter', () => {
expect(offeredRewritten.offeredAt).toEqual(offeredRewritten.updatedAt);
expect((await Task.findById(alreadyOffered._id).lean()).offeredAt)
.toEqual(alreadyOffered.offeredAt);
expect((await Task.findById(outsideWindow._id).lean()).offeredAt).toBeUndefined();
expect((await Task.findById(olderDue._id).lean()).offeredAt).toEqual(olderDue.updatedAt);
expect((await Task.findById(assigned._id).lean()).offeredAt).toBeUndefined();

const second = await KernelWorkSweepService.wakeForFoundWork(NOW);
Expand Down Expand Up @@ -481,7 +500,6 @@ describe('offeredAt race probe', () => {
{
$match: {
_id: { $in: [casTask._id, nowTask._id] },
updatedAt: { $gte: SINCE },
$expr: { $lt: ['$offeredAt', '$updatedAt'] },
},
},
Expand Down
24 changes: 18 additions & 6 deletions backend/__tests__/service/tasks.claim-lease.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,12 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D
}
});

test('PATCH unassignment offers the exact stored revision before the work sweep', async () => {
test('PATCH unassignment leaves the stored revision due for the work sweep', async () => {
await AgentInstallation.updateOne(
{ agentName: 'claim-audit-agent', podId: pod._id },
{ $set: { 'config.boardWake.enabled': true,
'config.workDiscovery': { enabled: true, role: 'implementer' } } },
);
await seedTask({ assignee: 'claim-audit-agent' });
// Force the same monotonic retry the PATCH takes when a competing writer
// has committed after this request chose its timestamp.
Expand All @@ -501,19 +506,26 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D
const realNotifyPodAgents = jest.requireActual('../../services/taskEventService').notifyPodAgents;
await realNotifyPodAgents(pod._id, wake[1], wake[2], wake[3], wake[4]);
const offered = await Task.findById(stored._id);
expect(offered.offeredAt?.getTime()).toBe(offered.updatedAt.getTime());
expect(offered.offeredAt).toBeUndefined();

const sweep = await require('../../services/kernelWorkSweepService').wakeForFoundWork(new Date());
expect(sweep.scannedPods).toBe(0);
// The prior-owner notice does not offer work to idle implementers.
// Only the opted-in discovery sweep offers the unassigned row. Use a clock past the forced monotonic revision above.
const sweep = await require('../../services/kernelWorkSweepService')
.wakeForFoundWork(new Date(stored.updatedAt.getTime() + 1000));
expect(sweep.scannedPods).toBe(1);
expect(sweep.woken).toBe(1);
const swept = await Task.findById(stored._id);
expect(swept.offeredAt?.getTime()).toBe(swept.updatedAt.getTime());

const note = await request(app)
.post(`/api/v1/tasks/${pod._id}/T-1/updates`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ text: 'Still available for pickup' });
expect(note.status).toBe(200);
const afterNote = await Task.findById(stored._id);
expect(afterNote.updatedAt.getTime()).toBeGreaterThan(offered.offeredAt.getTime());
const dueSweep = await require('../../services/kernelWorkSweepService').wakeForFoundWork(new Date());
expect(afterNote.updatedAt.getTime()).toBeGreaterThan(swept.offeredAt.getTime());
const dueSweep = await require('../../services/kernelWorkSweepService')
.wakeForFoundWork(new Date(afterNote.updatedAt.getTime() + 1000));
expect(dueSweep.scannedPods).toBe(1);

const title = await request(app)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,14 @@ describe('sweep', () => {
expect(result.woken).toBe(1);
});

it('matches due revisions within the 10-minute scan bound', async () => {
it('matches unoffered revisions regardless of age so the hourly batch can retry', async () => {
Task.aggregate.mockResolvedValue([]);

await KernelWorkSweepService.sweep(NOW);

const [pipeline] = Task.aggregate.mock.calls[0];
const match = pipeline[0].$match;
expect(match.updatedAt.$gte).toEqual(new Date(NOW.getTime() - 10 * 60 * 1000));
expect(match).not.toHaveProperty('updatedAt');
expect(match.status).toBe('pending');
expect(match.$expr).toEqual({ $lt: ['$offeredAt', '$updatedAt'] });
expect(match.$or).toEqual([
Expand Down
Loading
Loading