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

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 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: "<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.
- Reviewing, approving, or leaving comments does NOT require changing ownership.
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', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -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<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({
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<FastMCP, 'addTool'>) {
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: [

View file

@ -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({

View file

@ -2137,7 +2137,7 @@ export class TeamDataService {
}
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();
}

View file

@ -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<TaskHistoryEvent, 'id' | 'timestamp'>
);
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}

View file

@ -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 = ({
<StatusBadge status={event.to} />
</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':
return (
<span className="flex items-center gap-1">
@ -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':

View file

@ -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

View file

@ -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<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;

View file

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