feat(tasks): update team task status timeline

This commit is contained in:
777genius 2026-05-06 20:41:24 +03:00
parent 9e1abb0332
commit 3992ab0dab
12 changed files with 193 additions and 17 deletions

View file

@ -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) => { return updateTask(paths, taskRef, (task) => {
if (owner == null || owner === 'clear' || owner === 'none') { const previousOwner = normalizeOwnerValue(task.owner);
delete task.owner; const nextOwner = normalizeOwnerValue(owner);
if (nextOwner) {
task.owner = nextOwner;
} else { } 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; return task;
}); });
} }

View file

@ -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 { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const nextOwner = isClearOwnerValue(owner) const nextOwner = isClearOwnerValue(owner)
? owner ? owner
: assertKnownTaskActor(context, owner, 'task 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 { return {
previousTask: before, previousTask: before,
updatedTask: after, updatedTask: after,
@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
- Human-facing summaries should use the short display label like #abcd1234 for readability. - 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: 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: - If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner:
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>" } { teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>", actor: "<your-name>" }
- Do this only when you are genuinely taking over the work. - Do this only when you are genuinely taking over the work.
- Reviewing, approving, or leaving comments does NOT require changing ownership. - Reviewing, approving, or leaving comments does NOT require changing ownership.
2. Use MCP tool task_start to mark task started: 2. Use MCP tool task_start to mark task started:

View file

@ -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', () => { it('wraps review instructions in the canonical agent block format used by the UI', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
completeTask(taskId: string, actor?: string): unknown; completeTask(taskId: string, actor?: string): unknown;
softDeleteTask(taskId: string, actor?: string): unknown; softDeleteTask(taskId: string, actor?: string): unknown;
restoreTask(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; updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown; addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown; attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;

View file

@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'task_set_owner', name: 'task_set_owner',
description: 'Assign or clear task owner', description: 'Assign, reassign, or clear task owner',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
taskId: z.string().min(1), taskId: z.string().min(1),
owner: z.string().nullable(), owner: z.string().nullable(),
actor: z.string().optional(),
}), }),
execute: async ({ teamName, claudeDir, taskId, owner }) => { execute: async ({ teamName, claudeDir, taskId, owner, actor }) => {
assertConfiguredTeam(teamName, claudeDir); assertConfiguredTeam(teamName, claudeDir);
return await Promise.resolve( return await Promise.resolve(
jsonTextContent( jsonTextContent(
slimTask( slimTask(
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record< getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record<
string, string,
unknown unknown
> >
@ -624,7 +625,13 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
runtimeProvider: z.enum(['native', 'opencode']).optional(), runtimeProvider: z.enum(['native', 'opencode']).optional(),
includeActiveProcesses: z.boolean().optional(), includeActiveProcesses: z.boolean().optional(),
}), }),
execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses }) => { execute: async ({
teamName,
claudeDir,
memberName,
runtimeProvider,
includeActiveProcesses,
}) => {
assertConfiguredTeam(teamName, claudeDir); assertConfiguredTeam(teamName, claudeDir);
return { return {
content: [ content: [

View file

@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => {
); );
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id); 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( const owned = parseJsonToolResult(
await getTool('task_set_owner').execute({ await getTool('task_set_owner').execute({
claudeDir, claudeDir,
teamName, teamName,
taskId: createdTask.id, taskId: createdTask.id,
owner: 'alice', owner: 'alice',
actor: 'lead',
}) })
); );
expect(owned.owner).toBe('alice'); 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( const commented = parseJsonToolResult(
await getTool('task_add_comment').execute({ await getTool('task_add_comment').execute({

View file

@ -2137,7 +2137,7 @@ export class TeamDataService {
} }
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> { async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
this.getController(teamName).tasks.setTaskOwner(taskId, owner); this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user');
this.invalidateGlobalTaskProjectionCache(); this.invalidateGlobalTaskProjectionCache();
} }

View file

@ -378,11 +378,25 @@ export class TeamTaskWriter {
} }
const task = JSON.parse(raw) as TeamTask; const task = JSON.parse(raw) as TeamTask;
if (owner) { const previousOwner =
task.owner = owner; 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 { } else {
delete task.owner; 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<TaskHistoryEvent, 'id' | 'timestamp'>
);
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
}); });
} }

View file

@ -6,7 +6,7 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } 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'; import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
@ -107,6 +107,52 @@ const EventContent = ({
<StatusBadge status={event.to} /> <StatusBadge status={event.to} />
</span> </span>
); );
case 'owner_changed':
return (
<span className="flex items-center gap-1">
<UserRound size={10} className="text-cyan-400" />
{event.from && event.to ? (
<>
Reassigned
<MemberBadge
name={event.from}
color={memberColorMap?.get(event.from)}
size="sm"
hideAvatar
/>
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
<MemberBadge
name={event.to}
color={memberColorMap?.get(event.to)}
size="sm"
hideAvatar
/>
</>
) : event.to ? (
<>
Assigned to
<MemberBadge
name={event.to}
color={memberColorMap?.get(event.to)}
size="sm"
hideAvatar
/>
</>
) : event.from ? (
<>
Unassigned from
<MemberBadge
name={event.from}
color={memberColorMap?.get(event.from)}
size="sm"
hideAvatar
/>
</>
) : (
'Owner changed'
)}
</span>
);
case 'review_requested': case 'review_requested':
return ( return (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string {
return dotColorForStatus(event.status); return dotColorForStatus(event.status);
case 'status_changed': case 'status_changed':
return dotColorForStatus(event.to); return dotColorForStatus(event.to);
case 'owner_changed':
return 'bg-cyan-400';
case 'review_requested': case 'review_requested':
return 'bg-purple-400'; return 'bg-purple-400';
case 'review_started': case 'review_started':

View file

@ -126,6 +126,12 @@ export interface TaskStatusChangedEvent extends TaskHistoryEventBase {
to: TeamTaskStatus; to: TeamTaskStatus;
} }
export interface TaskOwnerChangedEvent extends TaskHistoryEventBase {
type: 'owner_changed';
from?: string;
to?: string;
}
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase { export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
type: 'review_requested'; type: 'review_requested';
from: TeamReviewState; from: TeamReviewState;
@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
export type TaskHistoryEvent = export type TaskHistoryEvent =
| TaskCreatedEvent | TaskCreatedEvent
| TaskStatusChangedEvent | TaskStatusChangedEvent
| TaskOwnerChangedEvent
| TaskReviewRequestedEvent | TaskReviewRequestedEvent
| TaskReviewChangesRequestedEvent | TaskReviewChangesRequestedEvent
| TaskReviewApprovedEvent | TaskReviewApprovedEvent

View file

@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
completeTask(taskId: string, actor?: string): unknown; completeTask(taskId: string, actor?: string): unknown;
softDeleteTask(taskId: string, actor?: string): unknown; softDeleteTask(taskId: string, actor?: string): unknown;
restoreTask(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; updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown; addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown; attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;

View file

@ -332,5 +332,42 @@ describe('TeamTaskWriter', () => {
actor: 'user', 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<string, unknown>) => 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();
});
}); });
}); });