From 3992ab0dab64173d7cfb72603709ad906109cf2d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 20:41:24 +0300 Subject: [PATCH] feat(tasks): update team task status timeline --- .../src/internal/taskStore.js | 29 +++++++++-- agent-teams-controller/src/internal/tasks.js | 6 +-- .../test/controller.test.js | 21 ++++++++ mcp-server/src/agent-teams-controller.d.ts | 2 +- mcp-server/src/tools/taskTools.ts | 15 ++++-- mcp-server/test/tools.test.ts | 21 ++++++++ src/main/services/team/TeamDataService.ts | 2 +- src/main/services/team/TeamTaskWriter.ts | 18 ++++++- .../team/dialogs/StatusHistoryTimeline.tsx | 50 ++++++++++++++++++- src/shared/types/team.ts | 7 +++ src/types/agent-teams-controller.d.ts | 2 +- .../main/services/team/TeamTaskWriter.test.ts | 37 ++++++++++++++ 12 files changed, 193 insertions(+), 17 deletions(-) diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index 62f489d3..a25b075a 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -475,13 +475,34 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) { }); } -function setTaskOwner(paths, taskRef, owner) { +function normalizeOwnerValue(owner) { + if (owner == null || owner === 'clear' || owner === 'none') { + return undefined; + } + const normalized = String(owner).trim(); + return normalized ? normalized : undefined; +} + +function setTaskOwner(paths, taskRef, owner, actor) { return updateTask(paths, taskRef, (task) => { - if (owner == null || owner === 'clear' || owner === 'none') { - delete task.owner; + const previousOwner = normalizeOwnerValue(task.owner); + const nextOwner = normalizeOwnerValue(owner); + + if (nextOwner) { + task.owner = nextOwner; } else { - task.owner = String(owner).trim(); + delete task.owner; } + + if (previousOwner !== nextOwner) { + task.historyEvents = appendHistoryEvent(task.historyEvents, { + type: 'owner_changed', + ...(previousOwner ? { from: previousOwner } : {}), + ...(nextOwner ? { to: nextOwner } : {}), + ...(actor ? { actor } : {}), + }); + } + return task; }); } diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 6222e4a7..82d49ab9 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -472,13 +472,13 @@ function restoreTask(context, taskId, actor) { }); } -function setTaskOwner(context, taskId, owner) { +function setTaskOwner(context, taskId, owner, actor) { const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => { const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); const nextOwner = isClearOwnerValue(owner) ? owner : assertKnownTaskActor(context, owner, 'task owner'); - const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner); + const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner, normalizeActorName(actor) || undefined); return { previousTask: before, updatedTask: after, @@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - Human-facing summaries should use the short display label like #abcd1234 for readability. 1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer: - If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner: - { teamName: "${teamName}", taskId: "", owner: "" } + { teamName: "${teamName}", taskId: "", owner: "", actor: "" } - Do this only when you are genuinely taking over the work. - Reviewing, approving, or leaving comments does NOT require changing ownership. 2. Use MCP tool task_start to mark task started: diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index c4136a2f..fbada8cd 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -717,6 +717,27 @@ describe('agent-teams-controller API', () => { ]); }); + it('tracks owner assignment history without duplicate same-owner events', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Owner history' }); + + controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead'); + controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead'); + controller.tasks.setTaskOwner(task.id, 'alice', 'team-lead'); + controller.tasks.setTaskOwner(task.id, null, 'team-lead'); + + const ownerEvents = controller.tasks + .getTask(task.id) + .historyEvents.filter((event) => event.type === 'owner_changed'); + + expect(ownerEvents).toHaveLength(3); + expect(ownerEvents[0]).toMatchObject({ to: 'bob', actor: 'team-lead' }); + expect(ownerEvents[1]).toMatchObject({ from: 'bob', to: 'alice', actor: 'team-lead' }); + expect(ownerEvents[2]).toMatchObject({ from: 'alice', actor: 'team-lead' }); + expect(ownerEvents[2].to).toBeUndefined(); + }); + it('wraps review instructions in the canonical agent block format used by the UI', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index a82d7702..48c9b8b8 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -31,7 +31,7 @@ declare module 'agent-teams-controller' { completeTask(taskId: string, actor?: string): unknown; softDeleteTask(taskId: string, actor?: string): unknown; restoreTask(taskId: string, actor?: string): unknown; - setTaskOwner(taskId: string, owner: string | null): unknown; + setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown; updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown; addTaskComment(taskId: string, flags: Record): unknown; attachTaskFile(taskId: string, flags: Record): unknown; diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 1bf98490..fe4fb479 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_set_owner', - description: 'Assign or clear task owner', + description: 'Assign, reassign, or clear task owner', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), owner: z.string().nullable(), + actor: z.string().optional(), }), - execute: async ({ teamName, claudeDir, taskId, owner }) => { + execute: async ({ teamName, claudeDir, taskId, owner, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( - getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record< + getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record< string, unknown > @@ -624,7 +625,13 @@ export function registerTaskTools(server: Pick) { runtimeProvider: z.enum(['native', 'opencode']).optional(), includeActiveProcesses: z.boolean().optional(), }), - execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses }) => { + execute: async ({ + teamName, + claudeDir, + memberName, + runtimeProvider, + includeActiveProcesses, + }) => { assertConfiguredTeam(teamName, claudeDir); return { content: [ diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 034df9f2..9860f8e1 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => { ); expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id); + await getTool('task_set_owner').execute({ + claudeDir, + teamName, + taskId: createdTask.id, + owner: null, + actor: 'lead', + }); + const owned = parseJsonToolResult( await getTool('task_set_owner').execute({ claudeDir, teamName, taskId: createdTask.id, owner: 'alice', + actor: 'lead', }) ); expect(owned.owner).toBe('alice'); + const ownedFull = parseJsonToolResult( + await getTool('task_get').execute({ + claudeDir, + teamName, + taskId: createdTask.id, + }) + ); + expect(ownedFull.historyEvents.at(-1)).toMatchObject({ + type: 'owner_changed', + to: 'alice', + actor: 'lead', + }); const commented = parseJsonToolResult( await getTool('task_add_comment').execute({ diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a4add099..de1bad7a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2137,7 +2137,7 @@ export class TeamDataService { } async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise { - this.getController(teamName).tasks.setTaskOwner(taskId, owner); + this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user'); this.invalidateGlobalTaskProjectionCache(); } diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 233f6908..e537448d 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -378,11 +378,25 @@ export class TeamTaskWriter { } const task = JSON.parse(raw) as TeamTask; - if (owner) { - task.owner = owner; + const previousOwner = + typeof task.owner === 'string' && task.owner.trim() ? task.owner.trim() : undefined; + const nextOwner = typeof owner === 'string' && owner.trim() ? owner.trim() : undefined; + if (nextOwner) { + task.owner = nextOwner; } else { delete task.owner; } + if (previousOwner !== nextOwner) { + task.historyEvents = appendHistoryEvent( + Array.isArray(task.historyEvents) ? task.historyEvents : undefined, + { + type: 'owner_changed', + ...(previousOwner ? { from: previousOwner } : {}), + ...(nextOwner ? { to: nextOwner } : {}), + actor: 'user', + } as Omit + ); + } await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); }); } diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index 589e67cf..09f0594d 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -6,7 +6,7 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; -import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react'; +import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react'; import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types'; @@ -107,6 +107,52 @@ const EventContent = ({ ); + case 'owner_changed': + return ( + + + {event.from && event.to ? ( + <> + Reassigned + + + + + ) : event.to ? ( + <> + Assigned to + + + ) : event.from ? ( + <> + Unassigned from + + + ) : ( + 'Owner changed' + )} + + ); case 'review_requested': return ( @@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string { return dotColorForStatus(event.status); case 'status_changed': return dotColorForStatus(event.to); + case 'owner_changed': + return 'bg-cyan-400'; case 'review_requested': return 'bg-purple-400'; case 'review_started': diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 4a93ba75..b11ac9fc 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -126,6 +126,12 @@ export interface TaskStatusChangedEvent extends TaskHistoryEventBase { to: TeamTaskStatus; } +export interface TaskOwnerChangedEvent extends TaskHistoryEventBase { + type: 'owner_changed'; + from?: string; + to?: string; +} + export interface TaskReviewRequestedEvent extends TaskHistoryEventBase { type: 'review_requested'; from: TeamReviewState; @@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase { export type TaskHistoryEvent = | TaskCreatedEvent | TaskStatusChangedEvent + | TaskOwnerChangedEvent | TaskReviewRequestedEvent | TaskReviewChangesRequestedEvent | TaskReviewApprovedEvent diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index b858cae4..271cd5df 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -31,7 +31,7 @@ declare module 'agent-teams-controller' { completeTask(taskId: string, actor?: string): unknown; softDeleteTask(taskId: string, actor?: string): unknown; restoreTask(taskId: string, actor?: string): unknown; - setTaskOwner(taskId: string, owner: string | null): unknown; + setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown; updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown; addTaskComment(taskId: string, flags: Record): unknown; attachTaskFile(taskId: string, flags: Record): unknown; diff --git a/test/main/services/team/TeamTaskWriter.test.ts b/test/main/services/team/TeamTaskWriter.test.ts index 0b237706..2c15e0dc 100644 --- a/test/main/services/team/TeamTaskWriter.test.ts +++ b/test/main/services/team/TeamTaskWriter.test.ts @@ -332,5 +332,42 @@ describe('TeamTaskWriter', () => { actor: 'user', }); }); + + it('updateOwner appends owner_changed event', async () => { + hoisted.files.set( + taskPath, + JSON.stringify({ + id: '12', + subject: 'task', + owner: 'alice', + status: 'pending', + historyEvents: [ + { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, + ], + }) + ); + + await writer.updateOwner('my-team', '12', 'bob'); + await writer.updateOwner('my-team', '12', 'bob'); + await writer.updateOwner('my-team', '12', null); + + const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); + const ownerEvents = persisted.historyEvents.filter( + (event: Record) => event.type === 'owner_changed' + ); + expect(ownerEvents).toHaveLength(2); + expect(ownerEvents[0]).toMatchObject({ + type: 'owner_changed', + from: 'alice', + to: 'bob', + actor: 'user', + }); + expect(ownerEvents[1]).toMatchObject({ + type: 'owner_changed', + from: 'bob', + actor: 'user', + }); + expect(ownerEvents[1].to).toBeUndefined(); + }); }); });