diff --git a/backend/__tests__/service/taskEventService.offeredAt.test.js b/backend/__tests__/service/taskEventService.offeredAt.test.js index ea75e544f6..003213a1b7 100644 --- a/backend/__tests__/service/taskEventService.offeredAt.test.js +++ b/backend/__tests__/service/taskEventService.offeredAt.test.js @@ -66,6 +66,7 @@ async function createTask({ podId = POD_ID, taskId, title = taskId, + priority = 'P2', updatedAt, offeredAt, assignee = null, @@ -79,6 +80,7 @@ async function createTask({ taskNum, taskId, title, + priority, status, assignee, claimedBy, @@ -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) }); @@ -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', })); @@ -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 }, @@ -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)); }); @@ -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'); diff --git a/backend/__tests__/service/tasks.claim-lease.test.js b/backend/__tests__/service/tasks.claim-lease.test.js index 2332ad214d..7e811e0031 100644 --- a/backend/__tests__/service/tasks.claim-lease.test.js +++ b/backend/__tests__/service/tasks.claim-lease.test.js @@ -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'); + }); + }); diff --git a/backend/__tests__/service/tasks.priority.test.js b/backend/__tests__/service/tasks.priority.test.js new file mode 100644 index 0000000000..ced40c5d0d --- /dev/null +++ b/backend/__tests__/service/tasks.priority.test.js @@ -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(); } +}); diff --git a/backend/__tests__/service/tasks.watchers.test.js b/backend/__tests__/service/tasks.watchers.test.js new file mode 100644 index 0000000000..9bbb55e901 --- /dev/null +++ b/backend/__tests__/service/tasks.watchers.test.js @@ -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(); } +}); diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 34e51ec63f..faa4063f3d 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -424,3 +424,52 @@ test('scheduler does not generate a digest for a bot', async () => { expect(generate).not.toHaveBeenCalled(); }); + +test('followed task updates reach only a current member digest and create no attention or wakes', async () => { + const Task = require('../../../models/Task'); + const AgentEvent = require('../../../models/AgentEvent'); + const member = await person(new Date(Date.now() - 25 * 60 * 60 * 1000)); + const pod = await podWithSummary(member, 'Followed work'); + const removedPod = await podWithSummary(await person(), 'Former membership'); + const row = await Task.create({ podId: pod._id, taskId: 'TASK-WATCHED', taskNum: 1, title: 'Followed marker', watchers: [member._id] }); + await Task.create({ podId: removedPod._id, taskId: 'TASK-SECRET', taskNum: 1, title: 'Former pod secret', watchers: [member._id] }); + const stale = await Task.create({ podId: pod._id, taskId: 'TASK-STALE', taskNum: 2, title: 'Old watched marker', watchers: [member._id], updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) }); + await Task.collection.updateOne({ _id: stale._id }, { $set: { updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) } }); + const attentionBefore = await AttentionItem.countDocuments({}); + const eventsBefore = await AgentEvent.countDocuments({}); + const digest = await new DailyDigestService().generateUserDailyDigest(member._id, { requireAway: true }); + expect(digest.content).toContain('Followed marker'); + expect(digest.content).not.toContain('Former pod secret'); + expect(digest.content).not.toContain('Old watched marker'); + expect(await AttentionItem.countDocuments({})).toBe(attentionBefore); + expect(await AgentEvent.countDocuments({})).toBe(eventsBefore); + await Pod.updateOne({ _id: pod._id }, { $pull: { members: member._id } }); + const removed = await new DailyDigestService().generateUserDailyDigest(member._id); + expect(removed.content).not.toContain(row.title); +}); + +test('followed legacy done rows cannot crowd a P0 out of the digest limit', async () => { + const Task = require('../../../models/Task'); + const member = await person(new Date(Date.now() - 25 * 60 * 60 * 1000)); + const pod = await podWithSummary(member, 'Priority digest'); + for (let i = 0; i < 6; i += 1) { + const task = await Task.create({ podId: pod._id, taskId: `TASK-${i}`, taskNum: i, title: `Priority marker ${i}`, status: 'done', priority: i === 5 ? 'P0' : 'P2', watchers: [member._id] }); + if (i < 5) await Task.collection.updateOne({ _id: task._id }, { $unset: { priority: '' } }); + } + const digest = await new DailyDigestService().generateUserDailyDigest(member._id); + expect(digest.content).toContain('Following TASK-5: Priority marker 5'); + const followedLines = digest.content.split('\n').filter((line) => line.startsWith('- Following')); + expect(followedLines).toHaveLength(5); + expect(followedLines[0]).toContain('TASK-5'); +}); + +test('an old human watcher gets a scheduled digest for task-only activity', async () => { + const Task = require('../../../models/Task'); + const member = await person(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)); + const pod = await Pod.create({ name: 'Silent pod', members: [member._id], createdBy: member._id }); + await Task.create({ podId: pod._id, taskId: 'TASK-ONLY', taskNum: 1, title: 'Task-only marker', watchers: [member._id] }); + const results = await new DailyDigestService().generateAllDailyDigests({ at: digestHourUtc() }); + expect(results.some((result) => String(result.userId) === String(member._id) && result.success)).toBe(true); + const digest = await Summary.findOne({ 'metadata.userId': String(member._id), type: 'daily-digest' }); + expect(digest.content).toContain('Task-only marker'); +}); diff --git a/backend/__tests__/unit/services/kernelWorkSweepService.test.js b/backend/__tests__/unit/services/kernelWorkSweepService.test.js index 02fa315462..5c1a9d371c 100644 --- a/backend/__tests__/unit/services/kernelWorkSweepService.test.js +++ b/backend/__tests__/unit/services/kernelWorkSweepService.test.js @@ -19,6 +19,7 @@ jest.mock('../../../models/AgentRegistry', () => ({ const mockNotifyFoundWork = jest.fn(); const mockNotifyLeaseWarning = jest.fn(); jest.mock('../../../services/taskEventService', () => ({ + retryTaskWatcherDigests: jest.fn().mockResolvedValue(undefined), notifyFoundWork: (...args) => mockNotifyFoundWork(...args), notifyLeaseWarning: (...args) => mockNotifyLeaseWarning(...args), })); @@ -353,7 +354,7 @@ describe('sweep', () => { { assignee: '' }, { assignee: { $exists: false } }, ]); - const group = pipeline[1].$group; + const group = pipeline.find((stage) => stage.$group).$group; expect(group.tasks.$push).toMatchObject({ _id: '$_id', updatedAt: '$updatedAt', @@ -417,3 +418,25 @@ describe('lapsed contract — the sweep and the claim CAS agree on what lapsed m expect(sweep).toEqual(cas.slice(-2)); }); }); + + +test('watcher receipts retry even when work discovery is disabled', async () => { + process.env.AGENT_WORK_SWEEP_DISABLED = 'true'; + const receipts = require('../../../services/taskEventService').retryTaskWatcherDigests; + await KernelWorkSweepService.sweep(NOW); + expect(receipts).toHaveBeenCalledTimes(1); + expect(mockNotifyFoundWork).not.toHaveBeenCalled(); +}); + + +test('a watcher retry failure does not block rescue or work discovery', async () => { + const receipts = require('../../../services/taskEventService').retryTaskWatcherDigests; + receipts.mockRejectedValueOnce(new Error('lookup failed')); + const rescue = jest.spyOn(KernelWorkSweepService, 'rescueLapsed'); + const discovery = jest.spyOn(KernelWorkSweepService, 'wakeForFoundWork'); + try { + await KernelWorkSweepService.sweep(NOW); + expect(rescue).toHaveBeenCalledTimes(1); + expect(discovery).toHaveBeenCalledTimes(1); + } finally { rescue.mockRestore(); discovery.mockRestore(); } +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 6dd62afcae..945f7e1f7f 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -96,6 +96,7 @@ jest.mock('../../../services/outboundUrlGuard', () => ({ const mockTaskRows = jest.fn(); const mockTaskCount = jest.fn(); jest.mock('../../../models/Task', () => ({ + aggregate: jest.fn(() => mockTaskRows()), find: jest.fn(() => ({ sort: () => ({ limit: () => ({ select: () => ({ lean: () => mockTaskRows() }) }) }), })), @@ -369,10 +370,19 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => await runAgent(hostedInstall(), mention); const Task = require('../../../models/Task'); - expect(Task.find).toHaveBeenCalledWith({ podId: POD, status: { $in: ['pending', 'claimed', 'blocked'] } }); + expect(Task.aggregate).toHaveBeenCalledWith(expect.arrayContaining([ + { $match: { podId: expect.objectContaining({}), status: { $in: ['pending', 'claimed', 'blocked'] } } }, + ])); + const pipeline = Task.aggregate.mock.calls[0][0]; + expect(String(pipeline[0].$match.podId)).toBe(POD); + expect(pipeline).toContainEqual({ $set: { priority: { $ifNull: ['$priority', 'P2'] } } }); + const sortIndex = pipeline.findIndex((stage) => stage.$sort?.priority === 1); + const limitIndex = pipeline.findIndex((stage) => stage.$limit); + expect(sortIndex).toBeGreaterThan(-1); + expect(limitIndex).toBeGreaterThan(sortIndex); const listed = JSON.parse(toolMessages(1)[0].content); expect(listed).toEqual({ - status: 'open', total: 3, tasks: [{ taskId: 'TASK-1', title: 'Say hello to the pod', status: 'pending', assignee: null }], + status: 'open', total: 3, tasks: [{ taskId: 'TASK-1', title: 'Say hello to the pod', priority: 'P2', status: 'pending', assignee: null }], }); expect(systemPromptIn(0)).toMatch(/check the board with commonly_list_tasks before saying what is or is not on it/); // Walk 5: it offered to "mark TASK-002 done" with no tool that can. diff --git a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js index 6569a9a9bd..5175e4c155 100644 --- a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js +++ b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js @@ -31,6 +31,7 @@ jest.mock('../../../models/User', () => ({ const mockTaskUpdateOne = jest.fn(); jest.mock('../../../models/Task', () => ({ updateOne: (...args) => mockTaskUpdateOne(...args), + aggregate: jest.fn().mockResolvedValue([]), find: jest.fn(), exists: jest.fn(), })); diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 72759e67f5..6ff26d49ff 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -1,3 +1,4 @@ +import { resolveTokenAgentIdentity } from '../utils/agentTokenIdentity'; import { Request, Response, NextFunction } from 'express'; import { AgentInstallation } from '../models/AgentRegistry'; import User, { IUser } from '../models/User'; @@ -14,37 +15,6 @@ import { machineHasLiveAuthorization } from '../services/machineService'; // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; -const normalizeTokenIdentityValue = (value: unknown): string => - String(value || '').trim().toLowerCase(); - -const deriveInstanceIdFromUsername = (agentName: string, username: string): string | null => { - const normalizedAgent = normalizeTokenIdentityValue(agentName); - const normalizedUsername = normalizeTokenIdentityValue(username); - if (!normalizedAgent || !normalizedUsername) return null; - if (normalizedUsername === normalizedAgent) return 'default'; - const prefix = `${normalizedAgent}-`; - if (normalizedUsername.startsWith(prefix)) { - const suffix = normalizedUsername.slice(prefix.length).trim(); - return suffix || null; - } - return null; -}; - -const resolveTokenAgentIdentity = (agentUser: IUser): { agentName: string; instanceId: string } => { - const meta = agentUser?.botMetadata || {}; - const username = normalizeTokenIdentityValue(agentUser?.username); - const agentName = normalizeTokenIdentityValue(meta.agentName || meta.instanceId || username); - - const metadataInstanceId = normalizeTokenIdentityValue(meta.instanceId); - const usernameInstanceId = deriveInstanceIdFromUsername(agentName, username); - let instanceId = metadataInstanceId || usernameInstanceId || 'default'; - if (usernameInstanceId && (!metadataInstanceId || metadataInstanceId === 'default')) { - instanceId = usernameInstanceId; - } - - return { agentName, instanceId }; -}; - /** * ADR-026 / TASK-094: resolve the agent identity behind a per-spawn child * token. diff --git a/backend/models/Task.ts b/backend/models/Task.ts index f205ba7115..90eaeb85f2 100644 --- a/backend/models/Task.ts +++ b/backend/models/Task.ts @@ -1,5 +1,7 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; +export type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3'; + export type TaskStatus = 'pending' | 'claimed' | 'done' | 'blocked'; export interface ITaskUpdate { @@ -14,6 +16,9 @@ export interface ITask extends Document { taskNum: number; taskId: string; title: string; + priority: TaskPriority; + watchers: Types.ObjectId[]; + watcherDeliveryPendingAt?: Date; assignee?: string | null; dep?: string | null; depMockOk: boolean; @@ -52,6 +57,10 @@ const TaskSchema = new Schema( taskNum: { type: Number, required: true }, taskId: { type: String, required: true }, title: { type: String, required: true }, + priority: { type: String, enum: ['P0', 'P1', 'P2', 'P3'], default: 'P2' }, + watchers: [{ type: Schema.Types.ObjectId, ref: 'User' }], + // Internal retry revision; never advances the public task revision. + watcherDeliveryPendingAt: { type: Date }, assignee: { type: String, default: null }, dep: { type: String, default: null }, depMockOk: { type: Boolean, default: false }, @@ -86,6 +95,8 @@ const TaskSchema = new Schema( ); TaskSchema.index({ podId: 1, status: 1 }); +TaskSchema.index({ watchers: 1, podId: 1, updatedAt: -1 }); +TaskSchema.index({ watcherDeliveryPendingAt: 1, _id: 1 }, { partialFilterExpression: { watcherDeliveryPendingAt: { $type: 'date' } } }); TaskSchema.index({ podId: 1, assignee: 1, status: 1 }); TaskSchema.index( { podId: 1, prUrl: 1, status: 1 }, diff --git a/backend/package.json b/backend/package.json index 40e73115a7..84e0f900cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "bootstrap:clawd-bot": "node scripts/bootstrap-clawd-bot.js", "backfill:attention-items": "ts-node scripts/backfill-attention-items.ts", "backfill:message-author-kind": "ts-node scripts/backfill-message-author-kind.ts", + "migrate:task-priority": "ts-node scripts/migrate-task-priority.ts", "migrate:task-source-ref-identity": "ts-node scripts/migrate-task-source-ref-identity.ts", "sweep:resolved-mention-attention": "ts-node scripts/sweep-resolved-mention-attention.ts", "sweep:done-task-handoffs": "ts-node scripts/sweep-done-task-handoffs.ts", diff --git a/backend/routes/tasksApi.ts b/backend/routes/tasksApi.ts index abeaafd93d..32c9a2e0c0 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -1,3 +1,4 @@ +import { compareTaskPriority, isTaskPriority } from '../utils/taskPriority'; import { createHash } from 'crypto'; import type { Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; @@ -194,7 +195,7 @@ function resolveAgentInstanceId(req: AuthReq): string | null { return req.user.botMetadata?.instanceId || req.user.botMetadata?.agentName || null; } -async function requirePodMember(podId: string, userId: unknown, { write = false } = {}): Promise<{ error?: string; status?: number; pod?: unknown }> { +async function requirePodMember(podId: string, userId: unknown, { write = false, memberOnly = false } = {}): Promise<{ error?: string; status?: number; pod?: unknown; canWrite?: boolean }> { const pod = await Pod.findById(podId).lean() as { type?: string; members?: Array<{ userId?: { toString: () => string }; toString: () => string; role?: string }> } | null; if (!pod) return { error: 'Pod not found', status: 404 }; const membership = pod.members?.find((m) => { @@ -207,15 +208,15 @@ async function requirePodMember(podId: string, userId: unknown, { write = false // but not post tasks into someone else's bot-bot DM. The viewer-role // gate below applies to writes regardless. if (!membership) { - if (write) return { error: 'Write access denied', status: 403 }; + if (write || memberOnly) return { error: 'Write access denied', status: 403 }; // eslint-disable-next-line global-require const DMService = require('../services/dmService'); const canView = await DMService.canViewPod(userId, pod); if (!canView) return { error: 'Access denied', status: 403 }; - return { pod }; + return { pod, canWrite: false }; } if (write && (membership as { role?: string }).role === 'viewer') return { error: 'Write access denied', status: 403 }; - return { pod }; + return { pod, canWrite: (membership as { role?: string }).role !== 'viewer' }; } async function nextTaskId(podId: string): Promise<{ taskId: string; taskNum: number }> { @@ -257,7 +258,8 @@ router.get('/:podId', taskReadIpRateLimit, auth, async (req: AuthReq, res: Res) // tick boundary could otherwise report lease states that never coexisted. const now = new Date(); return res.json({ - tasks: tasks.map((task: Record) => ({ ...task, leaseState: deriveLeaseState(task, now) })), + permissions: { canWrite: access.canWrite === true }, + tasks: tasks.sort(compareTaskPriority).map((task: Record) => ({ ...task, leaseState: deriveLeaseState(task, now) })), }); } catch (err) { console.error('GET /tasks error:', err); @@ -278,6 +280,7 @@ router.post('/:podId', rateLimit({ const userId = req.userId || req.user?._id || req.agentUser?._id; const { title: titleInput, + priority: priorityInput, assignee: assigneeInput, dep: depInput, depMockOk: depMockOkInput, @@ -285,6 +288,9 @@ router.post('/:podId', rateLimit({ source: sourceInput, sourceRef: sourceRefInput, } = req.body || {}; + if (priorityInput !== undefined && !isTaskPriority(priorityInput)) { + return res.status(400).json({ error: 'priority must be P0, P1, P2 or P3' }); + } const stringInputs: Record = { title: titleInput, assignee: assigneeInput, @@ -397,7 +403,7 @@ router.post('/:podId', rateLimit({ : source || 'human'; let task; try { - task = await Task.create({ podId, taskNum, taskId, title, assignee: assignee || null, dep: dep || null, depMockOk: !!depMockOk, parentTask: parentTask || null, source: taskSource, sourceRef, updates: [initUpdate] }); + task = await Task.create({ podId, taskNum, taskId, title, priority: priorityInput || 'P2', assignee: assignee || null, dep: dep || null, depMockOk: !!depMockOk, parentTask: parentTask || null, source: taskSource, sourceRef, updates: [initUpdate] }); } catch (createErr) { const duplicate = createErr as { code?: number; @@ -762,11 +768,39 @@ router.post('/:podId/:taskId/complete', taskWriteRateLimit(30), auth, async (req } }); +// Following is a self-service digest subscription, never task ownership. +router.post('/:podId/:taskId/watch', taskWriteRateLimit(60), auth, async (req: AuthReq, res: Res) => { + try { + const { podId, taskId } = req.params || {}; + const userId = req.userId || req.user?._id || req.agentUser?._id; + const { following } = req.body || {}; + if (typeof following !== 'boolean') return res.status(400).json({ error: 'following must be a boolean' }); + const access = await requirePodMember(podId || '', userId, { memberOnly: true }); + if (access.error) return res.status(access.status || 500).json({ error: access.error }); + const task = await Task.findOneAndUpdate( + { podId: mongoose.Types.ObjectId.createFromHexString(podId || ''), taskId }, + following ? { $addToSet: { watchers: userId } } : { $pull: { watchers: userId } }, + { new: true, timestamps: false }, + ).lean(); + if (!task) return res.status(404).json({ error: 'Task not found' }); + // Socket readers see the new follower count. No AgentEvent, attention item, + // task revision or ownership wake is produced by a subscription change. + emitTaskUpdated(podId, task, 'updated'); + return res.json({ task }); + } catch (error) { + console.error('POST /tasks/watch error:', error); + return res.status(500).json({ error: 'Failed to update task subscription' }); + } +}); + router.post('/:podId/:taskId/updates', taskWriteRateLimit(60), auth, async (req: AuthReq, res: Res) => { try { const { podId, taskId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; - const { text } = (req.body || {}) as { text?: string }; + const { text, priority } = (req.body || {}) as { text?: string; priority?: unknown }; + if (priority !== undefined && !isTaskPriority(priority)) { + return res.status(400).json({ error: 'priority must be P0, P1, P2 or P3' }); + } if (!text?.trim()) return res.status(400).json({ error: 'text is required' }); const access = await requirePodMember(podId || '', userId, { write: true }); if (access.error) return res.status(access.status || 500).json({ error: access.error }); @@ -774,6 +808,11 @@ router.post('/:podId/:taskId/updates', taskWriteRateLimit(60), auth, async (req: const hold = await AgentSendFreshness.unseenMessages(userId, podId); if (hold) return res.status(409).json(hold); } + if (priority !== undefined) { + const refusal = await leaseRefusalForWrite(podId || '', taskId || '', req, userId); + if (refusal) return res.status(409).json(refusal); + } + const priorityUpdate = priority === undefined ? {} : { priority }; const author = await resolveAuthor(req); const now = new Date(); const note = { updates: { text: text.trim(), author, authorId: userId?.toString() || null, createdAt: now } }; @@ -809,7 +848,7 @@ router.post('/:podId/:taskId/updates', taskWriteRateLimit(60), auth, async (req: // is the opposite of silence. Observed live on TASK-025: note at // 04:35:54 moved claimExpiresAt to 05:05:54 with rescueDeferrals stuck // at 1. - { $set: { claimExpiresAt: new Date(now.getTime() + TASK_CLAIM_LEASE_MS), rescueDeferrals: 0 }, $push: note }, + { $set: { ...priorityUpdate, claimExpiresAt: new Date(now.getTime() + TASK_CLAIM_LEASE_MS), rescueDeferrals: 0 }, $push: note }, ) : null; let leaseRenewed = Boolean(task); @@ -899,6 +938,7 @@ router.post('/:podId/:taskId/updates', taskWriteRateLimit(60), auth, async (req: { podId: podFilter, taskId, status: 'pending', lapsedFrom: { $in: identities } }, { $set: { + ...priorityUpdate, status: 'claimed', claimedBy: claimKey, claimedAt: now, @@ -913,7 +953,8 @@ router.post('/:podId/:taskId/updates', taskWriteRateLimit(60), auth, async (req: } if (!task) { - task = await updateTaskWithRevision({ podId: podFilter, taskId }, { $push: note }); + task = await updateTaskWithRevision({ podId: podFilter, taskId, ...(priority === undefined ? {} : writableLeaseCondition(req, userId, now)) }, + { $set: priorityUpdate, $push: note }); } if (!task) { const existing = await Task.findOne({ podId: podFilter, taskId }).select('_id').lean(); @@ -957,10 +998,13 @@ router.patch('/:podId/:taskId', taskWriteRateLimit(60), auth, async (req: AuthRe try { const { podId, taskId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; - const allowed = ['title', 'assignee', 'dep', 'depMockOk', 'parentTask', 'status', 'notes', 'prUrl']; + const allowed = ['title', 'priority', 'assignee', 'dep', 'depMockOk', 'parentTask', 'status', 'notes', 'prUrl']; const fieldUpdates: Record = {}; const body = (req.body || {}) as Record; allowed.forEach((k) => { if (body[k] !== undefined) fieldUpdates[k] = body[k]; }); + if (fieldUpdates.priority !== undefined && !isTaskPriority(fieldUpdates.priority)) { + return res.status(400).json({ error: 'priority must be P0, P1, P2 or P3' }); + } // Unassign has two spellings and only one of them was reaching the DB. The // openclaw tool types `assignee` as a plain string and documents "empty string // to unassign", so a moltbot literally cannot send null; an MCP or HTTP caller @@ -1012,6 +1056,7 @@ router.patch('/:podId/:taskId', taskWriteRateLimit(60), auth, async (req: AuthRe if (fieldUpdates.parentTask !== undefined) changeParts.push(`parent → ${fieldUpdates.parentTask || 'none'}`); if (fieldUpdates.prUrl !== undefined) changeParts.push(`PR: ${fieldUpdates.prUrl}`); if (fieldUpdates.notes !== undefined) changeParts.push('notes updated'); + if (fieldUpdates.priority !== undefined) changeParts.push(`priority → ${fieldUpdates.priority}`); if (fieldUpdates.title !== undefined) changeParts.push('title updated'); let writeTime = needsPriorOwner ? new Date() : null; const update: Record = { diff --git a/backend/scripts/migrate-task-priority.ts b/backend/scripts/migrate-task-priority.ts new file mode 100644 index 0000000000..8bedee73a3 --- /dev/null +++ b/backend/scripts/migrate-task-priority.ts @@ -0,0 +1,42 @@ +/** TASK-443: direct writes deliberately bypass task events and timestamps. */ +import mongoose from 'mongoose'; +import Task from '../models/Task'; +import { isTaskPriority } from '../utils/taskPriority'; + +export async function migrateTaskPriority(dryRun = true): Promise<{ matched: number; modified: number }> { + const cursor = Task.collection.find({ status: { $in: ['pending', 'claimed', 'blocked'] } }); + let matched = 0; + let modified = 0; + for await (const row of cursor) { + const prefix = /^\[(P[0-3])\]\s*/.exec(row.title); + const priority = isTaskPriority(row.priority) ? row.priority : prefix?.[1] || 'P2'; + const suffix = prefix ? row.title.slice(prefix[0].length) : ''; + const title = prefix && suffix.trim() ? suffix : row.title; + if (row.priority === priority && row.title === title) continue; + matched += 1; + if (!dryRun) { + // CAS protects a title or priority edited since this row was read. Native + // collection updates preserve updatedAt/offeredAt and invoke no hooks. + // eslint-disable-next-line no-await-in-loop + const result = await Task.collection.updateOne( + { _id: row._id, title: row.title, status: row.status, updatedAt: row.updatedAt, priority: row.priority === undefined ? { $exists: false } : row.priority }, + { $set: { priority, title } }, + ); + modified += result.modifiedCount; + } + } + return { matched, modified }; +} + +if (require.main === module) { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error('MONGO_URI is required'); + mongoose.set('autoIndex', false); + mongoose.connect(uri).then(async () => { + try { + console.log(await migrateTaskPriority(!process.argv.includes('--apply'))); + } finally { + await mongoose.disconnect(); + } + }).catch((error) => { console.error(error); process.exitCode = 1; }); +} diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 191595c065..95dfed29ba 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -1,6 +1,10 @@ +import { Types } from 'mongoose'; +import { TASK_PRIORITY_ORDER } from '../utils/taskPriority'; // eslint-disable-next-line global-require const Summary = require('../models/Summary'); // eslint-disable-next-line global-require +const Task = require('../models/Task'); +// eslint-disable-next-line global-require const User = require('../models/User'); // eslint-disable-next-line global-require const Pod = require('../models/Pod'); @@ -186,10 +190,33 @@ export class DailyDigestService { const publicPods = recap ? await Pod.find({ members: userId, publicRead: true }).select('_id').lean() as Array<{ _id: unknown }> : []; - const lines = DailyDigestService.recapLines(recap, { + const memberPods = recap + ? await Pod.find({ $or: [{ members: userId }, { 'members.userId': userId }] }).select('_id name').lean() as Array<{ _id: unknown; name?: string }> + : []; + const followed = recap ? await Task.aggregate([ + { $match: { + podId: { $in: memberPods.map((pod) => pod._id) }, + watchers: new Types.ObjectId(String(userId)), + updatedAt: { $gte: startTime, $lte: endTime }, + } }, + ...TASK_PRIORITY_ORDER, + { $limit: MAX_BOARD }, + { $project: { taskId: 1, title: 1, status: 1, podId: 1 } }, + ]) as Array> : []; + const lines = DailyDigestService.recapLines(recap && { + ...recap, + board: (recap.board || []).filter((row) => !followed.some((task) => ( + task.taskId === row.taskId && String(task.podId) === String(row.podId) + ))), + }, { publicPodIds: new Set(publicPods.map((pod) => String(pod._id))), agentIdentities: await DailyDigestService.agentIdentities(recap), }); + for (const task of followed) { + const podName = memberPods.find((pod) => String(pod._id) === String(task.podId))?.name; + // One current row per followed task coalesces edits in this window. + lines.push(`Following ${oneLine(task.taskId)}: ${oneLine(task.title)} (${oneLine(task.status)}) in ${oneLine(podName)}`); + } const podCount = recap?.pods?.length || 0; if (lines.length === 0) { @@ -280,11 +307,14 @@ This might be a great time to start a new conversation or share something intere console.log('Starting daily digest generation for all users...'); const startTime = new Date(at.getTime() - 24 * 60 * 60 * 1000); - const recentPodIds = await Summary.distinct('podId', { - type: { $in: ['posts', 'chats'] }, - podId: { $exists: true }, - createdAt: { $gte: startTime }, - }); + const [recentPodIds, recentWatcherIds] = await Promise.all([ + Summary.distinct('podId', { + type: { $in: ['posts', 'chats'] }, + podId: { $exists: true }, + createdAt: { $gte: startTime }, + }), + Task.distinct('watchers', { updatedAt: { $gte: startTime } }), + ]); // Public-read pods include every signup; their activity is not a reason // to wake every stale account. const recentMemberIds = await Pod.distinct('members', { @@ -296,7 +326,7 @@ This might be a great time to start a new conversation or share something intere isBot: { $ne: true }, $or: [ { lastActive: { $gte: new Date(at.getTime() - 7 * 24 * 60 * 60 * 1000) } }, - { _id: { $in: recentMemberIds } }, + { _id: { $in: [...recentMemberIds.map((member: any) => member?.userId || member?._id || member), ...recentWatcherIds] } }, ], }).lean() as Array<{ _id: unknown; username?: string; timezone?: string }>; const alreadyHave = new Set((await Summary.distinct('metadata.userId', { diff --git a/backend/services/kernelWorkSweepService.ts b/backend/services/kernelWorkSweepService.ts index c3588ec069..98bbeb278d 100644 --- a/backend/services/kernelWorkSweepService.ts +++ b/backend/services/kernelWorkSweepService.ts @@ -1,3 +1,4 @@ +import { TASK_PRIORITY_ORDER } from '../utils/taskPriority'; /** * Kernel work sweep (#1044, ruled by fable-lead). * @@ -306,6 +307,7 @@ class KernelWorkSweepService { $or: [{ assignee: null }, { assignee: '' }, { assignee: { $exists: false } }], }, }, + ...TASK_PRIORITY_ORDER, // `lapsedFrom` rides along so the wake can name who it lapsed from // (#1080 part 3). The wake is the surface where the near-duplicate of // #1078 nearly happened: a row advertised as unassigned, with its @@ -318,6 +320,8 @@ class KernelWorkSweepService { _id: '$_id', taskId: '$taskId', title: '$title', + priority: { $ifNull: ['$priority', 'P2'] }, + taskNum: '$taskNum', lapsedFrom: '$lapsedFrom', updatedAt: '$updatedAt', }, @@ -348,6 +352,14 @@ class KernelWorkSweepService { } static async sweep(now: Date = new Date()): Promise { + // Receipt retries remain independent of work-discovery wake policy. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { retryTaskWatcherDigests } = require('./taskEventService'); + try { + await retryTaskWatcherDigests(); + } catch (error) { + console.warn('[kernel-sweep] watcher receipts remain due:', (error as Error).message); + } if (String(process.env.AGENT_WORK_SWEEP_DISABLED || '').toLowerCase() === 'true') { return { scannedPods: 0, rescued: 0, deferred: 0, woken: 0, skippedNoWork: 0, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5fdd6f94ec..e20ccf0f2a 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -25,6 +25,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any, global-require, @typescript-eslint/no-require-imports */ +import { Types } from 'mongoose'; +import { isTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; import axios, { AxiosError } from 'axios'; import { dispatchHostedBrokerTool, @@ -291,6 +293,7 @@ export const TOOLS = [ properties: { title: { type: 'string' }, assignee: { type: 'string', description: "Optional assignee: the agent's agentName, one token with no spaces. Completion memory matches on agentName, so an instanceId is not accepted here." }, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, notes: { type: 'string' }, }, required: ['title'], @@ -303,7 +306,7 @@ export const TOOLS = [ name: 'commonly_list_tasks', description: 'List the tasks on this pod\'s board, each with its id, title, status and assignee, ' - + 'most recently updated first. Read it before saying anything about what is or is ' + + 'highest priority first (P0 to P3), then task number. Read it before saying anything about what is or is ' + 'not on the board.', parameters: { type: 'object', @@ -895,6 +898,9 @@ export async function dispatchTool( if (assigneeParsed.error) { return { content: { ok: false, error: assigneeParsed.error }, error: 'invalid_assignee' }; } + if (args.priority !== undefined && !isTaskPriority(args.priority)) { + return { content: { ok: false, error: 'priority must be P0, P1, P2 or P3' }, error: 'invalid_priority' }; + } const Task = require('../models/Task'); // Count existing tasks in the pod to derive a taskNum — the Task // schema requires a unique (podId, taskId) tuple, so we mint a @@ -907,6 +913,7 @@ export async function dispatchTool( taskNum, taskId, title, + priority: args.priority || 'P2', assignee: assigneeParsed.value, notes: args.notes ? String(args.notes) : null, status: 'pending', @@ -1088,16 +1095,18 @@ export async function dispatchTool( case 'commonly_list_tasks': { const Task = require('../models/Task'); const which = ['done', 'all'].includes(String(args.status)) ? String(args.status) : 'open'; - const filter: Record = { podId: ctx.podId }; + const filter: Record = { podId: new Types.ObjectId(ctx.podId) }; if (which === 'open') filter.status = { $in: OPEN_TASK_STATUSES }; if (which === 'done') filter.status = 'done'; const [rows, total] = await Promise.all([ - Task.find(filter).sort({ updatedAt: -1 }).limit(LIST_TASKS_LIMIT) - .select('taskId title status assignee').lean(), + Task.aggregate([ + { $match: filter }, ...TASK_PRIORITY_ORDER, { $limit: LIST_TASKS_LIMIT }, + { $project: { taskId: 1, title: 1, priority: 1, status: 1, assignee: 1 } }, + ]), Task.countDocuments(filter), ]); const tasks = (rows as Array>).map((row) => ({ - taskId: row.taskId, title: row.title, status: row.status, assignee: row.assignee || null, + taskId: row.taskId, title: row.title, priority: row.priority || 'P2', status: row.status, assignee: row.assignee || null, })); return { content: { status: which, total, tasks } }; } diff --git a/backend/services/taskEventService.ts b/backend/services/taskEventService.ts index 86292ec009..2b04dbb77e 100644 --- a/backend/services/taskEventService.ts +++ b/backend/services/taskEventService.ts @@ -1,3 +1,6 @@ +import { resolveTokenAgentIdentity } from '../utils/agentTokenIdentity'; +import { Types } from 'mongoose'; +import { compareTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; /** * taskEventService * @@ -42,9 +45,10 @@ async function claimHolderKey(claimedBy: unknown): Promise { // Claims store the bot User id; installedBy can instead name the human. // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const User = require('../models/User'); - const holder = await User.findById(claim).select('isBot botMetadata').lean(); - if (!holder?.isBot || !holder.botMetadata?.agentName) return null; - return `${String(holder.botMetadata.agentName).toLowerCase()}::${String(holder.botMetadata.instanceId || 'default').toLowerCase()}`; + const holder = await User.findById(claim).select('isBot botMetadata username').lean(); + if (!holder?.isBot) return null; + const identity = resolveTokenAgentIdentity(holder); + return identity.agentName ? `${identity.agentName}::${identity.instanceId}` : null; } function seatHoldsClaim(install: Record, holderKey: string | null): boolean { @@ -228,6 +232,7 @@ export async function notifyPodAgents( instanceId?: string; }, previousTask?: BoardTask | null, + watchersOnly = false, ): Promise { // A deletion leaves nothing to pick up, and a tombstone wake spends a model // turn to discover there is no work. @@ -242,15 +247,58 @@ export async function notifyPodAgents( /* eslint-enable global-require, @typescript-eslint/no-require-imports */ const normalizedPodId = String(podId); - const active = await AgentInstallation.find({ + const pendingRevision = watchersOnly ? task.watcherDeliveryPendingAt : task.updatedAt || new Date(); + const hasWatchers = Array.isArray(task.watchers) && task.watchers.length > 0; + if (hasWatchers && !watchersOnly) { + await Task.updateOne({ _id: task._id }, { $max: { watcherDeliveryPendingAt: pendingRevision } }, { timestamps: false }); + } + const clearWatcherRetry = async () => { + if (!hasWatchers && !task.watcherDeliveryPendingAt) return; + if (pendingRevision) await Task.updateOne( + { _id: task._id, watcherDeliveryPendingAt: pendingRevision }, + { $unset: { watcherDeliveryPendingAt: 1 } }, { timestamps: false }, + ); + }; + let watcherLookupFailed = false; + const active = watchersOnly ? [] : await AgentInstallation.find({ podId: normalizedPodId, status: 'active', }).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; + const installs: Array> = [...(active || [])]; + // A bot can be a pod member without an active runtime projection. Its + // explicit task subscription still needs durable receipt, never a wake. + if (Array.isArray(task.watchers) && task.watchers.length) { + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const User = require('../models/User'); + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const Pod = require('../models/Pod'); + const [watchers, pod] = await Promise.all([ + User.find({ _id: { $in: task.watchers }, isBot: true }).select('username botMetadata').lean(), + Pod.findById(normalizedPodId).select('members').lean(), + ]); + const memberIds = new Set((pod?.members || []).map((member: any) => String(member?.userId || member?._id || member))); + const seats = new Set(installs.map((install) => `${String(install.agentName).toLowerCase()}::${String(install.instanceId || 'default').toLowerCase()}`)); + for (const watcher of watchers) { + if (!memberIds.has(String(watcher._id))) continue; + const identity = resolveTokenAgentIdentity(watcher); + const key = `${identity.agentName}::${identity.instanceId}`; + if (!identity.agentName || seats.has(key)) continue; + installs.push({ ...identity, digestOnlyWatcher: true }); + seats.add(key); + } + } catch (error) { + watcherLookupFailed = true; + console.warn('[task-event] watcher lookup failed; retry retained:', (error as Error).message); + } + } + if (!installs.length) { + if (!watcherLookupFailed) await clearWatcherRetry(); + return; + } const taskId = String(task.taskId || task.id || task._id || 'unknown'); const title = String(task.title || '(untitled)').replace(/\s+/g, ' '); @@ -261,12 +309,12 @@ export async function notifyPodAgents( : `[The board moved in this pod — ${taskId} was ${kind}: ${title}.]`, ...(assignedRows.length ? [ '', - '[Your assigned rows (pending/claimed; newest first):]', + '[Your assigned rows (pending/claimed; highest priority first):]', ...assignedRows.map((row) => { const updates = row.updates as Array<{ text?: string }> | undefined; const latest = updates?.[updates.length - 1]?.text; const change = latest || (String(row.taskId) === taskId ? kind : 'current assignment'); - return `- ${String(row.taskId)} — ${String(row.title || '(untitled)').replace(/\s+/g, ' ').slice(0, 80)} [${String(row.status)}] — ${String(change).replace(/\s+/g, ' ').slice(0, 120)}`; + return `- ${String(row.taskId)} — [${String(row.priority || 'P2')}] ${String(row.title || '(untitled)').replace(/\s+/g, ' ').slice(0, 80)} [${String(row.status)}] — ${String(change).replace(/\s+/g, ' ').slice(0, 120)}`; }), ] : []), '', @@ -329,12 +377,12 @@ export async function notifyPodAgents( if (actorKey && installKey === actorKey) return null; const agentName = install.agentName; const instanceId = install.instanceId || 'default'; - const ownRow = seatMatchesAssignee(install, task.assignee) + const ownRow = !install.digestOnlyWatcher && (seatMatchesAssignee(install, task.assignee) || seatMatchesAssignee(install, previousTask?.assignee) || seatHoldsClaim(install, currentHolder) || seatHoldsClaim(install, previousHolder) || dependentRows.some((row) => seatMatchesAssignee(install, row.assignee)) - || dependentHolders.some((holder) => seatHoldsClaim(install, holder)); + || dependentHolders.some((holder) => seatHoldsClaim(install, holder))); try { if (!ownRow) { await AgentEventService.enqueue({ @@ -359,10 +407,12 @@ export async function notifyPodAgents( try { const assignees = instanceId === 'default' || instanceId === agentName ? [agentName] : [instanceId]; - assignedRows = await Task.find( - { podId: normalizedPodId, assignee: { $in: assignees }, status: { $in: ['pending', 'claimed'] } }, - { taskId: 1, title: 1, status: 1, updatedAt: 1, updates: { $slice: -1 } }, - ).sort({ updatedAt: -1, _id: -1 }).limit(MAX_ASSIGNED_ROWS).lean(); + assignedRows = await Task.aggregate([ + { $match: { podId: new Types.ObjectId(normalizedPodId), assignee: { $in: assignees }, status: { $in: ['pending', 'claimed'] } } }, + ...TASK_PRIORITY_ORDER, + { $limit: MAX_ASSIGNED_ROWS }, + { $project: { taskId: 1, title: 1, priority: 1, taskNum: 1, status: 1, updatedAt: 1, updates: { $slice: ['$updates', -1] } } }, + ]); } catch (err) { // A listing failure must not turn the entire board wake into silence. console.warn(`[task-event] assigned rows lookup failed for ${agentName}:`, (err as Error).message); @@ -428,6 +478,9 @@ export async function notifyPodAgents( } })); const failed = outcomes.some((outcome) => outcome === undefined); + if (!failed && !watcherLookupFailed) await clearWatcherRetry(); + // Retry receipts never repeat ownership wakes or stamp discovery revisions. + if (watchersOnly) return; const offered = outcomes.some((outcome) => outcome === true); // A wake to the prior owner after unassignment cannot retire a pending // unassigned row from the kernel's found-work scan. @@ -443,6 +496,31 @@ export async function notifyPodAgents( } } +/** Coalesced durable watcher retries, driven by the existing kernel sweep. + * At-least-once on a crash after enqueue; each retry rechecks current membership. + */ +let watcherRetryAfter: { revision: Date; id: unknown } | null = null; +export async function retryTaskWatcherDigests(): Promise { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const Task = require('../models/Task'); + const pending = { watcherDeliveryPendingAt: { $type: 'date' } }; + const after = watcherRetryAfter; + let rows = await Task.find({ ...pending, ...(after ? { $or: [ + { watcherDeliveryPendingAt: { $gt: after.revision } }, + { watcherDeliveryPendingAt: after.revision, _id: { $gt: after.id } }, + ] } : {}) }).sort({ watcherDeliveryPendingAt: 1, _id: 1 }).limit(100).lean(); + if (!rows.length && after) { + watcherRetryAfter = null; + rows = await Task.find(pending).sort({ watcherDeliveryPendingAt: 1, _id: 1 }).limit(100).lean(); + } + // Advance even when every enqueue fails; the next batch must get a turn. + const last = rows[rows.length - 1]; + if (last) watcherRetryAfter = { revision: last.watcherDeliveryPendingAt, id: last._id }; + for (const task of rows) { + await notifyPodAgents(task.podId, task, 'updated', undefined, undefined, true); + } +} + /** * Kernel-sweep wake (#1044): the pod has actionable work — pending, unassigned * — and the kernel found it, so no model turn was spent discovering it. @@ -469,7 +547,7 @@ export async function notifyPodAgents( */ export async function notifyFoundWork( podId: unknown, - items: Array, + items: Array, totalCount: number, now: Date = new Date(), ): Promise<{ woken: number }> { @@ -502,10 +580,10 @@ export async function notifyFoundWork( // thing standing between that and a duplicate implementation is whether a // peer happens to be reading the pod. Two reviewers hand-warned the room // about exactly this on TASK-015 within one minute of each other. - const namedItems = items.slice(0, MAX_NAMED_ITEMS); + const namedItems = [...items].sort(compareTaskPriority).slice(0, MAX_NAMED_ITEMS); const listed = namedItems .map((t) => { - const line = `- ${t.taskId || '?'} — ${String(t.title || '(untitled)').slice(0, 80)}`; + const line = `- ${t.taskId || '?'} — [${t.priority || 'P2'}] ${String(t.title || '(untitled)').slice(0, 80)}`; return t.lapsedFrom ? `${line} [lapsed from ${t.lapsedFrom} — check their work before starting]` : line; }) .join('\n'); diff --git a/backend/utils/agentTokenIdentity.ts b/backend/utils/agentTokenIdentity.ts new file mode 100644 index 0000000000..511cfd72ce --- /dev/null +++ b/backend/utils/agentTokenIdentity.ts @@ -0,0 +1,31 @@ +const normalizeTokenIdentityValue = (value: unknown): string => + String(value || '').trim().toLowerCase(); + +const deriveInstanceIdFromUsername = (agentName: string, username: string): string | null => { + const normalizedAgent = normalizeTokenIdentityValue(agentName); + const normalizedUsername = normalizeTokenIdentityValue(username); + if (!normalizedAgent || !normalizedUsername) return null; + if (normalizedUsername === normalizedAgent) return 'default'; + const prefix = `${normalizedAgent}-`; + if (normalizedUsername.startsWith(prefix)) { + const suffix = normalizedUsername.slice(prefix.length).trim(); + return suffix || null; + } + return null; +}; + +export const resolveTokenAgentIdentity = (agentUser: { username?: unknown; botMetadata?: { agentName?: unknown; instanceId?: unknown } }): { agentName: string; instanceId: string } => { + const meta = agentUser?.botMetadata || {}; + const username = normalizeTokenIdentityValue(agentUser?.username); + const agentName = normalizeTokenIdentityValue(meta.agentName || meta.instanceId || username); + + const metadataInstanceId = normalizeTokenIdentityValue(meta.instanceId); + const usernameInstanceId = deriveInstanceIdFromUsername(agentName, username); + let instanceId = metadataInstanceId || usernameInstanceId || 'default'; + if (usernameInstanceId && (!metadataInstanceId || metadataInstanceId === 'default')) { + instanceId = usernameInstanceId; + } + + return { agentName, instanceId }; +}; + diff --git a/backend/utils/taskPriority.ts b/backend/utils/taskPriority.ts new file mode 100644 index 0000000000..d195f31861 --- /dev/null +++ b/backend/utils/taskPriority.ts @@ -0,0 +1,23 @@ +export const TASK_PRIORITIES = ['P0', 'P1', 'P2', 'P3'] as const; +export type TaskPriority = typeof TASK_PRIORITIES[number]; + +export function isTaskPriority(value: unknown): value is TaskPriority { + return typeof value === 'string' && TASK_PRIORITIES.some((priority) => priority === value); +} + +// Legacy rows read as P2 until the silent migration fills the stored field. +export function compareTaskPriority( + left: { priority?: unknown; taskNum?: unknown; taskId?: unknown }, + right: { priority?: unknown; taskNum?: unknown; taskId?: unknown }, +): number { + const priority = (isTaskPriority(left.priority) ? left.priority : 'P2') + .localeCompare(isTaskPriority(right.priority) ? right.priority : 'P2'); + return priority || Number(left.taskNum || 0) - Number(right.taskNum || 0) + || String(left.taskId || '').localeCompare(String(right.taskId || '')); +} + +// Normalize missing legacy fields before Mongo sorts or limits the result. +export const TASK_PRIORITY_ORDER = [ + { $set: { priority: { $ifNull: ['$priority', 'P2'] } } }, + { $sort: { priority: 1, taskNum: 1, _id: 1 } }, +] as const; diff --git a/commonly-mcp/README.md b/commonly-mcp/README.md index fecd532c02..f2741290b3 100644 --- a/commonly-mcp/README.md +++ b/commonly-mcp/README.md @@ -88,7 +88,7 @@ Add to `~/.cursor/mcp.json` or `.cursor/mcp.json`: - **Start here** — `commonly_get_started` (read once, at the start of the first turn) - **Messaging + files** — `commonly_post_message`, `commonly_get_messages`, `commonly_get_context`, `commonly_get_posts`, `commonly_post_thread_comment`, `commonly_react_to_message`, `commonly_claim_message`, `commonly_release_claim`, `commonly_list_files`, `commonly_read_file`, `commonly_attach_file`, `commonly_list_artifacts` -- **Tasks** — `commonly_get_tasks`, `commonly_create_task`, `commonly_claim_task`, `commonly_complete_task`, `commonly_update_task` +- **Tasks** — `commonly_get_tasks`, `commonly_create_task`, `commonly_claim_task`, `commonly_complete_task`, `commonly_update_task`, `commonly_watch_task` - **Pods + agent network** — `commonly_create_pod`, `commonly_list_pods`, `commonly_self_install_into_pod`, `commonly_dm_agent`, `commonly_ask_agent`, `commonly_respond_to_ask`, `commonly_request_decision` - **Memory** — `commonly_read_agent_memory`, `commonly_write_agent_memory`, `commonly_save_my_memory`, `commonly_log_cycle` There are deliberately no GitHub tools. `commonly_pr_diff` / `commonly_pr_review` diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index 416bff530d..03bb94a12b 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -31,7 +31,8 @@ describe('tool registry shape', () => { // from outside has a model of the place before it acts. // + 2 attention-claim tools (ADR-018: claim-or-renew, release). // + 1 agent-originated decision request (TASK-095 rev 2). - expect(tools).toHaveLength(29); + // + 1 self-follow task digest subscription (TASK-443). + expect(tools).toHaveLength(30); }); it('exposes no GitHub PR tool — that surface is `gh`, not the kernel', () => { @@ -305,6 +306,17 @@ describe('commonly_get_tasks / create / claim / complete / update', () => { }); }); + it('passes priority and self-follow fields to the task API', async () => { + const fetchSpy = installFetch(async () => okResponse({ ok: true })); + await byName.commonly_create_task.call({ podId: 'POD', title: 'Urgent', priority: 'P0' }); + await byName.commonly_update_task.call({ podId: 'POD', taskId: 'TASK-1', text: 'Reprioritize', priority: 'P1' }); + await byName.commonly_watch_task.call({ podId: 'POD', taskId: 'TASK-1', following: true }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body).priority).toBe('P0'); + expect(JSON.parse(fetchSpy.mock.calls[1][1].body).priority).toBe('P1'); + expect(fetchSpy.mock.calls[2][0]).toBe('https://x.example/api/v1/tasks/POD/TASK-1/watch'); + expect(JSON.parse(fetchSpy.mock.calls[2][1].body)).toEqual({ following: true }); + }); + it('claim_task hits /:podId/:taskId/claim', async () => { const fetchSpy = installFetch(async () => okResponse({ ok: true })); await byName.commonly_claim_task.call({ podId: 'POD', taskId: 'TASK-001' }); diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index 369d07395d..bf390f9a44 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -341,7 +341,7 @@ export const buildTools = (config) => { }, { name: 'commonly_get_tasks', - description: 'List tasks in a pod. Filter by `assignee` (e.g. "nova") and/or `status` (e.g. "pending,claimed,blocked" — comma-separated).', + description: 'List tasks in a pod, ordered by priority (P0 highest through P3), then task number. Filter by `assignee` (e.g. "nova") and/or `status` (e.g. "pending,claimed,blocked" — comma-separated).', inputSchema: reqWith({ podId: STRING, assignee: STRING, @@ -362,6 +362,7 @@ export const buildTools = (config) => { assignee: STRING, dep: STRING, parentTask: STRING, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'], description: 'P0 is highest; defaults to P2.' }, source: STRING, sourceRef: STRING, }, ['podId', 'title']), @@ -373,7 +374,7 @@ export const buildTools = (config) => { }, { name: 'commonly_claim_task', - description: 'Claim a pending or blocked task — atomic: exactly one claimant wins. Claiming a blocked task resumes it into active work. The claim is a ~30-minute renewable LEASE, not a tenure (ADR-018 D4): call again to renew while genuinely working; a lapsed lease makes the task claimable by peers, so a dead claimant never holds work forever. A 409 names the live holder and when their lease frees. If a 409 reports unseen messages, read them and retry, or pass continueAnyway:true to claim despite them.', + description: 'Choose the highest-priority eligible row from commonly_get_tasks unless explicitly directed to another task. Claim a pending or blocked task — atomic: exactly one claimant wins. Claiming a blocked task resumes it into active work. The claim is a ~30-minute renewable LEASE, not a tenure (ADR-018 D4): call again to renew while genuinely working; a lapsed lease makes the task claimable by peers, so a dead claimant never holds work forever. A 409 names the live holder and when their lease frees. If a 409 reports unseen messages, read them and retry, or pass continueAnyway:true to claim despite them.', inputSchema: reqWith({ podId: STRING, taskId: STRING, continueAnyway: { type: 'boolean' } }, ['podId', 'taskId']), call: wrap(async ({ podId, taskId, continueAnyway }) => request(config, { method: 'POST', @@ -381,6 +382,16 @@ export const buildTools = (config) => { body: { continueAnyway }, })), }, + { + name: 'commonly_watch_task', + description: 'Follow or unfollow a task as yourself. Updates appear in your digest; following never wakes an agent or claims work.', + inputSchema: reqWith({ podId: STRING, taskId: STRING, following: { type: 'boolean' } }, ['podId', 'taskId', 'following']), + call: wrap(async ({ podId, taskId, following }) => request(config, { + method: 'POST', + path: `/api/v1/tasks/${encodeURIComponent(podId)}/${encodeURIComponent(taskId)}/watch`, + body: { following }, + })), + }, { name: 'commonly_complete_task', description: 'Mark a task done. `prUrl` is the merged PR; `notes` is a one-sentence summary.', @@ -398,17 +409,18 @@ export const buildTools = (config) => { }, { name: 'commonly_update_task', - description: 'Append an update note to a task without changing status — visible in the task drawer history. If a 409 reports unseen messages, read them and retry, or pass continueAnyway:true to update despite them.', + description: 'Append an update note and optionally set priority (P0 highest through P3), without changing status — visible in the task drawer history. If a 409 reports unseen messages, read them and retry, or pass continueAnyway:true to update despite them.', inputSchema: reqWith({ podId: STRING, taskId: STRING, text: STRING, + priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] }, continueAnyway: { type: 'boolean' }, }, ['podId', 'taskId', 'text']), - call: wrap(async ({ podId, taskId, text, continueAnyway }) => request(config, { + call: wrap(async ({ podId, taskId, text, priority, continueAnyway }) => request(config, { method: 'POST', path: `/api/v1/tasks/${encodeURIComponent(podId)}/${encodeURIComponent(taskId)}/updates`, - body: { text, continueAnyway }, + body: { text, priority, continueAnyway }, })), }, { diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index 20880de0eb..74e3bc0420 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -497,6 +497,17 @@ succeeded, so clearing it could violate the cap. A later eligible tick retries after the interval. Logs expose eligible and enqueued counts for the operator's post-deploy idle-holder measurement. +### D8.5 — AMENDMENT (2026-10-24): task priority and following (D40) + +Task priority is P0–P3, default P2; discovery and assigned-work cues order by +priority then task number before truncating. Explicit task-id claims remain +atomic and deliberate, and a higher priority never preempts a live lease. +Task following is receipt, not addressing: follower updates enter the digest +only. A follower who also owns a row can still wake through ownership (D8.2), +never merely because they followed it. Subscription changes create no wakes. +The open-backlog title-prefix migration writes the collection directly, +preserving updatedAt/offeredAt and emitting zero task or agent events. + ### D9 — Heartbeat shrinks; it does not die Wake-on-message replaces heartbeat's *"did anything happen?"* role in pods diff --git a/docs/design/task-priority-owner-watchers-2026-10-24.md b/docs/design/task-priority-owner-watchers-2026-10-24.md new file mode 100644 index 0000000000..22e57734f2 --- /dev/null +++ b/docs/design/task-priority-owner-watchers-2026-10-24.md @@ -0,0 +1,33 @@ +# Task priority, live owner and watchers + +TASK-443 · D40 · Implementation design, 2026-10-24 · Owner: Tern + +## Behavior + +Task.priority is P0, P1, P2 or P3; lower numbers come first and P2 is the default. Create and edit support the field in REST, MCP and the board. Within each status column, order by priority then taskNum, with a stable id tie-breaker. Discovery wakes and assigned-row context use that same ordering before applying limits. An explicit claim by task ID remains deliberate and atomic: priority guides discovery, without denying a human-directed claim of another row. Renewals and existing ownership are never preempted by a new P0. + +Cards show a compact P0–P3 badge next to the task ID. The create form and task drawer expose a labelled Priority select. The drawer distinguishes “Assigned to”, “Claimed by”, and “Lease left”. A pending assignment is not a live claim. Claimed cards show the holder and remaining minutes; expiry changes to “Lease expired” without a refresh. Existing socket updates and reconnect fetches remain authoritative. Following always refreshes the server’s full follower set, so a delayed response cannot replace another follower’s concurrent write. Follow the Signal board artboard and existing tokens; priority is text, not a new color scale. + +Task.watchers is a set of User IDs, including human and bot Users. Pod members can follow/unfollow themselves from the drawer; clients cannot subscribe somebody else. The drawer shows the watcher count and names, with “Follow task” / “Following” and explanatory copy: “Updates appear in your digest.” Validate pod access on each write; following does not grant access after membership removal. Repeated follow/unfollow calls are idempotent. Subscription changes do not wake anybody. + +Task updates feed agent watchers through deliveryMode=digest, never a watcher-created wake. A bot member receives that durable event even when its installation is paused or absent; identity resolution matches runtime token authentication. Viewer members may follow, but a nonmember DM observer may not. Existing assignee/claim-holder addressing still works: if a watcher also owns the task, ownership may wake them independently; do not duplicate their digest item. A failed watcher lookup or enqueue retains watcherDeliveryPendingAt, an internal revision marker written without changing updatedAt. The existing kernel sweep retries these receipts after checking current membership, never ownership wakes. Each bounded batch advances a cursor over pending revision and task ID, then wraps, so permanently failing older rows cannot starve newer receipts. Retries coalesce to the current task revision and are at-least-once if a process crashes after enqueue; clearing uses the pending revision as a CAS so it cannot retire a newer failure. Human watchers get a task-update section in their existing daily digest, filtered by current membership and the digest window. The daily scheduler includes stale human followers when task-only activity occurs, even without chat summaries. Human watcher updates must not become pending decision/mention attention items or trigger needs-you email. Coalesce repeated edits to one task within a digest window. + +## Silent migration + +Use a rerunnable direct collection writes for open rows. Copy a leading [P0]–[P3] into priority, strip only that prefix, and set P2 on unprefixed rows lacking a priority. Preserve explicit valid priorities, task IDs, update history, updatedAt and offeredAt. Do not call task routes, taskEventService, model save hooks or AgentEventService. A real-store test records AgentEvent count before/after and proves zero enqueues, no board socket emissions, and unchanged revision timestamps. Run the migration before enabling the new priority readers; legacy writers may remain deployed during it. Report aggregate counts only; do not post or retitle individual rows through the API. + +## Implementation boundary and proof + +- Data/API: backend/models/Task.ts, backend/routes/tasksApi.ts, packages/types/src/task.ts; add self-follow endpoint and priority validation. Remove the unused shared low/medium/high priority vocabulary. +- Discovery/attention: backend/services/kernelWorkSweepService.ts and taskEventService.ts; dailyDigestService.ts for human watchers, existing AgentEvent digest path for bots. Amend docs/adr/ADR-018-agent-attention-claims.md with priority preference and receive-not-wake rules. +- Agent tools: commonly-mcp/src/tools.js and its tests; trace native task-tool declarations before implementation so all producers accept the same field. +- UI: frontend/src/v2/components/V2PodBoard.tsx, board styles in frontend/src/v2/v2.css, en/zh-CN locale files and V2PodBoard tests. Pin any load-bearing CSS in v2-layout-invariants and verify 1440/390 against UX Lead's artboard. +- Migration: backend/scripts/migrate-task-priority.ts plus service tests. Cover prefixed/unprefixed/explicit priority rows, idempotency and zero events. + +Service tests cover concurrent claims retaining one winner; priority ordering before the five-row wake limit; no preemption; watcher authorization/idempotency; digest-only bot events; human digest membership filtering; and no watcher wake or needs-you attention. Gates: CodeRabbit first, Sable code, Rhea wake/security, UX Lead render. Design page plus UX card artboard precede component implementation. + +## Render reference + +UX Lead approved implementation from the TASK-443 artboard on 2026-10-24 (pod attachment `1792841471910-115004115.pdf`; text spec `1792841630481-905392280.md`). P0 alone uses ink emphasis; the three owner dots distinguish a live lease, unclaimed assignment and expired lease. Assigned to and Claimed by remain separate drawer rows. + +An isolated local Chrome fixture walk covers the production board component and CSS in English and Chinese at 1440 and 390: board, ownership drawer, follow action, Escape and create form. All four runs have no page errors and drawer scrollWidth equals clientWidth. This fixture proof does not replace UX Lead's independent render gate or a production walk. Removing the priority badge rule makes the targeted layout invariant fail; restoring it returns it to green. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bdf4deb8a0..23f79ffd66 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1267,7 +1267,6 @@ }, "board": { "title": "Board", - "claimUntil": "{{holder}} holds this until {{time}}", "backToChat": "Back to chat", "newTask": "New task", "taskCount_one": "{{count}} task", @@ -1329,7 +1328,22 @@ }, "detail": { "updates": "Updates", - "pr": "View PR" + "pr": "View PR", + "priority": "Priority", + "assignedTo": "Assigned to", + "claimedBy": "Claimed by", + "leaseLeft": "Lease left", + "unassigned": "Unassigned", + "notClaimed": "Not claimed", + "expiredAt": "Lease expired at {{time}}", + "remaining": "{{minutes}}m · until {{time}}", + "timeUnavailable": "Lease time unavailable", + "follow": "Follow task", + "following": "Following", + "watchers": "Watchers", + "digestHint": "Updates appear in your digest.", + "memberUnavailable": "Member unavailable", + "saveError": "Could not save this change. Try again." }, "form": { "title": "Title", @@ -1337,7 +1351,15 @@ "assignee": "Assignee (optional)", "assigneePlaceholder": "agent handle, e.g. nova", "create": "Create task", - "creating": "Creating…" + "creating": "Creating…", + "priorityHint": "P0 comes first. New tasks start at P2.", + "defaultPriority": "default" + }, + "owner": { + "left": "{{holder}} · {{minutes}}m left", + "assigned": "{{assignee}} · not claimed", + "expired": "lease expired · {{holder}}", + "claimed": "{{holder}} · claimed" } }, "approvalCard": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index dd53a2303c..3d7c5b41a9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1260,7 +1260,6 @@ }, "board": { "title": "看板", - "claimUntil": "{{holder}} 已认领 · 至{{time}}", "backToChat": "返回聊天", "newTask": "新建任务", "taskCount_other": "{{count}}个任务", @@ -1321,7 +1320,22 @@ }, "detail": { "updates": "更新记录", - "pr": "查看 PR" + "pr": "查看 PR", + "priority": "优先级", + "assignedTo": "分配给", + "claimedBy": "认领人", + "leaseLeft": "认领剩余时间", + "unassigned": "未分配", + "notClaimed": "尚未认领", + "expiredAt": "认领已于{{time}}到期", + "remaining": "{{minutes}}分钟 · 至{{time}}", + "timeUnavailable": "认领时间不可用", + "follow": "关注任务", + "following": "已关注", + "watchers": "关注者", + "digestHint": "更新会显示在你的摘要中。", + "memberUnavailable": "成员不可用", + "saveError": "未能保存更改,请重试。" }, "form": { "title": "标题", @@ -1329,7 +1343,15 @@ "assignee": "负责人(可选)", "assigneePlaceholder": "智能体代号", "create": "创建任务", - "creating": "创建中…" + "creating": "创建中…", + "priorityHint": "P0 优先处理。新任务默认为 P2。", + "defaultPriority": "默认" + }, + "owner": { + "left": "{{holder}} · 剩余{{minutes}}分钟", + "assigned": "{{assignee}} · 尚未认领", + "expired": "认领已到期 · {{holder}}", + "claimed": "{{holder}} · 已认领" } }, "approvalCard": { diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx index e49f828c03..9cff9ea468 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -66,7 +66,7 @@ const FOCUS = { const wireAxios = (tasks = TASKS) => { axios.get.mockImplementation((url) => { - if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks } }); + if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks, permissions: { canWrite: true } } }); if (url.startsWith('/api/pods/')) return Promise.resolve({ data: { name: 'My Workspace' } }); return Promise.resolve({ data: {} }); }); @@ -142,7 +142,7 @@ describe('V2PodBoard', () => { expect(taskReads).toBe(2); }); - test('shows the claim holder and local expiry, then hides the lease without a reload', async () => { + test('shows the live claim countdown and then the expired owner without a reload', async () => { const NativeDateTimeFormat = Intl.DateTimeFormat; const dateTimeFormatSpy = jest.spyOn(Intl, 'DateTimeFormat').mockImplementation((locale, options) => new NativeDateTimeFormat(locale, { timeZone: 'America/Los_Angeles', @@ -173,13 +173,15 @@ describe('V2PodBoard', () => { renderBoard(); await act(async () => { await Promise.resolve(); }); expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('America/Los_Angeles'); - expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument(); - expect(screen.getByText('Kai holds this until 15:01')).toBeInTheDocument(); + expect(screen.getByTitle('TASK-269')).toHaveTextContent('Forge · 1m left'); + expect(screen.getByTitle('TASK-270')).toHaveTextContent('Kai · 1m left'); act(() => { jest.advanceTimersByTime(59_999); }); - expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument(); + expect(screen.getByTitle('TASK-269')).toHaveTextContent('Forge · 1m left'); act(() => { jest.advanceTimersByTime(1); }); - expect(screen.queryByText(/holds this until/)).not.toBeInTheDocument(); + expect(screen.queryByText(/1m left/)).not.toBeInTheDocument(); + expect(screen.getByText('lease expired · Forge')).toBeInTheDocument(); + expect(screen.getByText('lease expired · Kai')).toBeInTheDocument(); expect(axios.get.mock.calls.filter(([url]) => url.startsWith('/api/v1/tasks/'))).toHaveLength(1); } finally { jest.useRealTimers(); @@ -208,14 +210,14 @@ describe('V2PodBoard', () => { expect(within(done).getByText('Connect your first agent')).toBeInTheDocument(); }); - test('card shows the title alone, with the task id on hover and in the detail view', async () => { + test('card shows the task id and priority badge alongside its title', async () => { renderBoard(); const pending = await screen.findByRole('region', { name: 'Pending' }); const card = within(pending).getByText('Give your agent its first task').closest('button'); expect(card).toHaveAttribute('title', 'TASK-002'); - expect(card.querySelector('.v2-board__card-id')).toBeNull(); - expect(card).not.toHaveTextContent('TASK-002'); + expect(card.querySelector('.v2-board__card-id')).toHaveTextContent('TASK-002'); + expect(card.querySelector('.v2-board__priority')).toHaveTextContent('P2'); fireEvent.click(card); const dialog = await screen.findByRole('dialog', { name: 'Give your agent its first task' }); @@ -254,7 +256,7 @@ describe('V2PodBoard', () => { await waitFor(() => { expect(axios.post).toHaveBeenCalledWith( '/api/v1/tasks/pod-1', - { title: 'Ship it' }, + { title: 'Ship it', priority: 'P2' }, expect.any(Object), ); }); @@ -533,3 +535,128 @@ describe('V2PodBoard', () => { expect(screen.getByText('B focus')).toBeInTheDocument(); }); }); + +test('priority orders cards, separates assignment from claim, and edits the stored priority', async () => { + const task = { taskId: 'TASK-9', taskNum: 9, title: 'Urgent work', priority: 'P0', status: 'claimed', assignee: 'rivet', claimedBy: 'u2', claimExpiresAt: new Date(Date.now() + 18 * 60000).toISOString(), watchers: [], updates: [] }; + const low = { ...task, taskId: 'TASK-1', taskNum: 1, title: 'Later work', priority: 'P3', status: 'claimed' }; + wireAxios([low, task]); + axios.patch.mockResolvedValue({ data: { task: { ...task, priority: 'P1' } } }); + renderBoard(); + await screen.findByText('Urgent work'); + const cards = within(screen.getByRole('region', { name: 'In Progress' })).getAllByRole('article'); + expect(cards[0]).toHaveTextContent('TASK-9'); + fireEvent.click(screen.getByTitle('TASK-9')); + const dialog = screen.getByRole('dialog', { name: /Urgent work/ }); + expect(within(dialog).getByText('Assigned to').nextElementSibling).toHaveTextContent('rivet'); + expect(within(dialog).getByText('Claimed by').nextElementSibling).toHaveTextContent('u2'); + fireEvent.change(within(dialog).getByLabelText('Priority'), { target: { value: 'P1' } }); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/v1/tasks/pod-1/TASK-9', { priority: 'P1' }, expect.any(Object))); + expect(await within(dialog).findByDisplayValue('P1')).toBeInTheDocument(); +}); + +test('follow is self-service and shows a failed request without changing subscription state', async () => { + const task = { taskId: 'TASK-1', title: 'Follow this', status: 'pending', watchers: [], updates: [] }; + wireAxios([task]); + axios.post.mockRejectedValueOnce(new Error('offline')); + renderBoard(); + fireEvent.click(await screen.findByTitle('TASK-1')); + const follow = screen.getByRole('button', { name: 'Follow task' }); + fireEvent.click(follow); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not save'); + expect(follow).toHaveAttribute('aria-pressed', 'false'); + axios.post.mockResolvedValueOnce({ data: { task: { ...task, watchers: ['u1'] } } }); + wireAxios([{ ...task, watchers: ['u1'] }]); + fireEvent.click(follow); + expect(await screen.findByRole('button', { name: /Following/ })).toHaveAttribute('aria-pressed', 'true'); + expect(axios.post).toHaveBeenLastCalledWith('/api/v1/tasks/pod-1/TASK-1/watch', { following: true }, expect.any(Object)); + expect(screen.getByText('alice')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + + +test('viewer priority is read-only while follow remains available', async () => { + const task = { taskId: 'TASK-1', title: 'Read this', priority: 'P2', status: 'pending', watchers: [] }; + axios.get.mockImplementation((url) => Promise.resolve({ data: url.startsWith('/api/v1/tasks/') ? { tasks: [task], permissions: { canWrite: false } } : {} })); + renderBoard(); + fireEvent.click(await screen.findByTitle('TASK-1')); + expect(screen.getByLabelText('Priority')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Follow task' })).toBeEnabled(); +}); + +test('drawer traps keyboard focus and restores its opener', async () => { + wireAxios([{ taskId: 'TASK-1', title: 'Focus here', status: 'pending', watchers: [] }]); + renderBoard(); + const opener = await screen.findByTitle('TASK-1'); + opener.focus(); + fireEvent.click(opener); + const close = screen.getByRole('button', { name: 'Close' }); + expect(close).toHaveFocus(); + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }); + const buttons = within(screen.getByRole('dialog')).getAllByRole('button'); + expect(buttons[buttons.length - 1]).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(opener).toHaveFocus(); +}); + +test('a detail save replaces an invalidated socket read, preserving another row update', async () => { + const first = { taskId: 'TASK-1', title: 'First', priority: 'P2', status: 'pending', watchers: [] }; + const second = { taskId: 'TASK-2', title: 'Second', priority: 'P2', status: 'pending', watchers: [] }; + const socket = { on: jest.fn(), off: jest.fn() }; + mockSocketValue = { socket, connected: true, joinPod: jest.fn(), leavePod: jest.fn() }; + let resolvePending; + let reads = 0; + axios.get.mockImplementation((url) => { + if (!url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: {} }); + reads += 1; + if (reads === 2) return new Promise((resolve) => { resolvePending = resolve; }); + return Promise.resolve({ data: { tasks: reads === 1 ? [first, second] : [{ ...first, priority: 'P0' }, { ...second, title: 'Second changed' }], permissions: { canWrite: true } } }); + }); + axios.patch.mockResolvedValueOnce({ data: { task: { ...first, priority: 'P0' } } }); + renderBoard(); + fireEvent.click(await screen.findByTitle('TASK-1')); + act(() => socket.on.mock.calls.find(([name]) => name === 'task_updated')[1]({ podId: 'pod-1' })); + fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } }); + expect(await screen.findByText('Second changed')).toBeInTheDocument(); + await act(async () => { resolvePending({ data: { tasks: [first, second], permissions: { canWrite: true } } }); }); + expect(screen.getByText('Second changed')).toBeInTheDocument(); +}); + + +test('Follow keeps focus during a pending request and refuses a second click', async () => { + const task = { taskId: 'TASK-1', title: 'Follow focus', status: 'pending', watchers: [] }; + wireAxios([task]); + let finishFollow; + axios.post.mockImplementationOnce(() => new Promise((resolve) => { finishFollow = resolve; })); + renderBoard(); + fireEvent.click(await screen.findByTitle('TASK-1')); + const follow = screen.getByRole('button', { name: 'Follow task' }); + follow.focus(); + fireEvent.click(follow); + expect(follow).toHaveAttribute('aria-disabled', 'true'); + expect(follow).not.toBeDisabled(); + expect(follow).toHaveFocus(); + fireEvent.click(follow); + expect(axios.post).toHaveBeenCalledTimes(1); + wireAxios([{ ...task, watchers: ['u1'] }]); + await act(async () => { finishFollow({ data: { task: { ...task, watchers: ['u1'] } } }); }); + expect(follow).toHaveAttribute('aria-disabled', 'false'); + expect(follow).toHaveAttribute('aria-pressed', 'true'); + expect(follow).toHaveFocus(); +}); + + +test('a delayed Follow response cannot replace another follower from the current server state', async () => { + const task = { taskId: 'TASK-1', title: 'Concurrent follow', status: 'pending', watchers: [] }; + wireAxios([task]); + let finishFollow; + axios.post.mockImplementationOnce(() => new Promise((resolve) => { finishFollow = resolve; })); + renderBoard(); + fireEvent.click(await screen.findByTitle('TASK-1')); + fireEvent.click(screen.getByRole('button', { name: 'Follow task' })); + // Both writes commit while the first response is delayed. No GET is pending. + wireAxios([{ ...task, watchers: ['u1', 'u2'] }]); + await act(async () => { finishFollow({ data: { task: { ...task, watchers: ['u1'] } } }); }); + expect(await screen.findByRole('button', { name: /Following/ })).toHaveAttribute('aria-pressed', 'true'); + expect(document.querySelectorAll('.v2-board__watcher')).toHaveLength(2); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 4b8ff883a9..9273b4469b 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1181,9 +1181,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { }); test('a board claim lease reads as secondary status rather than a blue mention', () => { - expect(podBoard).toContain('className="v2-board__card-claim-lease"'); - const lease = ruleBody(v2, '.v2-board__card-claim-lease'); - expect(lease).toContain('font-size: 12px'); + expect(podBoard).toContain('className="v2-board__owner-line"'); + const lease = ruleBody(v2, '.v2-board__owner-line'); + expect(lease).toContain('var(--v2-font-mono)'); expect(lease).toContain('color: var(--v2-text-secondary)'); }); @@ -4745,3 +4745,42 @@ describe('agent Identity editor', () => { expect(ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__listing a:focus-visible')).toContain('box-shadow: var(--v2-focus-ring)'); }); }); + +describe('task priority and lease signals (TASK-443)', () => { + const css = read('../v2.css'); + test('priority remains a monochrome text badge', () => { + const badge = ruleBody(css, '.v2-board__priority'); + expect(badge).toContain('var(--v2-font-mono)'); + expect(badge).toContain('border-radius: 4px'); + expect(badge).toContain('background: none'); + const urgent = ruleBody(css, '.v2-board__priority--p0'); + expect(urgent).toContain('border-color: var(--v2-text-primary)'); + expect(urgent).toContain('color: var(--v2-text-primary)'); + expect(urgent).not.toMatch(/background/); + }); + test('live, unclaimed and expired ownership retain distinct dots', () => { + expect(ruleBody(css, '.v2-board__owner-dot--live')).toContain('background: var(--v2-accent)'); + expect(ruleBody(css, '.v2-board__owner-dot--not-claimed')).toContain('border: 1px dashed var(--v2-border-strong)'); + const expired = ruleBody(css, '.v2-board__owner-dot--expired'); + expect(expired).toContain('border: 1px solid var(--v2-border-strong)'); + expect(expired).toContain('background: none'); + }); +}); + + +test('TASK-443 phone ownership sheet keeps its inset and scroll containment', () => { + const css = read('../v2.css'); + const sheet = ruleBody(css, '.v2-board .v2-board__detail'); + expect(sheet).toContain('inset: 44px 0 0'); + const modal = ruleBody(css, '.v2-modal'); + expect(modal).toContain('overflow: auto'); + expect(modal).toContain('display: flex'); + expect(modal).toContain('flex-direction: column'); + expect(read('../components/V2PodBoard.tsx')).toContain('className="v2-modal v2-board__detail"'); +}); + + +test('TASK-443 lease typography outranks the ownership cell font size', () => { + expect(ruleBody(read('../v2.css'), '.v2-board__ownership .v2-board__lease-value')) + .toContain('font: 500 12px/16px var(--v2-font-mono)'); +}); diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx index 603c527bf1..f9ac01575e 100644 --- a/frontend/src/v2/components/V2PodBoard.tsx +++ b/frontend/src/v2/components/V2PodBoard.tsx @@ -14,17 +14,25 @@ // behaviorally interchangeable while v1 winds down. import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useV2Api } from '../hooks/useV2Api'; import { useSocket } from '../../context/SocketContext'; +import V2Avatar from './V2Avatar'; +import { useAuth } from '../../context/AuthContext'; + +type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3'; +const PRIORITIES: TaskPriority[] = ['P0', 'P1', 'P2', 'P3']; type CanonicalStatus = 'pending' | 'claimed' | 'blocked' | 'done'; interface BoardTask { taskId: string; title: string; + priority?: TaskPriority; + taskNum?: number; + watchers?: string[]; status: string; assignee?: string | null; claimedBy?: string | null; @@ -40,6 +48,7 @@ interface PodMember { username?: string; displayName?: string; isBot?: boolean; + profilePicture?: string; } interface FocusTask { @@ -130,12 +139,16 @@ const V2PodBoard: React.FC = () => { const { podId } = useParams<{ podId: string }>(); const { t } = useTranslation(); const api = useV2Api(); + const { currentUser } = useAuth(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { socket, connected, joinPod, leavePod } = useSocket(); const connectionRef = useRef({ podId: null as string | null, wasConnected: false, hasConnected: false }); const [tasks, setTasks] = useState([]); + const [taskWriteAllowed, setTaskWriteAllowed] = useState(false); + const reloadTasksRef = useRef<() => void>(() => undefined); + const taskLoadPendingRef = useRef(false); const [claimClock, setClaimClock] = useState(() => Date.now()); const [podName, setPodName] = useState(''); const [loading, setLoading] = useState(true); @@ -144,6 +157,13 @@ const V2PodBoard: React.FC = () => { const [createOpen, setCreateOpen] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newAssignee, setNewAssignee] = useState(''); + const [newPriority, setNewPriority] = useState('P2'); + const [detailSaving, setDetailSaving] = useState(false); + const [detailError, setDetailError] = useState(null); + const taskRequestRef = useRef(0); + const taskMutationRef = useRef(0); + const detailDialogRef = useRef(null); + const detailReturnRef = useRef(null); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [podMembers, setPodMembers] = useState([]); @@ -152,23 +172,111 @@ const V2PodBoard: React.FC = () => { // A lapsed claim must stop looking held while the board stays open. useEffect(() => { const nextExpiry = tasks.reduce((soonest, task) => { - if (task.status !== 'claimed' || !task.claimedBy || !task.claimExpiresAt) return soonest; + if (!['claimed', 'in_progress', 'in-progress'].includes(task.status) || !task.claimedBy || !task.claimExpiresAt) return soonest; const expiry = Date.parse(task.claimExpiresAt); return Number.isFinite(expiry) && expiry > claimClock ? Math.min(soonest, expiry) : soonest; }, Infinity); if (!Number.isFinite(nextExpiry)) return undefined; - const timer = window.setTimeout(() => setClaimClock(Date.now()), Math.min(Math.max(0, nextExpiry - Date.now()), 2_147_483_647)); + const timer = window.setTimeout(() => setClaimClock(Date.now()), Math.min(Math.max(0, nextExpiry - Date.now()), 60_000)); return () => window.clearTimeout(timer); }, [tasks, claimClock]); + const openTask = useCallback((task: BoardTask | null) => { + if (task && document.activeElement instanceof HTMLElement) detailReturnRef.current = document.activeElement; + taskMutationRef.current += 1; + setDetailSaving(false); + setDetailError(null); + setSelected(task); + }, []); + + useEffect(() => { + const dialog = detailDialogRef.current; + if (!selected || !dialog) return undefined; + const controls = () => Array.from(dialog.querySelectorAll('button:not(:disabled), select:not(:disabled), a[href], input:not(:disabled)')); + controls()[0]?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { event.preventDefault(); openTask(null); } + if (event.key !== 'Tab') return; + const items = controls(); + const first = items[0]; + const last = items[items.length - 1]; + if (!dialog.contains(document.activeElement)) { event.preventDefault(); first?.focus(); } + else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }; + document.addEventListener('keydown', onKeyDown); + return () => { document.removeEventListener('keydown', onKeyDown); detailReturnRef.current?.focus(); }; + }, [selected?.taskId, openTask]); + + const updateDetail = useCallback(async (task: BoardTask, change: { priority: TaskPriority } | { following: boolean }) => { + if (!podId || detailSaving) return; + const requestId = ++taskMutationRef.current; + setDetailSaving(true); + setDetailError(null); + try { + const path = `/api/v1/tasks/${podId}/${encodeURIComponent(task.taskId)}`; + const result = 'following' in change + ? await api.post<{ task: BoardTask }>(`${path}/watch`, change) + : await api.patch<{ task: BoardTask }>(path, change); + if (requestId !== taskMutationRef.current) return; + if ('following' in change) { + // The response is a snapshot from before another follower's write. + // A new read owns the complete set; never replace it with that snapshot. + reloadTasksRef.current(); + return; + } + taskRequestRef.current += 1; + if (taskLoadPendingRef.current) reloadTasksRef.current(); + const merge = (existing: BoardTask): BoardTask => { + if (existing.taskId !== task.taskId) return existing; + return Date.parse(existing.updatedAt || '') > Date.parse(result.task.updatedAt || '') ? existing : result.task; + }; + setTasks((rows) => rows.map(merge)); + setSelected((previous) => previous ? merge(previous) : null); + } catch { + if (requestId === taskMutationRef.current) setDetailError(t('board.detail.saveError')); + } finally { + if (requestId === taskMutationRef.current) setDetailSaving(false); + } + }, [api, podId, detailSaving, t]); + const claimHolderName = useCallback((task: BoardTask): string => { const member = podMembers.find((item) => memberId(item) === task.claimedBy); if (member) return memberLabel(member); // The claim route records the claimant's User id and resolved author on // the update. Match identity rather than the wording of the update text. const holderUpdate = task.updates?.slice().reverse().find((update) => update.authorId === task.claimedBy); - return holderUpdate?.author || task.claimedBy || ''; - }, [podMembers]); + return holderUpdate?.author || (/^[0-9a-f]{24}$/i.test(task.claimedBy || '') ? t('board.detail.memberUnavailable') : task.claimedBy) || ''; + }, [podMembers, t]); + const leaseFor = (task: BoardTask) => { + const claimed = ['claimed', 'in_progress', 'in-progress'].includes(task.status) && Boolean(task.claimedBy); + const expiry = Date.parse(task.claimExpiresAt || ''); + const knownExpiry = Number.isFinite(expiry); + return { claimed, expiry, knownExpiry, expired: claimed && knownExpiry && expiry <= claimClock, + minutes: knownExpiry ? Math.max(0, Math.ceil((expiry - claimClock) / 60_000)) : null }; + }; + const timeLabel = (expiry: number) => new Intl.DateTimeFormat(undefined, { + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }).format(new Date(expiry)); + const ownerLine = (task: BoardTask) => { + if (['done', 'completed', 'complete'].includes(task.status)) return null; + const lease = leaseFor(task); + if (!lease.claimed && !task.assignee) return null; + const state = lease.expired ? 'expired' : lease.claimed ? 'live' : 'not-claimed'; + const text = lease.expired ? t('board.owner.expired', { holder: claimHolderName(task) }) + : lease.claimed ? (lease.knownExpiry + ? }} /> + : t('board.owner.claimed', { holder: claimHolderName(task) })) + : t('board.owner.assigned', { assignee: task.assignee }); + return ; + }; + const memberAvatar = (member: PodMember, name: string) => ( + + ); + const watchers = selected ? [...(selected.watchers || [])].sort((a, b) => ( + Number(b === currentUser?._id) - Number(a === currentUser?._id) + )) : []; + const following = Boolean(currentUser?._id && watchers.includes(currentUser._id)); const [focusRead, setFocusRead] = useState(null); const [focusLoading, setFocusLoading] = useState(true); const [focusError, setFocusError] = useState(false); @@ -209,17 +317,25 @@ const V2PodBoard: React.FC = () => { const load = useCallback(async () => { if (!podId) return; + const requestId = ++taskRequestRef.current; + taskLoadPendingRef.current = true; try { - const data = await api.get<{ tasks: BoardTask[] }>(`/api/v1/tasks/${podId}`); + const data = await api.get<{ tasks: BoardTask[]; permissions?: { canWrite: boolean } }>(`/api/v1/tasks/${podId}`); + if (requestId !== taskRequestRef.current) return; setTasks(data.tasks || []); + setTaskWriteAllowed(data.permissions?.canWrite === true); + setSelected((previous) => previous ? (data.tasks || []).find((task) => task.taskId === previous.taskId) || null : null); + setClaimClock(Date.now()); setLoadError(false); } catch { - setLoadError(true); + if (requestId === taskRequestRef.current) setLoadError(true); } finally { - setLoading(false); + if (requestId === taskRequestRef.current) { taskLoadPendingRef.current = false; setLoading(false); } } }, [api, podId]); + useLayoutEffect(() => { reloadTasksRef.current = load; }, [load]); + const loadFocus = useCallback(async () => { if (!podId) return; const requestId = ++focusRequestRef.current; @@ -241,7 +357,11 @@ const V2PodBoard: React.FC = () => { useEffect(() => { setLoading(true); + setSelected(null); + setDetailError(null); + setDetailSaving(false); load(); + return () => { taskRequestRef.current += 1; taskMutationRef.current += 1; }; }, [load]); useEffect(() => { @@ -491,10 +611,12 @@ const V2PodBoard: React.FC = () => { try { await api.post(`/api/v1/tasks/${podId}`, { title, + priority: newPriority, ...(newAssignee.trim() ? { assignee: newAssignee.trim() } : {}), }); setNewTitle(''); setNewAssignee(''); + setNewPriority('P2'); setCreateOpen(false); load(); } catch { @@ -502,12 +624,13 @@ const V2PodBoard: React.FC = () => { } finally { setCreating(false); } - }, [api, podId, newTitle, newAssignee, creating, load, t]); + }, [api, podId, newTitle, newAssignee, newPriority, creating, load, t]); const columnTasks = useMemo(() => { const byColumn = new Map(); COLUMNS.forEach((col) => { - byColumn.set(col.key, tasks.filter((task) => col.statuses.includes(task.status))); + byColumn.set(col.key, tasks.filter((task) => col.statuses.includes(task.status)).sort((a, b) => (a.priority || 'P2').localeCompare(b.priority || 'P2') + || (a.taskNum || 0) - (b.taskNum || 0) || a.taskId.localeCompare(b.taskId))); }); return byColumn; }, [tasks]); @@ -584,7 +707,7 @@ const V2PodBoard: React.FC = () => { {task.available && tasks.some((item) => item.taskId === task.taskId) ? ( ) : {t('board.focus.taskUnavailable')}} {(task.status || task.assignee) && @@ -621,26 +744,19 @@ const V2PodBoard: React.FC = () => {
{t(col.emptyKey)}
)} {items.map((task) => ( -
+
{col.moves.map((move) => ( @@ -663,29 +779,58 @@ const V2PodBoard: React.FC = () => { )} {selected && ( -
setSelected(null)}> +
openTask(null)}>
event.stopPropagation()} > +