From 29b307f81a7395da054c9d573e5ad2c5ae3d5920 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:13:43 -0700 Subject: [PATCH 1/8] Specify task priority ownership and watcher delivery Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- ...task-priority-owner-watchers-2026-10-24.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/design/task-priority-owner-watchers-2026-10-24.md 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..983d907d27 --- /dev/null +++ b/docs/design/task-priority-owner-watchers-2026-10-24.md @@ -0,0 +1,27 @@ +# Task priority, live owner and watchers + +TASK-443 · D40 · Design proposal, 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. 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. 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. Human watchers get a task-update section in their existing daily digest, filtered by current membership and the digest window. 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 bulkWrite 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. 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. From 73c889c0b431dc8210566c3e3d0282f5494c32a7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:27:37 -0700 Subject: [PATCH 2/8] Add task priority ordering and digest subscriptions Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../taskEventService.offeredAt.test.js | 29 ++++-- .../service/tasks.claim-lease.test.js | 10 ++ .../__tests__/service/tasks.priority.test.js | 98 +++++++++++++++++++ .../dailyDigestService.membership.test.js | 23 +++++ .../nativeRuntimeService.hostedEngine.test.js | 2 +- backend/models/Task.ts | 7 ++ backend/package.json | 1 + backend/routes/tasksApi.ts | 56 +++++++++-- backend/scripts/migrate-task-priority.ts | 42 ++++++++ backend/services/dailyDigestService.ts | 23 ++++- backend/services/kernelWorkSweepService.ts | 2 + backend/services/nativeRuntimeService.ts | 14 ++- backend/services/taskEventService.ts | 15 +-- backend/utils/taskPriority.ts | 17 ++++ commonly-mcp/__tests__/tools.test.mjs | 14 ++- commonly-mcp/src/tools.js | 18 +++- docs/adr/ADR-018-agent-attention-claims.md | 10 ++ ...task-priority-owner-watchers-2026-10-24.md | 2 +- packages/types/src/task.ts | 5 +- 19 files changed, 355 insertions(+), 33 deletions(-) create mode 100644 backend/__tests__/service/tasks.priority.test.js create mode 100644 backend/scripts/migrate-task-priority.ts create mode 100644 backend/utils/taskPriority.ts 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..caed127fcc --- /dev/null +++ b/backend/__tests__/service/tasks.priority.test.js @@ -0,0 +1,98 @@ +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']); +}); + +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); +}); diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index 34e51ec63f..e03eb2d082 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -424,3 +424,26 @@ 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); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 6dd62afcae..7cae1cbdb1 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -372,7 +372,7 @@ describe('nativeRuntimeService — hosted installs on the native engine', () => expect(Task.find).toHaveBeenCalledWith({ podId: POD, status: { $in: ['pending', 'claimed', 'blocked'] } }); 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/models/Task.ts b/backend/models/Task.ts index f205ba7115..9c3d32407b 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,8 @@ export interface ITask extends Document { taskNum: number; taskId: string; title: string; + priority: TaskPriority; + watchers: Types.ObjectId[]; assignee?: string | null; dep?: string | null; depMockOk: boolean; @@ -52,6 +56,8 @@ 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' }], assignee: { type: String, default: null }, dep: { type: String, default: null }, depMockOk: { type: Boolean, default: false }, @@ -86,6 +92,7 @@ const TaskSchema = new Schema( ); TaskSchema.index({ podId: 1, status: 1 }); +TaskSchema.index({ watchers: 1, podId: 1, updatedAt: -1 }); 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..f3b42ccbf3 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'; @@ -257,7 +258,7 @@ 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) })), + tasks: tasks.sort(compareTaskPriority).map((task: Record) => ({ ...task, leaseState: deriveLeaseState(task, now) })), }); } catch (err) { console.error('GET /tasks error:', err); @@ -278,6 +279,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 +287,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 +402,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 +767,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, { write: 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 +807,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 +847,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 +937,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 +952,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 +997,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 +1055,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..1810ffbd71 --- /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, 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..69a8ad2248 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -1,6 +1,8 @@ // 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 +188,29 @@ 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({ members: userId }).select('_id name').lean() as Array<{ _id: unknown; name?: string }> + : []; + const followed = recap ? await Task.find({ + podId: { $in: memberPods.map((pod) => pod._id) }, + watchers: userId, + updatedAt: { $gte: startTime, $lte: endTime }, + }).sort({ priority: 1, taskNum: 1 }).limit(MAX_BOARD) + .select('taskId title status podId').lean() 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) { diff --git a/backend/services/kernelWorkSweepService.ts b/backend/services/kernelWorkSweepService.ts index c3588ec069..34480ffac1 100644 --- a/backend/services/kernelWorkSweepService.ts +++ b/backend/services/kernelWorkSweepService.ts @@ -318,6 +318,8 @@ class KernelWorkSweepService { _id: '$_id', taskId: '$taskId', title: '$title', + priority: { $ifNull: ['$priority', 'P2'] }, + taskNum: '$taskNum', lapsedFrom: '$lapsedFrom', updatedAt: '$updatedAt', }, diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5fdd6f94ec..2dcc23237f 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -25,6 +25,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any, global-require, @typescript-eslint/no-require-imports */ +import { isTaskPriority } from '../utils/taskPriority'; import axios, { AxiosError } from 'axios'; import { dispatchHostedBrokerTool, @@ -291,6 +292,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 +305,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 +897,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 +912,7 @@ export async function dispatchTool( taskNum, taskId, title, + priority: args.priority || 'P2', assignee: assigneeParsed.value, notes: args.notes ? String(args.notes) : null, status: 'pending', @@ -1092,12 +1098,12 @@ export async function dispatchTool( 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.find(filter).sort({ priority: 1, taskNum: 1, _id: 1 }).limit(LIST_TASKS_LIMIT) + .select('taskId title priority status assignee').lean(), 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..ab453277d4 100644 --- a/backend/services/taskEventService.ts +++ b/backend/services/taskEventService.ts @@ -1,3 +1,4 @@ +import { compareTaskPriority } from '../utils/taskPriority'; /** * taskEventService * @@ -261,12 +262,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)}`; }), ] : []), '', @@ -361,8 +362,8 @@ export async function notifyPodAgents( ? [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(); + { taskId: 1, title: 1, priority: 1, taskNum: 1, status: 1, updatedAt: 1, updates: { $slice: -1 } }, + ).sort({ priority: 1, taskNum: 1, _id: 1 }).limit(MAX_ASSIGNED_ROWS).lean(); } 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); @@ -469,7 +470,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 +503,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/taskPriority.ts b/backend/utils/taskPriority.ts new file mode 100644 index 0000000000..5d2611843c --- /dev/null +++ b/backend/utils/taskPriority.ts @@ -0,0 +1,17 @@ +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 || '')); +} 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..0c54af2054 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -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']), @@ -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..a2879e2c72 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -496,6 +496,16 @@ If enqueue reports an ambiguous error, retain the slot: persistence may have 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 diff --git a/docs/design/task-priority-owner-watchers-2026-10-24.md b/docs/design/task-priority-owner-watchers-2026-10-24.md index 983d907d27..8ea09df3ea 100644 --- a/docs/design/task-priority-owner-watchers-2026-10-24.md +++ b/docs/design/task-priority-owner-watchers-2026-10-24.md @@ -14,7 +14,7 @@ Task updates feed agent watchers through deliveryMode=digest, never a watcher-cr ## Silent migration -Use a rerunnable direct collection bulkWrite 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. Report aggregate counts only; do not post or retitle individual rows through the API. +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 diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index c1500ab2cb..70086bb0ff 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -15,7 +15,10 @@ export interface ITask { description?: string; assignee?: string; status: TaskStatus; - priority?: 'low' | 'medium' | 'high'; + priority?: 'P0' | 'P1' | 'P2' | 'P3'; + watchers?: string[]; + claimedBy?: string | null; + claimExpiresAt?: string | Date | null; sourceRef?: string; prUrl?: string; dep?: string; From 05f73f5f72b552bcbb9eadefdd7b1c11a3332343 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:28:56 -0700 Subject: [PATCH 3/8] Publish task subscription tool and document priority choice Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- commonly-mcp/README.md | 2 +- commonly-mcp/package-lock.json | 4 ++-- commonly-mcp/package.json | 2 +- commonly-mcp/server.json | 4 ++-- commonly-mcp/src/tools.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) 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/package-lock.json b/commonly-mcp/package-lock.json index a400b1892a..d834be9fb0 100644 --- a/commonly-mcp/package-lock.json +++ b/commonly-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/mcp", - "version": "0.3.16", + "version": "0.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/mcp", - "version": "0.3.16", + "version": "0.3.17", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4" diff --git a/commonly-mcp/package.json b/commonly-mcp/package.json index 9eb063dbf6..c3cea4c73c 100644 --- a/commonly-mcp/package.json +++ b/commonly-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@commonlyai/mcp", "mcpName": "io.github.Team-Commonly/commonly", - "version": "0.3.16", + "version": "0.3.17", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/commonly-mcp/server.json b/commonly-mcp/server.json index 5265ee2c09..ec67195efb 100644 --- a/commonly-mcp/server.json +++ b/commonly-mcp/server.json @@ -9,12 +9,12 @@ "source": "github", "subfolder": "commonly-mcp" }, - "version": "0.3.16", + "version": "0.3.17", "packages": [ { "registryType": "npm", "identifier": "@commonlyai/mcp", - "version": "0.3.16", + "version": "0.3.17", "transport": { "type": "stdio" }, diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index 0c54af2054..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, @@ -374,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', From ad975b5bde3b9fd8dacc412db886c3820c97c613 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:47:43 -0700 Subject: [PATCH 4/8] feat(board): show task priority, live ownership and followers Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../__tests__/service/tasks.priority.test.js | 15 ++ .../__tests__/service/tasks.watchers.test.js | 41 ++++ .../dailyDigestService.membership.test.js | 26 +++ .../nativeRuntimeService.hostedEngine.test.js | 6 +- .../taskEventService.notifyPodAgents.test.js | 1 + backend/middleware/agentRuntimeAuth.ts | 32 +-- backend/routes/tasksApi.ts | 6 +- backend/scripts/migrate-task-priority.ts | 2 +- backend/services/dailyDigestService.ts | 35 ++-- backend/services/kernelWorkSweepService.ts | 2 + backend/services/nativeRuntimeService.ts | 11 +- backend/services/taskEventService.ts | 53 ++++- backend/utils/agentTokenIdentity.ts | 31 +++ backend/utils/taskPriority.ts | 6 + ...task-priority-owner-watchers-2026-10-24.md | 10 +- frontend/src/i18n/locales/en.json | 28 ++- frontend/src/i18n/locales/zh-CN.json | 28 ++- frontend/src/v2/__tests__/V2PodBoard.test.tsx | 57 ++++- .../v2/__tests__/v2-layout-invariants.test.ts | 27 ++- frontend/src/v2/components/V2PodBoard.tsx | 197 +++++++++++++++--- frontend/src/v2/v2.css | 82 +++++++- 21 files changed, 579 insertions(+), 117 deletions(-) create mode 100644 backend/__tests__/service/tasks.watchers.test.js create mode 100644 backend/utils/agentTokenIdentity.ts diff --git a/backend/__tests__/service/tasks.priority.test.js b/backend/__tests__/service/tasks.priority.test.js index caed127fcc..11e2642b89 100644 --- a/backend/__tests__/service/tasks.priority.test.js +++ b/backend/__tests__/service/tasks.priority.test.js @@ -96,3 +96,18 @@ test('note updates can change priority and reject invalid values', async () => { 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' }] } }); + 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..f73fe892f7 --- /dev/null +++ b/backend/__tests__/service/tasks.watchers.test.js @@ -0,0 +1,41 @@ +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 } = 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); +}); diff --git a/backend/__tests__/unit/services/dailyDigestService.membership.test.js b/backend/__tests__/unit/services/dailyDigestService.membership.test.js index e03eb2d082..faa4063f3d 100644 --- a/backend/__tests__/unit/services/dailyDigestService.membership.test.js +++ b/backend/__tests__/unit/services/dailyDigestService.membership.test.js @@ -447,3 +447,29 @@ test('followed task updates reach only a current member digest and create no att 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/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 7cae1cbdb1..6cd3f55fa4 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,7 +370,10 @@ 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'] } } }, + ])); + expect(String(Task.aggregate.mock.calls[0][0][0].$match.podId)).toBe(POD); 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', priority: 'P2', status: 'pending', assignee: null }], 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/routes/tasksApi.ts b/backend/routes/tasksApi.ts index f3b42ccbf3..afabdf1687 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -195,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 }> { 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) => { @@ -208,7 +208,7 @@ 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); @@ -774,7 +774,7 @@ router.post('/:podId/:taskId/watch', taskWriteRateLimit(60), auth, async (req: A 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, { write: true }); + 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 }, diff --git a/backend/scripts/migrate-task-priority.ts b/backend/scripts/migrate-task-priority.ts index 1810ffbd71..8bedee73a3 100644 --- a/backend/scripts/migrate-task-priority.ts +++ b/backend/scripts/migrate-task-priority.ts @@ -19,7 +19,7 @@ export async function migrateTaskPriority(dryRun = true): Promise<{ matched: num // 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, priority: row.priority === undefined ? { $exists: false } : row.priority }, + { _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; diff --git a/backend/services/dailyDigestService.ts b/backend/services/dailyDigestService.ts index 69a8ad2248..95dfed29ba 100644 --- a/backend/services/dailyDigestService.ts +++ b/backend/services/dailyDigestService.ts @@ -1,3 +1,5 @@ +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 @@ -189,14 +191,18 @@ export class DailyDigestService { ? await Pod.find({ members: userId, publicRead: true }).select('_id').lean() as Array<{ _id: unknown }> : []; const memberPods = recap - ? await Pod.find({ members: userId }).select('_id name').lean() as Array<{ _id: unknown; name?: string }> + ? await Pod.find({ $or: [{ members: userId }, { 'members.userId': userId }] }).select('_id name').lean() as Array<{ _id: unknown; name?: string }> : []; - const followed = recap ? await Task.find({ - podId: { $in: memberPods.map((pod) => pod._id) }, - watchers: userId, - updatedAt: { $gte: startTime, $lte: endTime }, - }).sort({ priority: 1, taskNum: 1 }).limit(MAX_BOARD) - .select('taskId title status podId').lean() as Array> : []; + 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) => ( @@ -301,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', { @@ -317,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 34480ffac1..cadf323c34 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 diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 2dcc23237f..e20ccf0f2a 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -25,7 +25,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any, global-require, @typescript-eslint/no-require-imports */ -import { isTaskPriority } from '../utils/taskPriority'; +import { Types } from 'mongoose'; +import { isTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; import axios, { AxiosError } from 'axios'; import { dispatchHostedBrokerTool, @@ -1094,12 +1095,14 @@ 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({ priority: 1, taskNum: 1, _id: 1 }).limit(LIST_TASKS_LIMIT) - .select('taskId title priority 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) => ({ diff --git a/backend/services/taskEventService.ts b/backend/services/taskEventService.ts index ab453277d4..1996ad1a46 100644 --- a/backend/services/taskEventService.ts +++ b/backend/services/taskEventService.ts @@ -1,4 +1,6 @@ -import { compareTaskPriority } from '../utils/taskPriority'; +import { resolveTokenAgentIdentity } from '../utils/agentTokenIdentity'; +import { Types } from 'mongoose'; +import { compareTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; /** * taskEventService * @@ -43,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 { @@ -250,7 +253,33 @@ export async function notifyPodAgents( // 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 || []; + 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) { + console.warn('[task-event] watcher lookup failed:', (error as Error).message); + } + } if (!installs.length) return; const taskId = String(task.taskId || task.id || task._id || 'unknown'); @@ -330,12 +359,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({ @@ -360,10 +389,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, priority: 1, taskNum: 1, status: 1, updatedAt: 1, updates: { $slice: -1 } }, - ).sort({ priority: 1, taskNum: 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); 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 index 5d2611843c..d195f31861 100644 --- a/backend/utils/taskPriority.ts +++ b/backend/utils/taskPriority.ts @@ -15,3 +15,9 @@ export function compareTaskPriority( 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/docs/design/task-priority-owner-watchers-2026-10-24.md b/docs/design/task-priority-owner-watchers-2026-10-24.md index 8ea09df3ea..8af6fab803 100644 --- a/docs/design/task-priority-owner-watchers-2026-10-24.md +++ b/docs/design/task-priority-owner-watchers-2026-10-24.md @@ -1,6 +1,6 @@ # Task priority, live owner and watchers -TASK-443 · D40 · Design proposal, 2026-10-24 · Owner: Tern +TASK-443 · D40 · Implementation design, 2026-10-24 · Owner: Tern ## Behavior @@ -10,7 +10,7 @@ Cards show a compact P0–P3 badge next to the task ID. The create form and task 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. 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. Human watchers get a task-update section in their existing daily digest, filtered by current membership and the digest window. 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. +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. 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 @@ -25,3 +25,9 @@ Use a rerunnable direct collection writes for open rows. Copy a leading [P0]–[ - 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..8f44a2b0a4 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -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,40 @@ 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'] } } }); + 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(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 4b8ff883a9..44be8c503d 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,24 @@ 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'); + }); +}); diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx index 603c527bf1..c72ebcf66a 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,6 +139,7 @@ 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(); @@ -144,6 +154,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 +169,107 @@ 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; + taskRequestRef.current += 1; + const merge = (existing: BoardTask): BoardTask => { + if (existing.taskId !== task.taskId) return existing; + // A socket read can land after this request began. Following changes + // only watchers and must not overwrite a newer task revision. + if ('following' in change) return { ...existing, watchers: result.task.watchers }; + 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,14 +310,18 @@ const V2PodBoard: React.FC = () => { const load = useCallback(async () => { if (!podId) return; + const requestId = ++taskRequestRef.current; try { const data = await api.get<{ tasks: BoardTask[] }>(`/api/v1/tasks/${podId}`); + if (requestId !== taskRequestRef.current) return; setTasks(data.tasks || []); + 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) setLoading(false); } }, [api, podId]); @@ -241,7 +346,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 +600,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 +613,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 +696,7 @@ const V2PodBoard: React.FC = () => { {task.available && tasks.some((item) => item.taskId === task.taskId) ? ( ) : {t('board.focus.taskUnavailable')}} {(task.status || task.assignee) && @@ -621,26 +733,19 @@ const V2PodBoard: React.FC = () => {
{t(col.emptyKey)}
)} {items.map((task) => ( -
+
{col.moves.map((move) => ( @@ -663,29 +768,58 @@ const V2PodBoard: React.FC = () => { )} {selected && ( -
setSelected(null)}> +
openTask(null)}>
event.stopPropagation()} > +