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
29 changes: 21 additions & 8 deletions backend/__tests__/service/taskEventService.offeredAt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function createTask({
podId = POD_ID,
taskId,
title = taskId,
priority = 'P2',
updatedAt,
offeredAt,
assignee = null,
Expand All @@ -79,6 +80,7 @@ async function createTask({
taskNum,
taskId,
title,
priority,
status,
assignee,
claimedBy,
Expand Down Expand Up @@ -119,6 +121,17 @@ afterAll(async () => {
});

describe('kernel sweep offeredAt filter', () => {
it('names a late P0 before lower priorities when the discovery wake is truncated', async () => {
for (let i = 0; i < 6; i += 1) {
await createTask({ taskId: `TASK-PRIORITY-${i}`, priority: i === 5 ? 'P0' : 'P2', updatedAt: new Date(NOW.getTime() - 30000) });
}
await KernelWorkSweepService.wakeForFoundWork(NOW);
const content = AgentEventService.enqueue.mock.calls[0][0].payload.content;
expect(content.indexOf('TASK-PRIORITY-5')).toBeLessThan(content.indexOf('TASK-PRIORITY-0'));
expect(content).not.toContain('TASK-PRIORITY-4');
expect(content).toContain('[P0]');
});

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) });

Expand Down Expand Up @@ -298,18 +311,19 @@ describe('board wake assigned work', () => {
const wakes = AgentEventService.enqueue.mock.calls.map(([event]) => event);
const scout = wakes.find((event) => event.agentName === 'scout');
const peer = wakes.find((event) => event.agentName === 'sprint-impl');
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).toContain('[Your assigned rows (pending/claimed; highest priority first):]');
expect(scout.payload.content).toContain('TASK-ASSIGN — [P2] TASK-ASSIGN [pending] — Lily updated: reassigned to scout');
expect(scout.payload.content).not.toContain('TASK-FOREIGN');
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 () => {
it('applies priority before the five assigned-row limit after a folded change', async () => {
const rows = [];
for (let i = 0; i < 6; i += 1) {
rows.push(await createTask({
taskId: `TASK-OWN-${i}`,
priority: i === 5 ? 'P0' : 'P2',
updatedAt: new Date(NOW.getTime() + i * 1000),
assignee: 'scout',
}));
Expand All @@ -318,8 +332,7 @@ describe('board wake assigned work', () => {
_id: 'pending-wake', payload: { boardChanges: 1 },
});

// The oldest insertion receives the newest edit. Sorting by _id alone
// would put it last and fail to name the change that prompted this wake.
// A new edit must not displace the P0 or reshuffle equal-priority rows.
const newestAt = new Date(NOW.getTime() + 10000);
await Task.updateOne(
{ _id: rows[0]._id }, { $set: { updatedAt: newestAt } }, { timestamps: false },
Expand All @@ -329,8 +342,8 @@ describe('board wake assigned work', () => {

const content = AgentEvent.updateOne.mock.calls[0][1].$set['payload.content'];
expect(content).toContain('2 changes');
expect(content).not.toContain('TASK-OWN-1');
const positions = [0, 5, 4, 3, 2].map((i) => content.indexOf(`TASK-OWN-${i} —`));
expect(content).not.toContain('TASK-OWN-4');
const positions = [5, 0, 1, 2, 3].map((i) => content.indexOf(`TASK-OWN-${i} —`));
expect(positions.every((position) => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
});
Expand All @@ -350,7 +363,7 @@ describe('board wake assigned work', () => {
const wakes = AgentEventService.enqueue.mock.calls.map(([event]) => event);
const anvil = wakes.find((event) => event.agentName === 'anvil');
const scout = wakes.find((event) => event.agentName === 'scout');
expect(anvil.payload.content).toContain('TASK-INSTANCE — Instance Do it now [pending]');
expect(anvil.payload.content).toContain('TASK-INSTANCE — [P2] 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.deliveryMode).toBe('digest');
Expand Down
10 changes: 10 additions & 0 deletions backend/__tests__/service/tasks.claim-lease.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,4 +548,14 @@ describe('POST /api/v1/tasks/:podId/:taskId/claim — lease semantics (ADR-018 D
expect(new Date(completed.body.task.updatedAt).getTime())
.toBeGreaterThan(new Date(claimed.body.task.updatedAt).getTime());
});
test('priority edits through note append respect another seat live lease', async () => {
await seedTask({ status: 'claimed', claimedBy: String(rival._id), claimedAt: new Date(), claimExpiresAt: new Date(Date.now() + LEASE_MS), priority: 'P1' });
const response = await request(app)
.post(`/api/v1/tasks/${pod._id}/T-1/updates`)
.set('Authorization', `Bearer ${AGENT_TOKEN}`)
.send({ text: 'Change priority', priority: 'P3', continueAnyway: true });
expect(response.status).toBe(409);
expect((await Task.findOne({ podId: pod._id, taskId: 'T-1' })).priority).toBe('P1');
});

});
116 changes: 116 additions & 0 deletions backend/__tests__/service/tasks.priority.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
process.env.PG_HOST = '';
process.env.JWT_SECRET = 'task-priority-service-test';

jest.mock('../../services/taskEventService', () => ({
...jest.requireActual('../../services/taskEventService'),
notifyPodAgents: jest.fn().mockResolvedValue(undefined),
}));
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const Task = require('../../models/Task');
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const AgentEvent = require('../../models/AgentEvent');
const { migrateTaskPriority } = require('../../scripts/migrate-task-priority');
const { setupMongoDb, closeMongoDb, clearMongoDb, generateTestToken } = require('../utils/testUtils');
const routes = require('../../routes/tasksApi');
const { bindSocketIO } = require('../../services/taskEventService');

let pod;
let token;
const app = express();
app.use(express.json());
app.use('/api/v1/tasks', routes);
beforeAll(setupMongoDb);
afterAll(closeMongoDb);
beforeEach(async () => {
await clearMongoDb();
const owner = await User.create({ username: 'priority-owner', email: 'priority@test.com', password: 'Password123!', verified: true });
token = generateTestToken(owner._id);
pod = await Pod.create({ name: 'Priority pod', type: 'team', createdBy: owner._id, members: [owner._id] });
});
const api = () => `/api/v1/tasks/${pod._id}`;

test('create, edit and list use validated P0–P3 and priority order', async () => {
const normal = await request(app).post(api()).set('Authorization', `Bearer ${token}`).send({ title: 'Normal' }).expect(201);
expect(normal.body.task.priority).toBe('P2');
await request(app).post(api()).set('Authorization', `Bearer ${token}`).send({ title: 'Urgent', priority: 'P0' }).expect(201);
await request(app).post(api()).set('Authorization', `Bearer ${token}`).send({ title: 'Bad', priority: { $ne: null } }).expect(400);
await request(app).patch(`${api()}/${normal.body.task.taskId}`).set('Authorization', `Bearer ${token}`).send({ priority: 'P1' }).expect(200);
await request(app).patch(`${api()}/${normal.body.task.taskId}`).set('Authorization', `Bearer ${token}`).send({ priority: 'critical' }).expect(400);
const list = await request(app).get(api()).set('Authorization', `Bearer ${token}`).expect(200);
expect(list.body.tasks.map((row) => row.priority)).toEqual(['P0', 'P1']);
expect(list.body.permissions.canWrite).toBe(true);
});

test('migration copies prefixes silently, preserves explicit priority/revisions and is rerunnable', async () => {
const at = new Date('2026-10-01T00:00:00Z');
const rows = [
{ taskId: 'TASK-1', title: '[P0] Urgent' },
{ taskId: 'TASK-2', title: 'Ordinary' },
{ taskId: 'TASK-3', title: '[P3] Explicit', priority: 'P1' },
{ taskId: 'TASK-4', title: '[P0] Finished', status: 'done' },
].map((row, i) => ({ _id: new mongoose.Types.ObjectId(), podId: pod._id, taskNum: i + 1, status: 'pending', createdAt: at, updatedAt: at, offeredAt: at, updates: [], ...row }));
await Task.collection.insertMany(rows);
const before = await AgentEvent.countDocuments({});
const emit = jest.fn();
bindSocketIO({ to: () => ({ emit }) });
expect(await migrateTaskPriority()).toEqual({ matched: 3, modified: 0 });
expect((await Task.collection.findOne({ taskId: 'TASK-1' })).title).toBe('[P0] Urgent');
expect(await migrateTaskPriority(false)).toEqual({ matched: 3, modified: 3 });
expect(await migrateTaskPriority(false)).toEqual({ matched: 0, modified: 0 });
const migrated = await Task.collection.find({ podId: pod._id }).sort({ taskNum: 1 }).toArray();
expect(migrated.map((row) => [row.title, row.priority])).toEqual([
['Urgent', 'P0'], ['Ordinary', 'P2'], ['Explicit', 'P1'], ['[P0] Finished', undefined],
]);
for (const row of migrated) {
expect(row.updatedAt).toEqual(at);
expect(row.offeredAt).toEqual(at);
expect(row.updates).toEqual([]);
}
expect(await AgentEvent.countDocuments({})).toBe(before);
expect(emit).not.toHaveBeenCalled();
bindSocketIO(null);
});

test('self-follow is idempotent and preserves task revisions without waking agents', async () => {
const row = await Task.create({ podId: pod._id, taskId: 'TASK-1', taskNum: 1, title: 'Watch me' });
const before = await AgentEvent.countDocuments({});
const watch = `${api()}/TASK-1/watch`;
for (let i = 0; i < 2; i += 1) {
await request(app).post(watch).set('Authorization', `Bearer ${token}`).send({ following: true, userId: new mongoose.Types.ObjectId() }).expect(200);
}
const followed = await Task.findById(row._id).lean();
expect(followed.watchers.map(String)).toEqual([String(pod.createdBy)]);
expect(followed.updatedAt).toEqual(row.updatedAt);
const stranger = await User.create({ username: 'stranger', email: 'stranger@test.com', password: 'Password123!', verified: true });
await request(app).post(watch).set('Authorization', `Bearer ${generateTestToken(stranger._id)}`).send({ following: true }).expect(403);
await request(app).post(watch).set('Authorization', `Bearer ${token}`).send({ following: false }).expect(200);
expect((await Task.findById(row._id)).watchers).toHaveLength(0);
expect(await AgentEvent.countDocuments({})).toBe(before);
});

test('note updates can change priority and reject invalid values', async () => {
const row = await Task.create({ podId: pod._id, taskId: 'TASK-1', taskNum: 1, title: 'Prioritize me' });
await request(app).post(`${api()}/TASK-1/updates`).set('Authorization', `Bearer ${token}`).send({ text: 'Urgent', priority: 'P0' }).expect(200);
expect((await Task.findById(row._id)).priority).toBe('P0');
await request(app).post(`${api()}/TASK-1/updates`).set('Authorization', `Bearer ${token}`).send({ text: 'Invalid', priority: 0 }).expect(400);
});

test('viewer members can follow, while a DM observer still cannot subscribe', async () => {
await Task.create({ podId: pod._id, taskId: 'TASK-1', taskNum: 1, title: 'Read-only work' });
// Exercise the role-bearing membership shape supported by the route helper.
await Pod.collection.updateOne({ _id: pod._id }, { $set: { members: [{ userId: pod.createdBy, role: 'viewer' }] } });
const view = await request(app).get(api()).set('Authorization', `Bearer ${token}`).expect(200);
expect(view.body.permissions.canWrite).toBe(false);
await request(app).post(`${api()}/TASK-1/watch`).set('Authorization', `Bearer ${token}`).send({ following: true }).expect(200);
await request(app).patch(`${api()}/TASK-1`).set('Authorization', `Bearer ${token}`).send({ priority: 'P0' }).expect(403);
const DMService = require('../../services/dmService');
const observerAccess = jest.spyOn(DMService, 'canViewPod').mockResolvedValue(true);
const observer = await User.create({ username: 'observer', email: 'observer@test.com', password: 'Password123!', verified: true });
try {
await request(app).post(`${api()}/TASK-1/watch`).set('Authorization', `Bearer ${generateTestToken(observer._id)}`).send({ following: true }).expect(403);
expect(observerAccess).not.toHaveBeenCalled();
} finally { observerAccess.mockRestore(); }
});
93 changes: 93 additions & 0 deletions backend/__tests__/service/tasks.watchers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
process.env.PG_HOST = '';
process.env.JWT_SECRET = 'task-watchers-service-test';
const express = require('express');
const request = require('supertest');
const Task = require('../../models/Task');
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const AgentEvent = require('../../models/AgentEvent');
const { AgentInstallation } = require('../../models/AgentRegistry');
const { hash } = require('../../utils/secret');
const { notifyPodAgents, retryTaskWatcherDigests } = require('../../services/taskEventService');
const routes = require('../../routes/tasksApi');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');
const app = express(); app.use(express.json()); app.use('/api/v1/tasks', routes);
beforeAll(setupMongoDb); afterAll(closeMongoDb); afterEach(clearMongoDb);

test('a bot follows, loses its active installation, and receives exactly one digest with no wake', async () => {
const secret = 'cm_agent_follow_test';
const bot = await User.create({ username: 'watcher-secondary', email: 'watcher@test.com', password: 'Password123!', verified: true, isBot: true, botType: 'agent', botMetadata: { agentName: 'watcher', instanceId: 'default' }, agentRuntimeTokens: [{ tokenHash: hash(secret), label: 'test', createdAt: new Date() }] });
const pod = await Pod.create({ name: 'Watched pod', type: 'team', createdBy: bot._id, members: [bot._id] });
const installation = await AgentInstallation.create({ agentName: 'watcher', instanceId: 'secondary', podId: pod._id, version: '1.0.0', installedBy: bot._id });
const row = await Task.create({ podId: pod._id, taskId: 'TASK-1', taskNum: 1, title: 'Watch this', status: 'pending' });
await request(app).post(`/api/v1/tasks/${pod._id}/TASK-1/watch`).set('Authorization', `Bearer ${secret}`).send({ following: true }).expect(200);
expect(await AgentEvent.countDocuments({})).toBe(0);
await AgentInstallation.updateOne({ _id: installation._id }, { $set: { status: 'paused' } });
const changed = await Task.findByIdAndUpdate(row._id, { $set: { notes: 'Work moved' } }, { new: true }).lean();
await notifyPodAgents(pod._id, changed, 'updated', { isAgent: false });
const events = await AgentEvent.find({ podId: pod._id }).lean();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ agentName: 'watcher', instanceId: 'secondary', deliveryMode: 'digest', status: 'pending', payload: { taskId: 'TASK-1', boardDigest: true } });
await AgentEvent.deleteMany({});
await AgentInstallation.updateOne({ _id: installation._id }, { $set: { status: 'active' } });
await notifyPodAgents(pod._id, changed, 'updated', { isAgent: false });
expect(await AgentEvent.countDocuments({ podId: pod._id })).toBe(1);
expect(await AgentEvent.countDocuments({ podId: pod._id, deliveryMode: { $ne: 'digest' } })).toBe(0);
await AgentEvent.deleteMany({});
await Pod.updateOne({ _id: pod._id }, { $pull: { members: bot._id } });
await AgentInstallation.updateOne({ _id: installation._id }, { $set: { status: 'paused' } });
await notifyPodAgents(pod._id, changed, 'updated', { isAgent: false });
expect(await AgentEvent.countDocuments({})).toBe(0);
});


test.each(['user', 'pod'])('failed %s lookup retains a durable retry, then rechecks membership without a wake', async (failingModel) => {
const bot = await User.create({ username: 'waiting-bot', email: 'waiting@test.com', password: 'Password123!', isBot: true, botType: 'agent', botMetadata: { agentName: 'waiting-bot', instanceId: 'default' } });
const pod = await Pod.create({ name: 'Retry receipts', type: 'team', createdBy: bot._id, members: [bot._id] });
const task = await Task.create({ podId: pod._id, taskId: 'TASK-2', taskNum: 2, title: 'Still visible', status: 'claimed', claimedBy: String(bot._id), claimExpiresAt: new Date(Date.now() + 60000), watchers: [bot._id] });
const original = task.toObject();
const target = failingModel === 'user' ? User : Pod;
const method = failingModel === 'user' ? 'find' : 'findById';
const lookup = jest.spyOn(target, method).mockReturnValueOnce({ select: () => ({ lean: () => Promise.reject(new Error('temporary lookup outage')) }) });
await notifyPodAgents(pod._id, original, 'updated', { isAgent: false });
lookup.mockRestore();
const pending = await Task.findById(task._id).lean();
expect(pending.watcherDeliveryPendingAt).toEqual(original.updatedAt);
expect(pending.updatedAt).toEqual(original.updatedAt);
expect(pending.claimExpiresAt).toEqual(original.claimExpiresAt);
expect(await AgentEvent.countDocuments({})).toBe(0);
await retryTaskWatcherDigests();
expect(await AgentEvent.countDocuments({ deliveryMode: 'digest' })).toBe(1);
expect(await AgentEvent.countDocuments({ deliveryMode: { $ne: 'digest' } })).toBe(0);
expect((await Task.findById(task._id).lean()).watcherDeliveryPendingAt).toBeUndefined();
await retryTaskWatcherDigests();
expect(await AgentEvent.countDocuments({})).toBe(1);
await AgentEvent.deleteMany({});
await Task.updateOne({ _id: task._id }, { $set: { watcherDeliveryPendingAt: original.updatedAt } }, { timestamps: false });
await Pod.updateOne({ _id: pod._id }, { $pull: { members: bot._id } });
await retryTaskWatcherDigests();
expect(await AgentEvent.countDocuments({})).toBe(0);
expect((await Task.findById(task._id).lean()).watcherDeliveryPendingAt).toBeUndefined();
});

test('a permanently failing batch cannot starve a newer watched task', async () => {
const bot = await User.create({ username: 'batch-watcher', email: 'batch@test.com', password: 'Password123!', isBot: true, botType: 'agent', botMetadata: { agentName: 'batch-watcher', instanceId: 'default' } });
const pod = await Pod.create({ name: 'Receipt fairness', type: 'team', createdBy: bot._id, members: [bot._id] });
const revision = new Date('2030-01-01T00:00:00Z');
await Task.insertMany(Array.from({ length: 101 }, (_, index) => ({ podId: pod._id, taskId: `TASK-${index + 1}`, taskNum: index + 1, title: 'Pending receipt', watchers: [bot._id], watcherDeliveryPendingAt: revision })));
const AgentEventService = require('../../services/agentEventService');
const enqueue = AgentEventService.enqueue.bind(AgentEventService);
const failure = jest.spyOn(AgentEventService, 'enqueue').mockImplementation((event) => {
if (event.payload.taskId !== 'TASK-101') return Promise.reject(new Error('permanent recipient failure'));
return enqueue(event);
});
const warning = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
await retryTaskWatcherDigests();
expect(await AgentEvent.countDocuments({})).toBe(0);
await retryTaskWatcherDigests();
expect(await AgentEvent.countDocuments({ 'payload.taskId': 'TASK-101', deliveryMode: 'digest' })).toBe(1);
expect(await AgentEvent.countDocuments({ deliveryMode: { $ne: 'digest' } })).toBe(0);
expect(await Task.countDocuments({ watcherDeliveryPendingAt: { $type: 'date' } })).toBe(100);
} finally { failure.mockRestore(); warning.mockRestore(); }
});
Loading
Loading