feat(tasks): update team task status timeline
This commit is contained in:
parent
9e1abb0332
commit
3992ab0dab
12 changed files with 193 additions and 17 deletions
|
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
2
mcp-server/src/agent-teams-controller.d.ts
vendored
2
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
src/types/agent-teams-controller.d.ts
vendored
2
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue