From b5a4d23af15e0d771bbb6b832016415c7ae2daa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 11 Aug 2026 05:56:39 +0800 Subject: [PATCH] fix(teams): stabilize activity, history, and lifecycle --- .../activity/sessionActivityModel.test.ts | 243 ++ .../activity/sessionActivityModel.ts | 90 +- .../agentTeams/AgentTeamsSummary.tsx | 66 +- .../agentTeams/AgentTeamsWorkbench.test.tsx | 117 +- .../agentTeams/AgentTeamsWorkbench.tsx | 62 +- .../agentTeams/agentTeamsModel.test.ts | 52 + .../components/agentTeams/agentTeamsModel.ts | 55 +- .../src/components/chat/MessageList.test.tsx | 656 +++++- desktop/src/components/chat/MessageList.tsx | 403 +++- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/lib/teamLifecycleScope.ts | 64 + desktop/src/pages/ActiveSession.tsx | 10 +- desktop/src/stores/chatStore.test.ts | 97 +- desktop/src/types/team.ts | 2 + src/bootstrap/state.ts | 15 +- src/server/__tests__/team-watcher.test.ts | 38 + src/server/__tests__/teams.test.ts | 2056 ++++++++++++++++- src/server/services/teamService.ts | 976 ++++++-- src/server/services/teamWatcher.ts | 13 +- src/tools/TaskCreateTool/TaskCreateTool.ts | 9 +- src/tools/TaskListTool/TaskListTool.ts | 9 +- src/tools/TaskTools.eager.test.ts | 497 ++++ src/tools/TaskUpdateTool/TaskUpdateTool.ts | 186 +- src/tools/TeamCreateTool/TeamCreateTool.ts | 114 +- src/tools/TeamDeleteTool/TeamDeleteTool.ts | 45 +- src/utils/swarm/inProcessRunner.test.ts | 559 ++++- src/utils/swarm/inProcessRunner.ts | 42 +- src/utils/swarm/teamHelpers.ts | 254 +- src/utils/taskListId.test.ts | 4 +- src/utils/tasks.ts | 834 ++++++- 34 files changed, 6952 insertions(+), 626 deletions(-) create mode 100644 desktop/src/lib/teamLifecycleScope.ts diff --git a/desktop/src/components/activity/sessionActivityModel.test.ts b/desktop/src/components/activity/sessionActivityModel.test.ts index 961f9830..a8ec0ec0 100644 --- a/desktop/src/components/activity/sessionActivityModel.test.ts +++ b/desktop/src/components/activity/sessionActivityModel.test.ts @@ -521,6 +521,249 @@ describe('buildSessionActivityModel', () => { ]) }) + it('waits for a pending Agent input to finish before projecting an unknown owner', () => { + const buildMainModel = (message: UIMessage) => buildMainSessionActivityModel({ + sessionId: 'session-1', + messages: [message], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + const partialAgent: UIMessage = { + id: 'streaming-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'streaming-agent-tool', + input: { description: '正在流式解析任务' }, + timestamp: 1000, + isPending: true, + partialInput: '{"description":"正在流式解析任务","name":', + } + + expect(hasVisibleSessionActivity(buildMainModel(partialAgent))).toBe(false) + + const completedDirectAgent = buildMainModel({ + ...partialAgent, + input: { description: '正在流式解析任务' }, + isPending: false, + partialInput: undefined, + }) + expect(completedDirectAgent.sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'streaming-agent-tool', + label: '正在流式解析任务', + status: 'running', + }), + ]) + }) + + it('uses the durable Team window to classify name-only Agent launches in both directions', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'create-tool', + input: { team_name: 'durable-team' }, + timestamp: 100, + }, + { + id: 'create-result', + type: 'tool_result', + toolUseId: 'create-tool', + content: { success: true, team_name: 'durable-team' }, + isError: false, + timestamp: 101, + }, + { + id: 'window-member', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'window-member', + input: { name: 'reviewer', description: 'Team member from durable scope' }, + timestamp: 150, + isPending: false, + }, + { + id: 'window-direct', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'window-direct', + input: { description: 'Unnamed direct Agent inside Team scope' }, + timestamp: 160, + isPending: false, + }, + { + id: 'post-window-direct', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'post-window-direct', + input: { name: 'standalone-reviewer', description: 'Named direct Agent after Team scope' }, + timestamp: 250, + isPending: false, + }, + { + id: 'explicit-team-member', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'explicit-team-member', + input: { + name: 'explicit-reviewer', + team_name: 'archived-team', + description: 'Explicit Team member outside durable scope', + }, + timestamp: 260, + isPending: false, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'window-direct', + label: 'Unnamed direct Agent inside Team scope', + }), + expect.objectContaining({ + id: 'post-window-direct', + label: 'Named direct Agent after Team scope', + }), + ]) + expect(model.sections.team.rows).toEqual([]) + }) + + it('does not classify a named direct Agent as a teammate after TeamCreate fails', () => { + const createTool: UIMessage = { + id: 'create-tool', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'create-tool', + input: { team_name: 'review-team' }, + timestamp: 100, + } + const namedAgent: UIMessage = { + id: 'named-agent-tool', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'named-agent-tool', + input: { name: 'reviewer', description: 'Review independently' }, + timestamp: 200, + } + const buildMainModel = (success: boolean, agentResult?: UIMessage) => buildMainSessionActivityModel({ + sessionId: 'session-1', + messages: [ + createTool, + { + id: 'create-result', + type: 'tool_result', + toolUseId: 'create-tool', + content: { success, team_name: 'review-team' }, + isError: false, + timestamp: 101, + }, + namedAgent, + ...(agentResult ? [agentResult] : []), + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(buildMainModel(false).sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'named-agent-tool', + label: 'Review independently', + }), + ]) + expect(hasVisibleSessionActivity(buildMainModel(true))).toBe(false) + + const completedTeammate = buildMainModel(true, { + id: 'named-agent-result', + type: 'tool_result', + toolUseId: 'named-agent-tool', + content: { status: 'teammate_spawned', name: 'reviewer', team_name: 'review-team' }, + isError: false, + timestamp: 201, + }) + expect(hasVisibleSessionActivity(completedTeammate)).toBe(false) + }) + + it('lets a durable Team end close task scope when TeamDelete is absent from history', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'audit-team' }, timestamp: 100, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true }, isError: false, timestamp: 101, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Shared Team task' }, timestamp: 150, + }, + { + id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task', + input: { subject: 'Lead follow-up task' }, timestamp: 250, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['Lead follow-up task']) + }) + + it('uses a newer durable Team window after an older explicit TeamDelete', () => { + const messages: UIMessage[] = [ + { + id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete', + input: { team_name: 'old-team' }, timestamp: 90, + }, + { + id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete', + content: { success: true }, isError: false, timestamp: 91, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Compacted Team task' }, timestamp: 150, + }, + { + id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task', + input: { subject: 'After compacted Team' }, timestamp: 250, + }, + ] + + const model = buildMainSessionActivityModel({ + sessionId: 'session-1', + messages, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['After compacted Team']) + }) + it('uses successful Team lifecycle results to hide implicit member spawns without hiding ordinary Agents', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', diff --git a/desktop/src/components/activity/sessionActivityModel.ts b/desktop/src/components/activity/sessionActivityModel.ts index 403c09c9..2c616e6d 100644 --- a/desktop/src/components/activity/sessionActivityModel.ts +++ b/desktop/src/components/activity/sessionActivityModel.ts @@ -2,6 +2,11 @@ import type { BackgroundAgentTask, AgentTaskNotification, BackgroundAgentTaskUsa import type { TaskSummaryItem, UIMessage } from '../../types/chat' import type { CLITask, TaskStatus } from '../../types/cliTask' import type { TeamMember } from '../../types/team' +import { + EMPTY_TEAM_LIFECYCLE_CURSOR, + isTeamLifecycleScopedAt, + updateTeamLifecycleCursor, +} from '../../lib/teamLifecycleScope' import { createBackgroundTaskDismissKey, isVisibleSessionBackgroundTask, @@ -359,16 +364,6 @@ function projectMessagesToRun(messages: UIMessage[], runScope: 'session' | 'agen return runScope === 'agent' ? messages : messages.filter(keepSessionRunMessage) } -function isWithinTeamTaskWindow( - timestamp: number, - windows: Array<{ startedAt: number; endedAt?: number }>, -): boolean { - return windows.some((window) => ( - timestamp >= window.startedAt && - (window.endedAt === undefined || timestamp <= window.endedAt) - )) -} - function explicitSuccessFlag(value: unknown): boolean | undefined { if (Array.isArray(value)) { for (const item of value) { @@ -409,14 +404,24 @@ function projectMessagesToTaskScope( const sharedTaskToolUseIds = new Set() const resultsByToolUseId = collectToolResults(messages) - let transcriptTeamActive: boolean | undefined + let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR for (const message of messages) { if (taskScope === 'team-session' && message.type === 'tool_use') { const lifecycleSucceeded = teamLifecycleSucceeded( resultsByToolUseId.get(message.toolUseId), ) - if (message.toolName === 'TeamCreate' && lifecycleSucceeded) transcriptTeamActive = true - if (message.toolName === 'TeamDelete' && lifecycleSucceeded) transcriptTeamActive = false + if (message.toolName === 'TeamCreate' && lifecycleSucceeded) { + transcriptTeamCursor = updateTeamLifecycleCursor( + true, + message.timestamp, + ) + } + if (message.toolName === 'TeamDelete' && lifecycleSucceeded) { + transcriptTeamCursor = updateTeamLifecycleCursor( + false, + message.timestamp, + ) + } } if ( message.type === 'tool_use' && @@ -424,9 +429,11 @@ function projectMessagesToTaskScope( ( taskScope === 'team' || ( - transcriptTeamActive === undefined - ? isWithinTeamTaskWindow(message.timestamp, teamTaskWindows) - : transcriptTeamActive + isTeamLifecycleScopedAt( + message.timestamp, + transcriptTeamCursor, + teamTaskWindows, + ) ) ) ) { @@ -635,6 +642,7 @@ function updatedActiveTeamName( if (!output) return undefined if (toolCall.toolName === 'TeamCreate') { + if (output.success === false) return undefined return stringField(output, 'team_name') || undefined } if (toolCall.toolName === 'TeamDelete' && output.success === true) { @@ -652,25 +660,30 @@ function teamSpawnIdentity( toolCall: Extract, result: Extract | undefined, activeTeamName: string | undefined, + teamScoped: boolean, ): TeamSpawnIdentity | null { const input = isRecordValue(toolCall.input) ? toolCall.input : {} const inputMemberName = stringField(input, 'name') - if (!inputMemberName) return null - const inputTeamName = stringField(input, 'team_name') - if (inputTeamName || activeTeamName) { + if (inputMemberName && (inputTeamName || teamScoped)) { return { memberName: inputMemberName, - teamName: inputTeamName || activeTeamName, + ...((inputTeamName || (teamScoped && activeTeamName)) + ? { teamName: inputTeamName || activeTeamName } + : {}), } } const output = parseToolResultRecord(result) if (stringField(output ?? {}, 'status') === 'teammate_spawned') { + const memberName = stringField(output ?? {}, 'name') || inputMemberName + if (!memberName) return null const outputTeamName = stringField(output ?? {}, 'team_name') return { - memberName: stringField(output ?? {}, 'name') || inputMemberName, - ...(outputTeamName ? { teamName: outputTeamName } : {}), + memberName, + ...((outputTeamName || activeTeamName) + ? { teamName: outputTeamName || activeTeamName } + : {}), } } @@ -689,7 +702,10 @@ function teamSpawnIdentity( } } -function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] { +function buildAgentRowsFromMessages( + messages: UIMessage[], + teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>, +): ActivityRow[] { const resultsByToolUseId = new Map>() const toolCallsByToolUseId = new Map>() for (const message of messages) { @@ -702,6 +718,7 @@ function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] { const rows: ActivityRow[] = [] let activeTeamName: string | undefined + let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR for (const message of messages) { if (message.type === 'tool_result') { const toolCall = toolCallsByToolUseId.get(message.toolUseId) @@ -711,11 +728,32 @@ function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] { } continue } - if (message.type !== 'tool_use' || message.toolName !== 'Agent') continue + if (message.type !== 'tool_use') continue const result = resultsByToolUseId.get(message.toolUseId) + const lifecycleSucceeded = teamLifecycleSucceeded(result) + if (message.toolName === 'TeamCreate' && lifecycleSucceeded) { + transcriptTeamCursor = updateTeamLifecycleCursor(true, message.timestamp) + } else if (message.toolName === 'TeamDelete' && lifecycleSucceeded) { + transcriptTeamCursor = updateTeamLifecycleCursor(false, message.timestamp) + } + if (message.toolName !== 'Agent') continue + const resultText = result ? stripAgentMetadata(extractTextContent(result.content)) : '' - const teamIdentity = teamSpawnIdentity(message, result, activeTeamName) + const teamIdentity = teamSpawnIdentity( + message, + result, + activeTeamName, + isTeamLifecycleScopedAt( + message.timestamp, + transcriptTeamCursor, + teamTaskWindows, + ), + ) + // Agent inputs stream before their ownership fields. Until the input is + // complete, treating an unknown owner as direct makes Team members flash + // through the main session's SubAgents section. + if (message.isPending && !teamIdentity) continue if (teamIdentity) { rows.push({ id: message.toolUseId, @@ -1169,7 +1207,7 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput) ]).filter((name): name is string => Boolean(name)), ) const teamLaunchRowsByMember = new Map() - for (const row of buildAgentRowsFromMessages(runMessages)) { + for (const row of buildAgentRowsFromMessages(runMessages, input.teamTaskWindows ?? [])) { if (row.section === 'team') { if (!includeTeamActivity) continue if (row.teamMemberName && knownTeamMemberNames.has(row.teamMemberName)) continue diff --git a/desktop/src/components/agentTeams/AgentTeamsSummary.tsx b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx index 19d08bcf..8be8c423 100644 --- a/desktop/src/components/agentTeams/AgentTeamsSummary.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx @@ -1,4 +1,5 @@ import { ChevronRight, UsersRound } from 'lucide-react' +import type { ReactNode } from 'react' import { Badge, StatusDot, type Tone } from '@/components/ui/Badge' import { useTranslation, type TranslationKey } from '../../i18n' import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team' @@ -126,15 +127,24 @@ export function AgentTeamsStrip({ */ export function AgentTeamsInlineCard({ snapshot, + teamName, + fallbackPhase = 'forming', + phaseOverride, onOpen, + children, }: { - snapshot: TeamWorkbenchSnapshot - onOpen: () => void + snapshot?: TeamWorkbenchSnapshot + teamName: string + fallbackPhase?: WorkbenchPhase + phaseOverride?: WorkbenchPhase + onOpen?: () => void + children?: ReactNode }) { const t = useTranslation() - const phase = getWorkbenchPhase(snapshot) - const progress = getWorkbenchProgress(snapshot) - const members = snapshot.team.members + const phase = phaseOverride ?? (snapshot ? getWorkbenchPhase(snapshot) : fallbackPhase) + const progress = snapshot ? getWorkbenchProgress(snapshot) : { completed: 0, total: 0 } + const members = snapshot?.team.members ?? [] + const canOpen = Boolean(snapshot && onOpen) return (
@@ -142,35 +152,47 @@ export function AgentTeamsInlineCard({ type="button" data-testid="agent-teams-inline-card" onClick={onOpen} + disabled={!canOpen} className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]" > - + {members.length > 0 ? ( + + ) : ( + + + )} - {snapshot.team.name} + {teamName} {t(`agentTeams.phase.${phase}` as TranslationKey)} - - {t('agentTeams.inline.created', { count: members.length })} - {progress.total > 0 ? ( - <> - - - {t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })} - - - ) : null} + {snapshot ? ( + + {t('agentTeams.inline.created', { count: members.length })} + {progress.total > 0 ? ( + <> + + + {t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })} + + + ) : null} + + ) : null} + + {canOpen ? ( + + {t('agentTeams.inline.open')} + - - - {t('agentTeams.inline.open')} - + ) : null} + {children}
) } diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx index 069084be..24d07017 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' @@ -246,6 +246,57 @@ describe('AgentTeamsWorkbench', () => { expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team') }) + it('replays a shrinking archived roster without orphaning owners or hanging exited observers under the lead', async () => { + const beforeRemoval = workbench('roster-1', ['in_progress', 'in_progress', 'pending']) + beforeRemoval.team.members.push({ + agentId: 'observer@visual-team', + name: 'observer', + role: 'researcher', + status: 'idle', + }) + const afterRemoval: TeamWorkbenchSnapshot = { + ...workbench('roster-2', ['completed', 'completed', 'completed']), + deletedAt: '2026-08-08T00:10:00.000Z', + team: { + ...beforeRemoval.team, + members: beforeRemoval.team.members + .filter((member) => member.name !== 'builder') + .map((member) => ({ ...member, status: 'completed' as const })), + }, + tasks: workbench('roster-2', ['completed', 'completed', 'completed']).tasks.map((entry) => ( + entry.id === '3' ? { ...entry, owner: 'legacy-owner' } : entry + )), + } + getWorkbenchForSessionMock.mockResolvedValueOnce({ + sessionId: 'lead-session', + teamName: 'visual-team', + source: 'archive', + snapshots: [beforeRemoval, afterRemoval], + }) + + await act(async () => { + await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true }) + }) + render() + + const restoredOwnerTask = screen.getByTestId('agent-teams-task-1') + expect(restoredOwnerTask.getAttribute('data-owner-agent-id')).toBe('builder@visual-team') + expect(within(restoredOwnerTask).getByText('builder')).toBeTruthy() + fireEvent.click(restoredOwnerTask) + expect(useTabStore.getState().activeTabId).toBe('team-member:builder@visual-team') + + const rawOwnerTask = screen.getByTestId('agent-teams-task-3') + expect(within(rawOwnerTask).getByText('legacy-owner')).toBeTruthy() + expect(within(rawOwnerTask).queryByText('Waiting for a teammate')).toBeNull() + + const archivedRoster = screen.getByTestId('agent-teams-archived-members') + const observer = screen.getByTestId('agent-teams-member-observer@visual-team') + expect(archivedRoster.contains(observer)).toBe(true) + expect(observer.getAttribute('data-member-state')).toBe('exited') + fireEvent.click(observer) + expect(useTabStore.getState().activeTabId).toBe('team-member:observer@visual-team') + }) + it('carries no docked-panel chrome of its own', async () => { getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) await act(async () => { @@ -261,6 +312,29 @@ describe('AgentTeamsWorkbench', () => { expect(feed.className).not.toContain('h-[210px]') }) + it('never describes an ownerless completed task as waiting to be claimed', async () => { + const snapshot = workbench('v1', ['completed', 'pending', 'completed']) + snapshot.tasks = snapshot.tasks.map((entry) => ( + entry.id === '1' || entry.id === '2' + ? { ...entry, owner: undefined } + : entry + )) + getWorkbenchMock.mockResolvedValueOnce(snapshot) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + + render() + + const completed = screen.getByTestId('agent-teams-task-1') + expect(within(completed).getByText('Completed · owner not recorded')).toBeTruthy() + expect(within(completed).queryByText('Waiting for a teammate')).toBeNull() + + const available = screen.getByTestId('agent-teams-task-2') + expect(within(available).getByText('Waiting for a teammate')).toBeTruthy() + expect(within(available).queryByText('Completed · owner not recorded')).toBeNull() + }) + it('resizes the communication column by pointer and keyboard without collapsing the DAG', async () => { getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) await act(async () => { @@ -306,4 +380,45 @@ describe('AgentTeamsWorkbench', () => { fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team')) expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team') }) + + it('keeps a crowded archived roster in a scrollable narrow lane with touch-sized targets', async () => { + const archived = workbench('v1', ['completed', 'completed', 'completed']) + archived.deletedAt = '2026-08-08T00:10:00.000Z' + archived.team.members = [ + ...archived.team.members.map((member) => ({ + ...member, + status: 'completed' as const, + })), + ...Array.from({ length: 12 }, (_, index) => ({ + agentId: `observer-${index}@visual-team`, + name: `observer-${index}`, + role: 'observer', + status: 'completed' as const, + })), + ] + getWorkbenchMock.mockResolvedValueOnce(archived) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + + render() + + const archivedRoster = screen.getByTestId('agent-teams-archived-members') + expect(archivedRoster.className).toContain('left-3') + expect(archivedRoster.className).toContain('right-3') + expect(archivedRoster.className).toContain('h-16') + expect(archivedRoster.className).toContain('overflow-x-auto') + expect(archivedRoster.className).toContain('overflow-y-hidden') + + const observers = screen.getAllByTestId(/^agent-teams-member-observer-/) + expect(observers).toHaveLength(12) + for (const observer of observers) { + expect(observer.className).toContain('h-11') + expect(observer.className).toContain('w-11') + expect(observer.className).toContain('shrink-0') + } + + fireEvent.click(observers.at(-1)!) + expect(useTabStore.getState().activeTabId).toBe('team-member:observer-11@visual-team') + }) }) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx index a18b78ec..bd76e7f8 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx @@ -27,6 +27,7 @@ import { getMemberAvatarKey, layoutWorkbenchTasks, runningTaskForMember, + snapshotWithHistoricalMembers, taskOwnedByMember, WORKBENCH_TASK_HEIGHT, WORKBENCH_TASK_WIDTH, @@ -153,8 +154,14 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { const snapshots = timeline?.snapshots ?? [] const latestIndex = snapshots.length - 1 const selectedIndex = historyIndex === null ? latestIndex : Math.min(historyIndex, latestIndex) - const snapshot = selectedIndex >= 0 ? snapshots[selectedIndex] : undefined - const previousSnapshot = selectedIndex > 0 ? snapshots[selectedIndex - 1] : undefined + const snapshot = useMemo( + () => snapshotWithHistoricalMembers(snapshots, selectedIndex), + [selectedIndex, snapshots], + ) + const previousSnapshot = useMemo( + () => snapshotWithHistoricalMembers(snapshots, selectedIndex - 1), + [selectedIndex, snapshots], + ) useEffect(() => { const element = officeViewportRef.current @@ -263,6 +270,12 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { if (firstOwnedTask) primaryTaskByMemberId.set(member.agentId, firstOwnedTask.id) } const unassignedMembers = workerMembers.filter((member) => !primaryTaskByMemberId.has(member.agentId)) + const archivedUnassignedMembers = unassignedMembers.filter((member) => ( + memberState(member, snapshot, false) === 'exited' + )) + const activeUnassignedMembers = unassignedMembers.filter((member) => ( + memberState(member, snapshot, false) !== 'exited' + )) const rootTasks = layout.tasks.filter(({ task }) => task.blockedBy.every((dependencyId) => !layout.byId.has(dependencyId)), ) @@ -284,9 +297,10 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { state, }) } else { - const unassignedIndex = unassignedMembers.findIndex((worker) => worker.agentId === member.agentId) - const pitch = Math.min(62, (layout.width - 48) / Math.max(unassignedMembers.length, 1)) - const rowWidth = Math.max(0, (unassignedMembers.length - 1) * pitch) + const unassignedIndex = activeUnassignedMembers.findIndex((worker) => worker.agentId === member.agentId) + if (unassignedIndex < 0) return + const pitch = Math.min(62, (layout.width - 48) / Math.max(activeUnassignedMembers.length, 1)) + const rowWidth = Math.max(0, (activeUnassignedMembers.length - 1) * pitch) memberPositions.set(member.agentId, { x: layout.width / 2 - rowWidth / 2 + Math.max(0, unassignedIndex) * pitch, y: 147, @@ -447,7 +461,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { /> ) : null} - {unassignedMembers.map((member) => { + {activeUnassignedMembers.map((member) => { const position = memberPositions.get(member.agentId) if (!position) return null return ( @@ -464,6 +478,32 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { ) })} + {archivedUnassignedMembers.length > 0 ? ( +
+ + {t('agentTeams.member.exited')} + + {archivedUnassignedMembers.map((member) => ( + selectMember(member)} + t={t} + /> + ))} +
+ ) : null} + {layout.tasks.length === 0 ? (
{t('agentTeams.emptyTasks')} @@ -825,6 +865,7 @@ function TaskCard({ const owner = task.owner ? members.find((member) => taskOwnedByMember(task, member)) : undefined + const ownerLabel = owner ? memberName(owner) : task.owner?.trim() const dependencyLabel = task.blockedBy.map((dependency) => `#${dependency}`).join(' ') const ownerState = owner ? memberState(owner, snapshot, owner.agentId === snapshot.team.leadAgentId) @@ -842,6 +883,7 @@ function TaskCard({ data-testid={`agent-teams-task-${task.id}`} data-state={state} data-owner-agent-id={owner?.agentId} + data-owner-identity={task.owner} tabIndex={0} role={owner ? 'button' : undefined} aria-label={owner ? t('agentTeams.openMember', { name: memberName(owner) }) : undefined} @@ -896,9 +938,13 @@ function TaskCard({ {task.subject}
- {owner ? ( + {ownerLabel ? ( - {memberName(owner)} + {ownerLabel} + + ) : state === 'completed' ? ( + + {t('agentTeams.task.completedNoOwner')} ) : ( diff --git a/desktop/src/components/agentTeams/agentTeamsModel.test.ts b/desktop/src/components/agentTeams/agentTeamsModel.test.ts index 00284bb5..4ad0a374 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.test.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.test.ts @@ -10,6 +10,7 @@ import { parseWorkbenchMessageBody, resolveTeamMemberIdentity, runningTaskForMember, + snapshotWithHistoricalMembers, taskOwnedByMember, WORKBENCH_TASK_WIDTH, } from './agentTeamsModel' @@ -165,6 +166,57 @@ describe('Agent Teams workbench model', () => { expect(getMemberAvatarKey(reviewer.member, reviewer.isLead)).toBe('security-reviewer') }) + it('replays roster transitions so shutdown cannot orphan a completed task owner', () => { + const lead: TeamMember = { + agentId: 'team-lead@team-a', + name: 'team-lead', + role: 'orchestrator', + status: 'running', + } + const owner: TeamMember = { + agentId: 'builder@team-a', + name: 'builder', + role: 'frontend', + status: 'running', + } + const observer: TeamMember = { + agentId: 'observer@team-a', + name: 'observer', + role: 'reviewer', + status: 'idle', + } + const beforeRemoval = { + ...snapshot([task('1', 'in_progress', [], 'builder')]), + version: 'roster-1', + team: { ...snapshot([]).team, members: [lead, owner, observer] }, + } + const afterRemoval = { + ...snapshot([task('1', 'completed', [], 'builder')]), + version: 'roster-2', + deletedAt: '2026-08-08T00:01:00.000Z', + team: { + ...snapshot([]).team, + members: [ + { ...lead, status: 'completed' as const }, + { ...observer, status: 'completed' as const }, + ], + }, + } + + const repaired = snapshotWithHistoricalMembers([beforeRemoval, afterRemoval], 1)! + const repairedOwner = repaired.team.members.find(member => taskOwnedByMember(repaired.tasks[0]!, member)) + + expect(repaired.team.members.map(member => ({ + name: member.name, + status: member.status, + }))).toEqual([ + { name: 'team-lead', status: 'completed' }, + { name: 'builder', status: 'completed' }, + { name: 'observer', status: 'completed' }, + ]) + expect(repairedOwner?.agentId).toBe('builder@team-a') + }) + it('derives forming, running, finishing, and completed phases from real transitions', () => { expect(getWorkbenchPhase(snapshot([]))).toBe('forming') expect(getWorkbenchPhase(snapshot([task('1', 'in_progress')]))).toBe('running') diff --git a/desktop/src/components/agentTeams/agentTeamsModel.ts b/desktop/src/components/agentTeams/agentTeamsModel.ts index 028d4978..173ddf11 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.ts @@ -81,6 +81,56 @@ function identityAliases(value: string): string[] { return normalized === short ? [normalized] : [normalized, short] } +function sameTeamMember(left: TeamMember, right: TeamMember): boolean { + const leftAliases = [ + ...identityAliases(left.agentId), + ...(left.name ? identityAliases(left.name) : []), + ] + const rightAliases = [ + ...identityAliases(right.agentId), + ...(right.name ? identityAliases(right.name) : []), + ] + return leftAliases.some(alias => rightAliases.includes(alias)) +} + +/** + * Team config is a mutable live roster, while the workbench is a run history. + * Rebuild the roster by replaying snapshots so a teammate removed during + * shutdown still owns its completed task and can reopen its transcript. + */ +export function snapshotWithHistoricalMembers( + snapshots: TeamWorkbenchSnapshot[], + selectedIndex: number, +): TeamWorkbenchSnapshot | undefined { + const selected = snapshots[selectedIndex] + if (!selected) return undefined + + let members: TeamMember[] = [] + for (const snapshot of snapshots.slice(0, selectedIndex + 1)) { + const remaining = [...snapshot.team.members] + members = members.map((historicalMember) => { + const currentIndex = remaining.findIndex(member => sameTeamMember(historicalMember, member)) + if (currentIndex < 0) { + return { + ...historicalMember, + status: historicalMember.status === 'error' ? 'error' : 'completed', + } + } + const [currentMember] = remaining.splice(currentIndex, 1) + return { ...historicalMember, ...currentMember! } + }) + members.push(...remaining) + } + + return { + ...selected, + team: { + ...selected.team, + members, + }, + } +} + /** * Resolves the same persisted teammate identity for the DAG, communication * feed, and member transcript. A sender can be serialized as either its bare @@ -194,7 +244,10 @@ export function layoutWorkbenchTasks( tasks: positioned, byId: new Map(positioned.map((task) => [task.task.id, task])), width, - height: DAG_TOP + Math.max(0, row - 1) * ROW_HEIGHT + TASK_HEIGHT + 54, + // Leave enough room for a 44px historical-member target plus a classic + // horizontal scrollbar, which consumes layout height on Windows and on + // macOS when scrollbars are configured to stay visible. + height: DAG_TOP + Math.max(0, row - 1) * ROW_HEIGHT + TASK_HEIGHT + 70, columns, } } diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 424e091e..f21efe16 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, @@ -17,6 +17,7 @@ import type { ConversationNavigationItem } from './ConversationNavigator' import type { VirtualRenderItemMetric } from './virtualHeightCache' import { relativizeWorkspacePath } from './CurrentTurnChangeCard' import { sessionsApi } from '../../api/sessions' +import { teamsApi } from '../../api/teams' import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' @@ -24,6 +25,7 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' +import { useTeamStore } from '../../stores/teamStore' import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp' import type { UIMessage } from '../../types/chat' import type { PerSessionState } from '../../stores/chatStore' @@ -302,6 +304,7 @@ describe('MessageList nested tool calls', () => { useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } }) + useTeamStore.getState().clearTeam() useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true) // The workspace panel store is a shared singleton; reset it so preview tabs opened by // one test (clicking a change-card row) don't dedupe/leak into the next test. @@ -319,6 +322,10 @@ describe('MessageList nested tool calls', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('windows long transcripts instead of mounting every historical message at once', () => { useChatStore.setState({ sessions: { @@ -7253,6 +7260,12 @@ describe('turn rail positions', () => { }) describe('Agent Teams chat projection', () => { + function renderedToolUseIds(messages: UIMessage[], options?: Parameters[2]) { + return buildRenderModel(messages, null, options).renderItems.flatMap((item) => ( + item.kind === 'tool_group' ? item.toolCalls.map((toolCall) => toolCall.toolUseId) : [] + )) + } + function sendMessageRun( id: string, result: unknown, @@ -7289,10 +7302,14 @@ describe('Agent Teams chat projection', () => { }) expect(buildRenderModel(messages).renderItems).toHaveLength(1) - expect(buildRenderModel(messages, null, { + const lead = buildRenderModel(messages, null, { hideTeamCoordinationTools: true, teamMemberNames: new Set(['worker']), - }).renderItems).toHaveLength(0) + teamName: 'audit-team', + }) + expect(lead.renderItems).toEqual([ + expect.objectContaining({ kind: 'team_card', coordinationToolCalls: [messages[0]] }), + ]) }) it('hides successful team messages from their real input shape when the transport omits routing', () => { @@ -7303,10 +7320,14 @@ describe('Agent Teams chat projection', () => { }) const messages = [...direct, ...broadcast] - expect(buildRenderModel(messages, null, { + const lead = buildRenderModel(messages, null, { hideTeamCoordinationTools: true, teamMemberNames: new Set(['worker']), - }).renderItems).toHaveLength(0) + teamName: 'audit-team', + }) + expect(lead.renderItems).toEqual([ + expect.objectContaining({ kind: 'team_card', coordinationToolCalls: [direct[0], broadcast[0]] }), + ]) }) it('keeps ordinary agent continuation and failed team SendMessage calls visible', () => { @@ -7329,6 +7350,75 @@ describe('Agent Teams chat projection', () => { }).renderItems).toHaveLength(1) }) + it('stops using a historical Team roster after delete when a same-name direct Agent is messaged', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'audit-team' }, timestamp: 1, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true, team_name: 'audit-team' }, isError: false, timestamp: 2, + }, + { + id: 'team-message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'team-message', + input: { to: 'reviewer', message: 'Finish the Team review' }, timestamp: 3, + }, + { + id: 'team-message-result', type: 'tool_result', toolUseId: 'team-message', + content: 'Message sent to reviewer inbox', isError: false, timestamp: 4, + }, + { + id: 'delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'delete', + input: {}, timestamp: 5, + }, + { + id: 'delete-result', type: 'tool_result', toolUseId: 'delete', + content: { success: true, team_name: 'audit-team' }, isError: false, timestamp: 6, + }, + { + id: 'direct-agent-tool', type: 'tool_use', toolName: 'Agent', toolUseId: 'direct-agent', + input: { name: 'reviewer', description: 'Continue as an ordinary SubAgent' }, timestamp: 7, + }, + { + id: 'direct-agent-result', type: 'tool_result', toolUseId: 'direct-agent', + content: { status: 'completed', agentId: 'direct-reviewer' }, isError: false, timestamp: 8, + }, + { + id: 'direct-message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'direct-message', + input: { to: 'reviewer', message: 'Continue the direct review' }, timestamp: 9, + }, + { + id: 'direct-message-result', type: 'tool_result', toolUseId: 'direct-message', + content: 'Message queued for delivery to reviewer at its next tool round.', + isError: false, + timestamp: 10, + }, + ] + + const options = { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['reviewer']), + teamName: 'audit-team', + } + const model = buildRenderModel(messages, null, options) + const teamCards = model.renderItems.filter((item) => item.kind === 'team_card') + + expect(teamCards).toHaveLength(1) + expect(teamCards[0]).toMatchObject({ + teamName: 'audit-team', + endedAt: 5, + coordinationToolCalls: [ + expect.objectContaining({ toolUseId: 'team-message' }), + expect.objectContaining({ toolUseId: 'delete' }), + ], + }) + expect(renderedToolUseIds(messages, options)).toEqual([ + 'direct-agent', + 'direct-message', + ]) + }) + it('replaces the TeamCreate call with a team card at the point the team was formed', () => { const messages: UIMessage[] = [ { id: 'user-1', type: 'user_text', content: 'Audit the queue', timestamp: 1 }, @@ -7362,4 +7452,560 @@ describe('Agent Teams chat projection', () => { expect(buildRenderModel(messages).renderItems.map((item) => item.kind)) .toEqual(['message', 'tool_group', 'message']) }) + + it('projects team orchestration into the team card without hiding ordinary tools', () => { + const tool = ( + id: string, + toolName: string, + input: unknown, + timestamp: number, + ): UIMessage => ({ + id: `tool-${id}`, + type: 'tool_use', + toolName, + toolUseId: id, + input, + timestamp, + }) + const result = ( + id: string, + content: unknown, + timestamp: number, + isError = false, + ): UIMessage => ({ + id: `result-${id}`, + type: 'tool_result', + toolUseId: id, + content, + isError, + timestamp, + }) + const messages: UIMessage[] = [ + tool('pre-task', 'TaskCreate', { subject: 'Operator todo' }, 1), + result('pre-task', 'Task #1 created successfully', 2), + tool('create-team', 'TeamCreate', { team_name: 'audit-team' }, 3), + result('create-team', { team_name: 'audit-team' }, 4), + tool('team-task', 'TaskCreate', { subject: 'Review API' }, 5), + result('team-task', 'Task #1 created successfully', 6), + tool('team-update', 'TaskUpdate', { taskId: '1', owner: 'reviewer' }, 7), + result('team-update', 'Task #1 updated successfully', 8), + tool('team-agent', 'Agent', { + description: 'Review API', + name: 'reviewer', + team_name: 'audit-team', + }, 9), + result('team-agent', { status: 'teammate_spawned', name: 'reviewer', team_name: 'audit-team' }, 10), + tool('direct-agent', 'Agent', { description: 'Check an unrelated question' }, 11), + result('direct-agent', 'agentId: direct-1', 12), + tool('team-message', 'SendMessage', { to: 'reviewer', message: 'Start task #1' }, 13), + result('team-message', 'Message sent to reviewer inbox', 14), + tool('failed-update', 'TaskUpdate', { taskId: 'missing', status: 'completed' }, 15), + result('failed-update', 'Task missing not found', 16, true), + tool('failed-team-agent', 'Agent', { + description: 'Spawn missing teammate', + name: 'missing', + team_name: 'audit-team', + }, 17), + result('failed-team-agent', 'Agent spawn failed', 18, true), + tool('delete-team', 'TeamDelete', {}, 19), + result('delete-team', { success: true, team_name: 'audit-team' }, 20), + tool('post-task', 'TaskCreate', { subject: 'Operator follow-up' }, 21), + result('post-task', 'Task #2 created successfully', 22), + tool('post-agent', 'Agent', { description: 'Summarize the follow-up' }, 23), + result('post-agent', 'agentId: direct-2', 24), + ] + + const model = buildRenderModel(messages, null, { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['reviewer']), + }) + + expect(renderedToolUseIds(messages, { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['reviewer']), + })).toEqual([ + 'pre-task', + 'direct-agent', + 'failed-update', + 'failed-team-agent', + 'post-task', + 'post-agent', + ]) + const teamCards = model.renderItems.filter((item) => item.kind === 'team_card') + expect(teamCards).toHaveLength(1) + expect(teamCards[0]).toMatchObject({ + teamName: 'audit-team', + endedAt: 19, + coordinationToolCalls: [ + expect.objectContaining({ toolUseId: 'team-task' }), + expect.objectContaining({ toolUseId: 'team-update' }), + expect.objectContaining({ toolUseId: 'team-agent' }), + expect.objectContaining({ toolUseId: 'team-message' }), + expect.objectContaining({ toolUseId: 'delete-team' }), + ], + }) + // Projection removes duplicate UI only. The raw audit result remains + // available to the transcript/store and the workbench owns its presentation. + expect(model.toolResultMap.get('team-task')?.content).toBe('Task #1 created successfully') + expect(messages).toHaveLength(24) + }) + + it('does not enter Team scope when TeamCreate returns success false', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'failed-team' }, timestamp: 1, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: false, error: 'already exists' }, isError: false, timestamp: 2, + }, + { + id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'task', + input: { subject: 'Main-session task' }, timestamp: 3, + }, + ] + + expect(renderedToolUseIds(messages, { + hideTeamCoordinationTools: true, + })).toEqual(['create', 'task']) + expect(buildRenderModel(messages, null, { + hideTeamCoordinationTools: true, + }).renderItems.some(item => item.kind === 'team_card')).toBe(false) + }) + + it('keeps Team scope active when TeamDelete returns success false', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'audit-team' }, timestamp: 1, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true }, isError: false, timestamp: 2, + }, + { + id: 'delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'delete', + input: {}, timestamp: 3, + }, + { + id: 'delete-result', type: 'tool_result', toolUseId: 'delete', + content: { success: false, team_name: 'audit-team', error: 'still running' }, isError: false, timestamp: 4, + }, + { + id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'task', + input: { subject: 'Shared Team task' }, timestamp: 5, + }, + ] + + expect(renderedToolUseIds(messages, { + hideTeamCoordinationTools: true, + })).toEqual(['delete']) + }) + + it('never renders an identity-unknown pending Agent as a SubAgent during a team lifecycle', () => { + vi.useFakeTimers() + const sessionId = 'team-transcript-stream' + useChatStore.setState({ + sessions: { [sessionId]: makeSessionState() }, + }) + const store = useChatStore.getState() + const renderedIds = () => renderedToolUseIds( + useChatStore.getState().sessions[sessionId]?.messages ?? [], + { hideTeamCoordinationTools: true }, + ) + + store.handleServerMessage(sessionId, { + type: 'content_start', blockType: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create-team', + }) + store.handleServerMessage(sessionId, { + type: 'tool_use_complete', + toolName: 'TeamCreate', + toolUseId: 'create-team', + input: { team_name: 'audit-team' }, + }) + store.handleServerMessage(sessionId, { + type: 'tool_result', + toolUseId: 'create-team', + content: { team_name: 'audit-team' }, + isError: false, + }) + + store.handleServerMessage(sessionId, { + type: 'content_start', blockType: 'tool_use', toolName: 'Agent', toolUseId: 'team-agent', + }) + store.handleServerMessage(sessionId, { + type: 'content_delta', toolInput: '{"description":"Review API","name":', + }) + vi.advanceTimersByTime(60) + + expect(useChatStore.getState().sessions[sessionId]?.messages).toContainEqual( + expect.objectContaining({ + type: 'tool_use', + toolUseId: 'team-agent', + input: { description: 'Review API' }, + isPending: true, + }), + ) + expect(renderedIds()).toEqual([]) + + store.handleServerMessage(sessionId, { + type: 'tool_use_complete', + toolName: 'Agent', + toolUseId: 'team-agent', + input: { description: 'Review API', name: 'reviewer', team_name: 'audit-team' }, + }) + store.handleServerMessage(sessionId, { + type: 'tool_result', + toolUseId: 'team-agent', + content: { status: 'teammate_spawned', name: 'reviewer', team_name: 'audit-team' }, + isError: false, + }) + expect(renderedIds()).toEqual([]) + + store.handleServerMessage(sessionId, { + type: 'content_start', blockType: 'tool_use', toolName: 'Agent', toolUseId: 'direct-agent', + }) + store.handleServerMessage(sessionId, { + type: 'content_delta', toolInput: '{"description":"Check an unrelated question"', + }) + vi.advanceTimersByTime(60) + expect(renderedIds()).toEqual([]) + + store.handleServerMessage(sessionId, { + type: 'tool_use_complete', + toolName: 'Agent', + toolUseId: 'direct-agent', + input: { description: 'Check an unrelated question' }, + }) + expect(renderedIds()).toEqual(['direct-agent']) + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('uses a team lifecycle window only when compacted history omits TeamCreate', () => { + const messages: UIMessage[] = [ + { + id: 'pre-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'pre-task', + input: { subject: 'Before team' }, timestamp: 90, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Team task' }, timestamp: 150, + }, + { + id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task', + input: { subject: 'After team' }, timestamp: 210, + }, + ] + + expect(renderedToolUseIds(messages, { + hideTeamCoordinationTools: true, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + teamName: 'audit-team', + })).toEqual(['pre-task', 'post-task']) + expect(renderedToolUseIds(messages)).toEqual(['pre-task', 'team-task', 'post-task']) + }) + + it('lets a durable end close an explicit TeamCreate when TeamDelete was compacted', () => { + const messages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'audit-team' }, timestamp: 100, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true }, isError: false, timestamp: 101, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Inside lifecycle' }, timestamp: 150, + }, + { + id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task', + input: { subject: 'After lifecycle' }, timestamp: 250, + }, + ] + + expect(renderedToolUseIds(messages, { + hideTeamCoordinationTools: true, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + teamName: 'audit-team', + })).toEqual(['post-task']) + }) + + it('uses a newer durable Team window after an older explicit TeamDelete', () => { + const messages: UIMessage[] = [ + { + id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete', + input: {}, timestamp: 90, + }, + { + id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete', + content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 91, + }, + { + id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Compacted new Team task' }, timestamp: 150, + }, + { + id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task', + input: { subject: 'After compacted Team' }, timestamp: 250, + }, + ] + + const options = { + hideTeamCoordinationTools: true, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + teamName: 'audit-team', + teamStartedAt: 100, + } + expect(renderedToolUseIds(messages, options)).toEqual(['post-task']) + expect(buildRenderModel(messages, null, options).renderItems.filter( + (item) => item.kind === 'team_card', + )).toEqual([ + expect.objectContaining({ + teamName: 'old-team', + endedAt: 90, + coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-delete' })], + }), + expect.objectContaining({ + teamName: 'audit-team', + startedAt: 100, + coordinationToolCalls: [expect.objectContaining({ toolUseId: 'team-task' })], + }), + ]) + }) + + it('starts a new audit card when a compacted Team lifecycle follows an explicit delete', () => { + const messages: UIMessage[] = [ + { + id: 'old-create', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'old-create', + input: { team_name: 'old-team' }, timestamp: 10, + }, + { + id: 'old-create-result', type: 'tool_result', toolUseId: 'old-create', + content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 11, + }, + { + id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete', + input: {}, timestamp: 20, + }, + { + id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete', + content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 21, + }, + { + id: 'new-team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'new-team-task', + input: { subject: 'Task from compacted new Team' }, timestamp: 150, + }, + ] + + const model = buildRenderModel(messages, null, { + hideTeamCoordinationTools: true, + teamTaskWindows: [{ startedAt: 100, endedAt: 200 }], + teamName: 'new-team', + teamStartedAt: 100, + }) + const cards = model.renderItems.filter((item) => item.kind === 'team_card') + + expect(cards).toHaveLength(2) + expect(cards[0]).toMatchObject({ + teamName: 'old-team', + startedAt: 10, + endedAt: 20, + coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-delete' })], + }) + expect(cards[1]).toMatchObject({ + teamName: 'new-team', + startedAt: 100, + coordinationToolCalls: [expect.objectContaining({ toolUseId: 'new-team-task' })], + }) + }) + + it('uses the successful TeamCreate identity and never substitutes the current Team for an explicit old scope', () => { + const createMessages: UIMessage[] = [ + { + id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create', + input: { team_name: 'requested-name' }, timestamp: 10, + }, + { + id: 'create-result', type: 'tool_result', toolUseId: 'create', + content: { success: true, team_name: 'requested-name-2' }, isError: false, timestamp: 11, + }, + ] + const createdCard = buildRenderModel(createMessages, null, { + hideTeamCoordinationTools: true, + }).renderItems.find((item) => item.kind === 'team_card') + expect(createdCard).toMatchObject({ + teamName: 'requested-name-2', + startedAt: 10, + }) + + const explicitOldScope = buildRenderModel([{ + id: 'old-agent', type: 'tool_use', toolName: 'Agent', toolUseId: 'old-agent', + input: { name: 'reviewer', team_name: 'old-team', description: 'Review the old run' }, + timestamp: 150, + }], null, { + hideTeamCoordinationTools: true, + teamName: 'new-team', + teamStartedAt: 200, + }).renderItems.find((item) => item.kind === 'team_card') + expect(explicitOldScope).toMatchObject({ + teamName: 'old-team', + startedAt: 150, + coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-agent' })], + }) + }) + + it('uses synchronous Team lifecycle ownership before the first workbench snapshot resolves', async () => { + let resolveWorkbench!: (snapshot: Awaited>) => void + vi.spyOn(teamsApi, 'get').mockImplementation(() => new Promise(() => {})) + vi.spyOn(teamsApi, 'getWorkbench').mockImplementation(() => new Promise((resolve) => { + resolveWorkbench = resolve + })) + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task', + input: { subject: 'Never flash this Team task' }, timestamp: 102, + }, + { + id: 'agent-tool', type: 'tool_use', toolName: 'Agent', toolUseId: 'team-agent', + input: { description: 'Never flash this teammate', name: 'reviewer' }, + timestamp: 103, isPending: true, + }, + { + id: 'message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'team-message', + input: { to: 'reviewer', message: 'Never flash this Team message' }, + timestamp: 104, isPending: false, + }, + ], + }), + }, + }) + + act(() => { + useTeamStore.getState().handleTeamCreated('audit-team', ACTIVE_TAB, { createdAt: 100 }) + }) + render() + + const pendingCard = screen.getByTestId('agent-teams-inline-card') + const audit = screen.getByTestId('agent-teams-coordination-audit') + expect((pendingCard as HTMLButtonElement).disabled).toBe(true) + expect(within(audit).getByText('Never flash this Team task')).toBeTruthy() + expect(within(audit).getByText(/Never flash this teammate/)).toBeTruthy() + expect(within(audit).getByText(/Never flash this Team message/)).toBeTruthy() + expect(screen.getAllByText('Never flash this Team task')).toHaveLength(1) + expect(screen.getAllByText(/Never flash this teammate/)).toHaveLength(1) + expect(screen.getAllByText(/Never flash this Team message/)).toHaveLength(1) + expect(useTeamStore.getState().workbenchesBySession[ACTIVE_TAB]).toBeUndefined() + + await act(async () => { + resolveWorkbench({ + version: 'snapshot-v1', + generatedAt: new Date(110).toISOString(), + team: { + name: 'audit-team', + leadSessionId: ACTIVE_TAB, + leadAgentId: 'team-lead@audit-team', + createdAt: '100', + members: [{ + agentId: 'team-lead@audit-team', name: 'team-lead', role: 'lead', status: 'running', + }], + }, + tasks: [], + messages: [], + }) + await Promise.resolve() + }) + + await waitFor(() => expect( + (screen.getByTestId('agent-teams-inline-card') as HTMLButtonElement).disabled, + ).toBe(false)) + expect(screen.getAllByText('Never flash this Team task')).toHaveLength(1) + expect(screen.getAllByText(/Never flash this teammate/)).toHaveLength(1) + expect(screen.getAllByText(/Never flash this Team message/)).toHaveLength(1) + }) + + it('never binds an older TeamCreate card to a newer incarnation with the same name', async () => { + let resolveWorkbench!: (snapshot: Awaited>) => void + vi.spyOn(teamsApi, 'get').mockImplementation(() => new Promise(() => {})) + vi.spyOn(teamsApi, 'getWorkbench').mockImplementation(() => new Promise((resolve) => { + resolveWorkbench = resolve + })) + const createRun = (id: string, timestamp: number): UIMessage[] => [ + { + id: `${id}-tool`, type: 'tool_use', toolName: 'TeamCreate', toolUseId: id, + input: { team_name: 'reused-team' }, timestamp, + }, + { + id: `${id}-result`, type: 'tool_result', toolUseId: id, + content: { success: true, team_name: 'reused-team' }, isError: false, timestamp: timestamp + 1, + }, + ] + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + ...createRun('old-create', 100_000), + { + id: 'old-delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete', + input: {}, timestamp: 101_000, + }, + { + id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete', + content: { success: true, team_name: 'reused-team' }, isError: false, timestamp: 101_001, + }, + // The next incarnation starts within the transport-tolerance + // window of the first. Its snapshot must still never revive the + // already-ended historical card. + ...createRun('new-create', 102_000), + ], + }), + }, + }) + + act(() => { + useTeamStore.getState().handleTeamCreated('reused-team', ACTIVE_TAB, { createdAt: 102_000 }) + }) + render() + + await act(async () => { + resolveWorkbench({ + version: 'new-incarnation', + generatedAt: new Date(102_010).toISOString(), + team: { + name: 'reused-team', + leadSessionId: ACTIVE_TAB, + leadAgentId: 'team-lead@reused-team', + incarnationId: 'new-incarnation', + createdAt: '102000', + members: [{ + agentId: 'team-lead@reused-team', name: 'team-lead', role: 'lead', status: 'running', + }], + }, + tasks: [], + messages: [], + }) + await Promise.resolve() + }) + + const cards = await screen.findAllByTestId('agent-teams-inline-card') + expect(cards).toHaveLength(2) + expect((cards[0] as HTMLButtonElement).disabled).toBe(true) + expect((cards[1] as HTMLButtonElement).disabled).toBe(false) + expect(within(cards[0]!).getByText('Completed')).toBeTruthy() + expect(within(cards[1]!).getByText('Forming team')).toBeTruthy() + + fireEvent.click(cards[1]!) + expect(useTabStore.getState().tabs).toContainEqual(expect.objectContaining({ + type: 'team', + title: 'reused-team', + teamLeadSessionId: ACTIVE_TAB, + })) + }) }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 9676f290..54da7d97 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -8,7 +8,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useWorkspacePanelStore, type WorkspacePanelOrigin } from '../../stores/workspacePanelStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' -import { useTeamStore } from '../../stores/teamStore' +import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' @@ -26,7 +26,11 @@ import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary' import { MEMBER_AVATARS, memberAccentColor } from '../agentTeams/agentTeamsAvatars' -import { getMemberAvatarKey, resolveTeamMemberIdentity } from '../agentTeams/agentTeamsModel' +import { + getMemberAvatarKey, + resolveTeamMemberIdentity, + snapshotWithHistoricalMembers, +} from '../agentTeams/agentTeamsModel' import { buildConversationNavigationItems, ConversationNavigator, @@ -34,11 +38,16 @@ import { type ConversationNavigationMode, } from './ConversationNavigator' import type { AgentTaskNotification, BackgroundAgentTask, UIMessage } from '../../types/chat' -import type { TeamDetail } from '../../types/team' +import type { TeamDetail, TeamWorkbenchSnapshot } from '../../types/team' import { formatTokenCount } from '../../lib/formatTokenCount' import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks' import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion' import { isTouchH5Document } from '../../lib/touchH5' +import { + EMPTY_TEAM_LIFECYCLE_CURSOR, + isTeamLifecycleScopedAt, + updateTeamLifecycleCursor, +} from '../../lib/teamLifecycleScope' import { Button } from '@/components/ui/Button' import { ActionDialog, type ActionDialogAction } from '@/components/ui/ActionDialog' import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' @@ -72,7 +81,14 @@ type RenderItem = * Stands in for the TeamCreate call so the transcript records that this turn * handed work to a team, without expanding into the workbench inline. */ - | { kind: 'team_card'; id: string } + | { + kind: 'team_card' + id: string + teamName: string + startedAt: number + endedAt?: number + coordinationToolCalls: ToolCall[] + } type RenderModel = { renderItems: RenderItem[] @@ -674,18 +690,105 @@ function isTeamCoordinationSendMessage( input: unknown, result: unknown, teamMemberNames: ReadonlySet | undefined, + allowRosterFallback: boolean, ): boolean { if (hasTeamMessageRouting(result)) return true + if (!allowRosterFallback) return false const target = getSendMessageTarget(input) return target === '*' || Boolean(target && teamMemberNames?.has(target)) } +function parseRecordValue(value: unknown): Record | null { + if (isRecordValue(value)) return value + if (typeof value !== 'string') return null + const text = value.trim() + if (!text.startsWith('{')) return null + try { + const parsed = JSON.parse(text) as unknown + return isRecordValue(parsed) ? parsed : null + } catch { + return null + } +} + +function nestedStringField(value: unknown, field: string): string { + if (Array.isArray(value)) { + for (const item of value) { + const nested = nestedStringField(item, field) + if (nested) return nested + } + return '' + } + const record = parseRecordValue(value) + if (!record) return '' + const direct = record[field] + if (typeof direct === 'string' && direct.trim()) return direct.trim() + for (const child of [record.content, record.text]) { + const nested = nestedStringField(child, field) + if (nested) return nested + } + return '' +} + +function explicitSuccessFlag(value: unknown): boolean | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nested = explicitSuccessFlag(item) + if (nested !== undefined) return nested + } + return undefined + } + const record = parseRecordValue(value) + if (!record) return undefined + if (typeof record.success === 'boolean') return record.success + if ('content' in record) return explicitSuccessFlag(record.content) + if ('text' in record) return explicitSuccessFlag(record.text) + return undefined +} + +function canSummarizeCoordinationResult(result: ToolResult | undefined): boolean { + return !result || (!result.isError && explicitSuccessFlag(result.content) !== false) +} + +function lifecycleToolSucceeded(result: ToolResult | undefined): boolean { + return Boolean(result) && canSummarizeCoordinationResult(result) +} + +function teamNameFromCreate(toolCall: ToolCall, result: ToolResult | undefined): string { + const input = parseRecordValue(toolCall.input) + // TeamCreate can uniquify a requested name. The successful result owns the + // durable identity that the workbench will expose. + for (const value of [nestedStringField(result?.content, 'team_name'), input?.team_name]) { + if (typeof value === 'string' && value.trim()) return value.trim() + } + return 'Agent Teams' +} + +function isExplicitTeamAgent( + input: unknown, + result: ToolResult | undefined, + teamActive: boolean, +): boolean { + const inputRecord = parseRecordValue(input) + const inputTeamName = inputRecord?.team_name + if (typeof inputTeamName === 'string' && inputTeamName.trim()) return true + const inputName = inputRecord?.name + if (teamActive && typeof inputName === 'string' && inputName.trim()) return true + + if (nestedStringField(result?.content, 'status') === 'teammate_spawned') return true + if (typeof result?.content !== 'string') return false + return /^\s*team_name:\s*\S+/m.test(result.content) && /^\s*name:\s*\S+/m.test(result.content) +} + export function buildRenderModel( messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null, options: { hideTeamCoordinationTools?: boolean teamMemberNames?: ReadonlySet + teamTaskWindows?: Array<{ startedAt: number; endedAt?: number }> + teamName?: string + teamStartedAt?: number } = {}, ): RenderModel { const items: RenderItem[] = [] @@ -694,6 +797,8 @@ export function buildRenderModel( const toolUseIds = new Set() const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map() let lastUnresolvedAskUserQuestionIndex: number | null = null + let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR + let activeTeamCard: Extract | undefined let pendingSteps: ActivityStep[] = [] let pendingToolCount = 0 let pendingAgentCount = 0 @@ -743,6 +848,40 @@ export function buildRenderModel( if (pendingToolCount > 0 && pendingAgentCount === pendingToolCount) flushGroup() pendingSteps.push({ kind: 'thinking', message }) } + const ensureTeamCardForCoordination = ( + message: ToolCall, + result: ToolResult | undefined, + ) => { + const input = parseRecordValue(message.input) + const outputTeamName = nestedStringField(result?.content, 'team_name') + const inputTeamName = typeof input?.team_name === 'string' + ? input.team_name.trim() + : '' + const explicitTeamName = outputTeamName || inputTeamName + if ( + activeTeamCard && + (!explicitTeamName || activeTeamCard.teamName === explicitTeamName) + ) return activeTeamCard + if (activeTeamCard) activeTeamCard = undefined + + const scopedTeamName = options.teamName?.trim() || '' + const teamName = explicitTeamName || scopedTeamName + if (!teamName) return undefined + const usesDurableScope = !explicitTeamName || explicitTeamName === scopedTeamName + + flushGroup() + activeTeamCard = { + kind: 'team_card', + id: `team-card-scope-${message.id}`, + teamName, + startedAt: usesDurableScope + ? options.teamStartedAt ?? message.timestamp + : message.timestamp, + coordinationToolCalls: [], + } + items.push(activeTeamCard) + return activeTeamCard + } for (const msg of messages) { if (msg.type === 'tool_use') { @@ -752,6 +891,11 @@ export function buildRenderModel( toolResultMap.set(msg.toolUseId, msg) } } + const hasTeamLifecycleEvidence = Boolean(options.teamTaskWindows?.length) || messages.some((msg) => ( + msg.type === 'tool_use' && + (msg.toolName === 'TeamCreate' || msg.toolName === 'TeamDelete') && + lifecycleToolSucceeded(toolResultMap.get(msg.toolUseId)) + )) messages.forEach((msg, index) => { if ( msg.type === 'tool_use' && @@ -785,19 +929,96 @@ export function buildRenderModel( continue } const toolResult = toolResultMap.get(msg.toolUseId) - if ( - options.hideTeamCoordinationTools && - msg.toolName === 'SendMessage' && - toolResult?.isError === false && - isTeamCoordinationSendMessage(msg.input, toolResult.content, options.teamMemberNames) - ) { - continue + let summarizedTeamDelete = false + if (options.hideTeamCoordinationTools) { + if (msg.toolName === 'TeamCreate' && lifecycleToolSucceeded(toolResult)) { + transcriptTeamCursor = updateTeamLifecycleCursor( + true, + msg.timestamp, + ) + } else if (msg.toolName === 'TeamDelete' && lifecycleToolSucceeded(toolResult)) { + transcriptTeamCursor = updateTeamLifecycleCursor( + false, + msg.timestamp, + ) + const deletedTeamCard = ensureTeamCardForCoordination(msg, toolResult) + if (deletedTeamCard) { + deletedTeamCard.endedAt = msg.timestamp + deletedTeamCard.coordinationToolCalls.push(msg) + summarizedTeamDelete = true + } + // A later lifecycle can be present only in the durable workbench + // window after transcript compaction. Do not attach that run's + // coordination audit to the card from the lifecycle just deleted. + activeTeamCard = undefined + } + + const isTeamScopedAtMessage = isTeamLifecycleScopedAt( + msg.timestamp, + transcriptTeamCursor, + options.teamTaskWindows, + ) + const isTeamTask = + (msg.toolName === 'TaskCreate' || msg.toolName === 'TaskUpdate') && + isTeamScopedAtMessage + const explicitTeamAgent = msg.toolName === 'Agent' && + isExplicitTeamAgent(msg.input, toolResult, isTeamScopedAtMessage) + const ambiguousTeamAgent = msg.toolName === 'Agent' && + isTeamScopedAtMessage && + !explicitTeamAgent && + msg.isPending === true + const isTeamAgent = msg.toolName === 'Agent' && ( + explicitTeamAgent + ) + const isTeamMessage = msg.toolName === 'SendMessage' && ( + isTeamScopedAtMessage || + isTeamCoordinationSendMessage( + msg.input, + toolResult?.content, + options.teamMemberNames, + !hasTeamLifecycleEvidence, + ) + ) + + // During an active Team lifecycle, a streamed Agent without + // teammate identity is not yet classifiable: it can settle as a direct + // SubAgent if `name` stays absent, or as a teammate once that identity + // arrives. Keep it out of both projections during that partial state. + if (ambiguousTeamAgent) continue + + // These records remain untouched in `messages` and `toolResultMap`. + // The lead transcript projects them through the inline team card while + // the workbench owns the roster, DAG, and communication presentation. + // A failed coordination call stays visible because it changes what the + // turn means and is not represented by successful workbench state. + if ( + (isTeamTask || isTeamAgent || isTeamMessage) && + canSummarizeCoordinationResult(toolResult) + ) { + const teamCard = ensureTeamCardForCoordination(msg, toolResult) + if (teamCard) { + teamCard.coordinationToolCalls.push(msg) + continue + } + } } + if (summarizedTeamDelete) continue // The raw TeamCreate call and its JSON result say nothing a reader can // use; the team card in its place links to the workbench that does. - if (options.hideTeamCoordinationTools && msg.toolName === 'TeamCreate') { + if ( + options.hideTeamCoordinationTools && + msg.toolName === 'TeamCreate' && + canSummarizeCoordinationResult(toolResult) + ) { flushGroup() - items.push({ kind: 'team_card', id: `team-card-${msg.id}` }) + activeTeamCard = { + kind: 'team_card', + id: `team-card-${msg.id}`, + teamName: teamNameFromCreate(msg, toolResult), + startedAt: msg.timestamp, + coordinationToolCalls: [], + } + items.push(activeTeamCard) continue } if (msg.toolName === 'AskUserQuestion') { @@ -846,6 +1067,77 @@ export function buildRenderModel( return { renderItems: items, toolResultMap, childToolCallsByParent } } +function coordinationToolSummary(toolCall: ToolCall): string | null { + const input = parseRecordValue(toolCall.input) + if (!input) return null + const values = toolCall.toolName === 'TaskCreate' + ? [input.subject] + : toolCall.toolName === 'TaskUpdate' + ? [input.taskId, input.status, input.owner] + : toolCall.toolName === 'Agent' + ? [input.name, input.description] + : toolCall.toolName === 'SendMessage' + ? [input.to, input.message] + : [] + const summary = values + .filter((value): value is string => typeof value === 'string' && Boolean(value.trim())) + .join(' · ') + return summary || null +} + +function TeamCoordinationAudit({ toolCalls }: { toolCalls: ToolCall[] }) { + const t = useTranslation() + if (toolCalls.length === 0) return null + + return ( +
+ + {t('agentTeams.inline.coordination', { count: toolCalls.length })} + +
    + {toolCalls.map((toolCall) => { + const summary = coordinationToolSummary(toolCall) + return ( +
  1. + + {toolCall.toolName} + + {summary ? {summary} : null} +
  2. + ) + })} +
+
+ ) +} + +function teamTimestamp(value: string | number | undefined): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (!value) return undefined + const numeric = Number(value) + if (Number.isFinite(numeric)) return numeric + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +function snapshotForTeamCard( + snapshot: TeamWorkbenchSnapshot | undefined, + item: Extract, +): TeamWorkbenchSnapshot | undefined { + if (!snapshot || snapshot.team.name !== item.teamName) return undefined + const startedAt = teamTimestamp(snapshot.team.createdAt) + if (startedAt === undefined) return snapshot + if (item.endedAt !== undefined && item.endedAt < startedAt) return undefined + + // The TeamCreate tool and server lifecycle event are emitted by separate + // transports, so allow a small clock/order gap while still refusing to bind + // an older card to a newer incarnation that reused the same team name. + return Math.abs(item.startedAt - startedAt) <= 5_000 ? snapshot : undefined +} + function isTurnResponseMessage(message: UIMessage) { return ( message.type === 'assistant_text' || @@ -1606,10 +1898,16 @@ function estimateMessageHeight(message: UIMessage): number { const ACTIVITY_GROUP_COLLAPSED_HEIGHT = 34 /** Avatar row plus two text lines, plus the turn's padding-bottom. */ const TEAM_CARD_HEIGHT = 86 +/** Collapsed coordination disclosure below the card adds one compact row. */ +const TEAM_CARD_WITH_AUDIT_HEIGHT = 116 export function estimateRenderItemHeight(item: RenderItem): number { if (item.kind === 'message') return estimateMessageHeight(item.message) - if (item.kind === 'team_card') return TEAM_CARD_HEIGHT + if (item.kind === 'team_card') { + return item.coordinationToolCalls.length > 0 + ? TEAM_CARD_WITH_AUDIT_HEIGHT + : TEAM_CARD_HEIGHT + } // Agent dispatch groups keep their taller per-agent cards; everything else // collapses to the single-line activity header. const isAgentGroup = item.toolCalls.length > 0 && item.toolCalls.every((toolCall) => toolCall.toolName === 'Agent') @@ -1649,7 +1947,9 @@ function getMessageMetricSignature(message: UIMessage): string { function getRenderItemMetricSignature(item: RenderItem): string { if (item.kind === 'message') return getMessageMetricSignature(item.message) - if (item.kind === 'team_card') return `team_card:${item.id}` + if (item.kind === 'team_card') { + return `team_card:${item.id}:${item.coordinationToolCalls.length}` + } return item.steps .map((step) => getMessageMetricSignature(step.kind === 'tool' ? step.toolCall : step.message)) .join('|') @@ -1892,25 +2192,39 @@ export function MessageList({ resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null )) const isMemberSession = Boolean(memberSessionTeam) - const isSubagentSession = useTabStore((s) => s.tabs.some((tab) => ( - tab.sessionId === resolvedSessionId && tab.type === 'subagent' + const isAgentRunTab = useTabStore((s) => s.tabs.some((tab) => ( + tab.sessionId === resolvedSessionId && + (tab.type === 'subagent' || tab.type === 'team-member') ))) - const isDirectAgentSession = isMemberSession || isSubagentSession + const isDirectAgentSession = isMemberSession || isAgentRunTab const teamWorkbench = useTeamStore((s) => resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined, ) - const isTeamLeadSession = Boolean(teamWorkbench?.snapshots.length) - const teamSnapshot = teamWorkbench?.snapshots.at(-1) + const activeTeamName = useTeamStore((s) => ( + resolvedSessionId ? s.teamNameBySession[resolvedSessionId] : undefined + )) + const activeTeamStartedAt = useTeamStore((s) => ( + resolvedSessionId ? s.activeTeamStartedAtBySession[resolvedSessionId] : undefined + )) + const teamSnapshot = useMemo(() => { + const snapshots = teamWorkbench?.snapshots + return snapshots?.length + ? snapshotWithHistoricalMembers(snapshots, snapshots.length - 1) + : undefined + }, [teamWorkbench?.snapshots]) + const teamTaskWindows = useMemo( + () => teamTaskWindowsForSnapshot(teamSnapshot, activeTeamStartedAt), + [activeTeamStartedAt, teamSnapshot], + ) const openTeamWorkbench = useCallback((leadSessionId: string, teamName: string) => { useTabStore.getState().openTeamWorkbenchTab(leadSessionId, teamName) }, []) const teamMemberNames = useMemo(() => { - const snapshots = teamWorkbench?.snapshots - if (!snapshots?.length) return undefined - return new Set(snapshots[snapshots.length - 1]!.team.members.flatMap((member) => + if (!teamSnapshot) return undefined + return new Set(teamSnapshot.team.members.flatMap((member) => member.name ? [member.name] : [], )) - }, [teamWorkbench?.snapshots]) + }, [teamSnapshot]) const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? EMPTY_MESSAGES const chatState = sessionState?.chatState ?? 'idle' @@ -2424,10 +2738,22 @@ export function MessageList({ const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages, activeAskUserQuestionToolUseId, { - hideTeamCoordinationTools: isTeamLeadSession, + hideTeamCoordinationTools: !isDirectAgentSession, teamMemberNames, + teamTaskWindows, + teamName: activeTeamName ?? teamSnapshot?.team.name, + teamStartedAt: activeTeamStartedAt, }), - [activeAskUserQuestionToolUseId, isTeamLeadSession, messages, teamMemberNames], + [ + activeAskUserQuestionToolUseId, + activeTeamName, + activeTeamStartedAt, + isDirectAgentSession, + messages, + teamMemberNames, + teamSnapshot?.team.name, + teamTaskWindows, + ], ) // Defer the per-message branchable / completed-turn computations so the first // commit on tab switch can render the virtualization window without doing two @@ -3087,12 +3413,27 @@ export function MessageList({ isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem} /> ) : item.kind === 'team_card' ? ( - teamSnapshot && resolvedSessionId ? ( + resolvedSessionId ? (() => { + const cardSnapshot = snapshotForTeamCard(teamSnapshot, item) + const fallbackPhase = item.endedAt !== undefined || teamTaskWindows.some((window) => ( + item.startedAt >= window.startedAt && + window.endedAt !== undefined && + item.startedAt <= window.endedAt + )) ? 'completed' : 'forming' + return ( openTeamWorkbench(resolvedSessionId, teamSnapshot.team.name)} - /> - ) : null + snapshot={cardSnapshot} + teamName={item.teamName} + fallbackPhase={fallbackPhase} + phaseOverride={item.endedAt !== undefined ? 'completed' : undefined} + onOpen={cardSnapshot + ? () => openTeamWorkbench(resolvedSessionId, cardSnapshot.team.name) + : undefined} + > + + + ) + })() : null ) : ( = { 'agentTeams.task.completed': '完了', 'agentTeams.task.dependencies': '依存 {dependencies}', 'agentTeams.task.unclaimed': 'メンバーの取得待ち', + 'agentTeams.task.completedNoOwner': '完了 · 担当者の記録なし', 'agentTeams.member.working': '作業中', 'agentTeams.member.idle': '待機中', 'agentTeams.member.stopped': '停止済み', @@ -2340,6 +2341,7 @@ export const jp: Record = { 'agentTeams.communication.expand': 'もっと見る', 'agentTeams.communication.collapse': '折りたたむ', 'agentTeams.inline.created': 'エージェントチーム · メンバー {count} 名', + 'agentTeams.inline.coordination': 'チーム編成ログ · {count} ステップ', 'agentTeams.inline.open': '実行レポートを開く', 'agentTeams.report.title': 'Agent Teams 実行レポート', 'agentTeams.report.openWorkbench': 'ワークベンチ', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 264cbc86..2167e02e 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2290,6 +2290,7 @@ export const kr: Record = { 'agentTeams.task.completed': '완료됨', 'agentTeams.task.dependencies': '{dependencies}에 의존', 'agentTeams.task.unclaimed': '팀원 할당 대기 중', + 'agentTeams.task.completedNoOwner': '완료 · 담당자 기록 없음', 'agentTeams.member.working': '작업 중', 'agentTeams.member.idle': '대기 중', 'agentTeams.member.stopped': '중지됨', @@ -2340,6 +2341,7 @@ export const kr: Record = { 'agentTeams.communication.expand': '더 보기', 'agentTeams.communication.collapse': '접기', 'agentTeams.inline.created': '에이전트 팀 · 팀원 {count}명', + 'agentTeams.inline.coordination': '팀 조정 기록 · {count}단계', 'agentTeams.inline.open': '실행 보고서 열기', 'agentTeams.report.title': 'Agent Teams 실행 보고서', 'agentTeams.report.openWorkbench': '워크벤치', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 1c7c56a6..b257fa11 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2289,6 +2289,7 @@ export const zh: Record = { 'agentTeams.task.completed': '已完成', 'agentTeams.task.dependencies': '依賴 {dependencies}', 'agentTeams.task.unclaimed': '等待成員領取', + 'agentTeams.task.completedNoOwner': '已完成 · 未記錄負責人', 'agentTeams.member.working': '工作中', 'agentTeams.member.idle': '待命', 'agentTeams.member.stopped': '已停止', @@ -2339,6 +2340,7 @@ export const zh: Record = { 'agentTeams.communication.expand': '展開', 'agentTeams.communication.collapse': '收合', 'agentTeams.inline.created': 'Agent 團隊 · {count} 名成員', + 'agentTeams.inline.coordination': '團隊編排記錄 · {count} 步', 'agentTeams.inline.open': '開啟執行報告', 'agentTeams.report.title': 'Agent Teams 執行報告', 'agentTeams.report.openWorkbench': '工作台', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8fa42a5f..132a6994 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2289,6 +2289,7 @@ export const zh: Record = { 'agentTeams.task.completed': '已完成', 'agentTeams.task.dependencies': '依赖 {dependencies}', 'agentTeams.task.unclaimed': '等待成员领取', + 'agentTeams.task.completedNoOwner': '已完成 · 未记录负责人', 'agentTeams.member.working': '工作中', 'agentTeams.member.idle': '待命', 'agentTeams.member.stopped': '已停止', @@ -2339,6 +2340,7 @@ export const zh: Record = { 'agentTeams.communication.expand': '展开', 'agentTeams.communication.collapse': '收起', 'agentTeams.inline.created': 'Agent 团队 · {count} 名成员', + 'agentTeams.inline.coordination': '团队编排记录 · {count} 步', 'agentTeams.inline.open': '打开运行报告', 'agentTeams.report.title': 'Agent Teams 运行报告', 'agentTeams.report.openWorkbench': '工作台', diff --git a/desktop/src/lib/teamLifecycleScope.ts b/desktop/src/lib/teamLifecycleScope.ts new file mode 100644 index 00000000..2188afdc --- /dev/null +++ b/desktop/src/lib/teamLifecycleScope.ts @@ -0,0 +1,64 @@ +export type TeamLifecycleWindow = { + startedAt: number + endedAt?: number +} + +export type TeamLifecycleCursor = { + active: boolean | undefined + changedAt: number | undefined +} + +export const EMPTY_TEAM_LIFECYCLE_CURSOR: TeamLifecycleCursor = { + active: undefined, + changedAt: undefined, +} + +export function updateTeamLifecycleCursor( + active: boolean, + changedAt: number, +): TeamLifecycleCursor { + return { active, changedAt } +} + +/** + * Merge transcript lifecycle boundaries with durable workbench windows. + * + * Either source can be truncated: compacted transcripts may omit a TeamCreate, + * while a live transcript may not contain TeamDelete yet even though the + * archived workbench already has an end time. The newer applicable boundary + * wins, and a durable end closes the same lifecycle as an explicit create. + */ +export function isTeamLifecycleScopedAt( + timestamp: number, + cursor: TeamLifecycleCursor, + windows: readonly TeamLifecycleWindow[] = [], +): boolean { + const window = windows + .filter(candidate => candidate.startedAt <= timestamp) + .reduce((latest, candidate) => ( + !latest || candidate.startedAt >= latest.startedAt ? candidate : latest + ), undefined) + const windowActive = Boolean( + window && (window.endedAt === undefined || timestamp <= window.endedAt), + ) + + if (cursor.active === undefined || cursor.changedAt === undefined) { + return windowActive + } + if (!window) return cursor.active + + // A compacted newer lifecycle overrides an older explicit delete. + if (window.startedAt > cursor.changedAt) return windowActive + + // A durable window that contains the explicit create describes that same + // lifecycle, so its end must still close the scope when TeamDelete is absent. + if ( + cursor.active && + cursor.changedAt >= window.startedAt && + (window.endedAt === undefined || cursor.changedAt <= window.endedAt) + ) { + return windowActive + } + + return cursor.active +} diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 862858a9..4ceaef83 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -37,6 +37,7 @@ import { getWorktreeDisplayName, WorktreeDetails } from '../components/chat/Work import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel' import { AgentTeamsStrip } from '../components/agentTeams/AgentTeamsSummary' +import { snapshotWithHistoricalMembers } from '../components/agentTeams/agentTeamsModel' import { SessionActivityPanel, type OpenSubagentPayload, @@ -347,9 +348,14 @@ export function ActiveSession() { session?.messageCount ?? 0, ) const sessionSlashCommandCount = sessionState?.slashCommands.length ?? 0 - const agentTeamsSnapshot = useTeamStore((s) => activeTabId - ? s.workbenchesBySession[activeTabId]?.snapshots.at(-1) + const agentTeamsSnapshots = useTeamStore((s) => activeTabId + ? s.workbenchesBySession[activeTabId]?.snapshots : undefined) + const agentTeamsSnapshot = useMemo(() => ( + agentTeamsSnapshots?.length + ? snapshotWithHistoricalMembers(agentTeamsSnapshots, agentTeamsSnapshots.length - 1) + : undefined + ), [agentTeamsSnapshots]) const activeTeamStartedAt = useTeamStore((s) => activeTabId ? s.activeTeamStartedAtBySession[activeTabId] : undefined) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index d2336fa5..e770a340 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageEntry } from '../types/session' -import { buildSessionActivityModel } from '../components/activity/sessionActivityModel' +import { + buildMainSessionActivityModel, + buildSessionActivityModel, + hasVisibleSessionActivity, +} from '../components/activity/sessionActivityModel' import { useSessionRuntimeStore } from './sessionRuntimeStore' const { @@ -3209,6 +3213,97 @@ describe('chatStore history mapping', () => { vi.useRealTimers() }) + it('never projects sequential streaming teammates into main Activity', () => { + vi.useFakeTimers() + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession(), + }, + }) + + const buildMainActivity = () => buildMainSessionActivityModel({ + sessionId: TEST_SESSION_ID, + messages: useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + for (const [index, member] of ['feature-analyst', 'bug-analyst', 'quality-analyst'].entries()) { + const toolUseId = `team-agent-${index + 1}` + const description = `分析第 ${index + 1} 组 commit` + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Agent', + toolUseId, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: `{"description":"${description}","name":`, + }) + vi.advanceTimersByTime(60) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toContainEqual( + expect.objectContaining({ + type: 'tool_use', + toolUseId, + input: { description }, + isPending: true, + }), + ) + expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Agent', + toolUseId, + input: { + description, + name: member, + team_name: 'commit-analysis', + }, + }) + + expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false) + } + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'tool_use', + toolName: 'Agent', + toolUseId: 'direct-agent', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: '{"description":"审查普通 SubAgent 路径"', + }) + vi.advanceTimersByTime(60) + + expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Agent', + toolUseId: 'direct-agent', + input: { description: '审查普通 SubAgent 路径' }, + }) + + expect(buildMainActivity().sections.subagents.rows).toEqual([ + expect.objectContaining({ + id: 'direct-agent', + label: '审查普通 SubAgent 路径', + status: 'running', + }), + ]) + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + it('batches streaming tool input deltas before updating the pending card', () => { vi.useFakeTimers() diff --git a/desktop/src/types/team.ts b/desktop/src/types/team.ts index 7b0469a9..c7afafe3 100644 --- a/desktop/src/types/team.ts +++ b/desktop/src/types/team.ts @@ -56,6 +56,8 @@ export type TeamWorkbenchMessage = { export type TeamWorkbenchSnapshot = { version: string generatedAt: string + taskListRevision?: number + terminalTaskFrameId?: string team: TeamDetail tasks: TeamWorkbenchTask[] messages: TeamWorkbenchMessage[] diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index cb4f76eb..5ed51af8 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -146,7 +146,7 @@ type State = { // persist on disk forever (gh-32730). TeamDelete removes entries to // avoid double-cleanup. Lives here (not teamHelpers.ts) so // resetStateForTests() clears it between tests. - sessionCreatedTeams: Set + sessionCreatedTeams: Map // Session-only trust flag for home directory (not persisted to disk) // When running from home dir, trust dialog is shown but not saved to disk. // This flag allows features requiring trust to work during the session. @@ -362,7 +362,7 @@ function getInitialState(): State { // Scheduled tasks disabled until flag or dialog enables them scheduledTasksEnabled: false, sessionCronTasks: [], - sessionCreatedTeams: new Set(), + sessionCreatedTeams: new Map(), // Session-only trust flag (not persisted to disk) sessionTrustAccepted: false, // Session-only flag to disable session persistence to disk @@ -1315,6 +1315,15 @@ export type SessionCronTask = { scheduledTime?: string } +export type SessionCreatedTeamLifecycle = { + generation: number + identity: { + teamName: string + createdAt: number + leadSessionId?: string + } +} + export function getSessionCronTasks(): SessionCronTask[] { return STATE.sessionCronTasks } @@ -1493,7 +1502,7 @@ export function getPlanSlugCache(): Map { return STATE.planSlugCache } -export function getSessionCreatedTeams(): Set { +export function getSessionCreatedTeams(): Map { return STATE.sessionCreatedTeams } diff --git a/src/server/__tests__/team-watcher.test.ts b/src/server/__tests__/team-watcher.test.ts index aa2d6b25..0820e21c 100644 --- a/src/server/__tests__/team-watcher.test.ts +++ b/src/server/__tests__/team-watcher.test.ts @@ -367,6 +367,44 @@ describe('TeamWatcher polling', () => { }]) }) + it('uses the canonical config name when a sanitized Team directory is deleted', async () => { + const deliveries: Array<{ message: ServerMessage; sessionId?: string }> = [] + const deleted: Array<[string, string | undefined, string | undefined]> = [] + const workbenchNames: string[] = [] + watcher = new TeamWatcher( + (message, sessionId) => deliveries.push({ message, sessionId }), + { + getWorkbench: async (name) => { + workbenchNames.push(name) + return {} as never + }, + markWorkbenchArchiveDeleted: async (name, sessionId, incarnationId) => { + deleted.push([name, sessionId, incarnationId]) + }, + }, + ) + const config = makeTeamConfig({ + name: 'My Team', + createdAt: 1900000000000, + leadSessionId: 'canonical-lead', + }) + await writeTeamConfig('my-team', config) + await watcher.checkNow() + await fs.rm(path.join(tmpDir, 'teams', 'my-team'), { recursive: true, force: true }) + await watcher.checkNow() + + expect(workbenchNames).toEqual(['my-team']) + expect(deleted).toEqual([[ + 'My Team', + 'canonical-lead', + teamIncarnationId(config), + ]]) + expect(deliveries.map(delivery => delivery.message)).toEqual([ + expect.objectContaining({ type: 'team_created', teamName: 'My Team' }), + expect.objectContaining({ type: 'team_deleted', teamName: 'My Team' }), + ]) + }) + it('should detect team deletion', async () => { // Create a team await writeTeamConfig('doomed-team', makeTeamConfig({ name: 'doomed-team' })) diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index 4e201982..23227f6f 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' import * as crypto from 'node:crypto' +import { AsyncResource } from 'node:async_hooks' import { TeamService, projectTeamWorkbenchesFromTranscript, @@ -14,6 +15,24 @@ import { } from '../services/teamService.js' import type { TeamWorkbenchSnapshot } from '../services/teamService.js' import type { MessageEntry } from '../services/sessionService.js' +import * as lockfile from '../../utils/lockfile.js' +import { getSessionCreatedTeams } from '../../bootstrap/state.js' +import { + cleanupSessionTeams, + cleanupTeamDirectories, + registerTeamForSessionCleanup, + unregisterTeamForSessionCleanup, +} from '../../utils/swarm/teamHelpers.js' +import { + beginTaskListLifecycle, + completeTaskListLifecycle, + createTaskWithCommit, + readTaskListLifecycleState, + readTaskListSnapshot, + resetTaskList, + updateTask, + withTaskListLifecycleLock, +} from '../../utils/tasks.js' import { captureSourceFingerprint, serializeSourceFingerprint, @@ -878,6 +897,1719 @@ describe('TeamService', () => { expect(reopened?.snapshots.at(-1)).toEqual(live) }) + it('reconciles the final task tail from the matching Team incarnation before deletion', async () => { + const teamName = 'delete-before-next-poll' + const leadSessionId = 'delete-before-next-poll-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { + getSessionMessages: async () => transcript, + }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + for (const task of [{ + id: '1', + subject: 'Finish after the last watcher poll', + }, { + id: '2', + subject: 'Observe the final TaskList', + }]) { + await writeTeamTask(teamName, { + ...task, + description: task.subject, + status: 'pending', + blocks: [], + blockedBy: [], + }) + } + + const stale = await service.getWorkbench(teamName) + const archivedAt = Date.parse(stale.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('matching-team-create', 'TeamCreate', { + team_name: 'requested-delete-before-next-poll', + }, timestamp(createdAt - 1)), + transcriptToolResult('matching-team-create', { + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('final-task-list', 'TaskList', {}, timestamp(archivedAt + 10)), + transcriptToolResult('final-task-list', { + tasks: [{ + id: '1', + subject: 'Finish after the last watcher poll', + owner: 'Worker Agent', + status: 'pending', + }, { + id: '2', + subject: 'Observe the final TaskList', + owner: 'Observer Agent', + status: 'completed', + }], + }, timestamp(archivedAt + 11)), + transcriptToolUse('late-task-create', 'TaskCreate', { + subject: 'Created after the final watcher poll', + description: 'Must survive immediate Team deletion', + }, timestamp(archivedAt + 12)), + transcriptToolResult('late-task-create', { + success: true, + task: { + id: '3', + subject: 'Created after the final watcher poll', + description: 'Must survive immediate Team deletion', + }, + }, timestamp(archivedAt + 13)), + transcriptToolUse('final-task-update', 'TaskUpdate', { + taskId: '1', + owner: 'Worker Agent', + status: 'completed', + }, timestamp(archivedAt + 20)), + transcriptToolResult('final-task-update', { + success: true, + taskId: '1', + updatedFields: ['owner', 'status'], + }, timestamp(archivedAt + 21)), + transcriptToolUse('matching-team-delete', 'TeamDelete', {}, timestamp(archivedAt + 25)), + transcriptToolResult('matching-team-delete', { + success: true, + team_name: teamName, + }, timestamp(archivedAt + 26)), + // A later same-name Team in the same lead session is a different + // incarnation and must not overwrite the tombstone being repaired. + transcriptToolUse('next-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(archivedAt + 30)), + transcriptToolResult('next-team-create', { + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(archivedAt + 31)), + transcriptToolUse('next-team-task-list', 'TaskList', {}, timestamp(archivedAt + 40)), + transcriptToolResult('next-team-task-list', { + tasks: [{ + id: '1', + subject: 'Wrong incarnation', + owner: 'Wrong Agent', + status: 'pending', + }], + }, timestamp(archivedAt + 41)), + ] + + // The task files reach their terminal state, but TeamDelete removes both + // directories before TeamWatcher performs another joined workbench read. + await writeTeamTask(teamName, { + id: '1', + subject: 'Finish after the last watcher poll', + description: 'Finish after the last watcher poll', + owner: 'Worker Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }) + await writeTeamTask(teamName, { + id: '2', + subject: 'Observe the final TaskList', + description: 'Observe the final TaskList', + owner: 'Observer Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }) + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + stale.team.incarnationId, + ) + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: stale.team.incarnationId, + }) + + expect(reopened?.source).toBe('archive') + expect(reopened?.snapshots.at(-1)?.deletedAt).toBeDefined() + expect(reopened?.snapshots.at(-1)?.version).toContain(':tasks:') + expect(reopened?.snapshots.at(-1)?.tasks.map(task => ({ + id: task.id, + subject: task.subject, + owner: task.owner, + status: task.status, + }))).toEqual([{ + id: '1', + subject: 'Finish after the last watcher poll', + owner: 'Worker Agent', + status: 'completed', + }, { + id: '2', + subject: 'Observe the final TaskList', + owner: 'Observer Agent', + status: 'completed', + }, { + id: '3', + subject: 'Created after the final watcher poll', + owner: undefined, + status: 'pending', + }]) + }) + + it('uses the TeamDelete terminal task frame when cleanup follows the last watcher poll', async () => { + const teamName = 'terminal-delete-frame' + const leadSessionId = 'terminal-delete-frame-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { + getSessionMessages: async () => transcript, + }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Finish before deleting the Team', + description: 'The watcher still sees this task as pending', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + const stale = await service.getWorkbench(teamName) + const boundary = Date.parse(stale.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('terminal-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('terminal-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('terminal-delete', 'TeamDelete', {}, timestamp(boundary + 9)), + transcriptToolResult('terminal-delete', { + success: true, + team_name: teamName, + // The wall clock may collide with the previous snapshot; the locked + // revision is the authoritative causal order. + taskListSnapshotAt: timestamp(boundary), + taskListSnapshotRevision: (stale.taskListRevision ?? 0) + 1, + finalTasks: [{ + id: '1', + subject: 'Finish before deleting the Team', + description: 'The terminal frame owns the final state', + owner: 'Worker Agent', + status: 'completed', + blocks: ['2'], + blockedBy: [], + }, { + id: '2', + subject: 'Preserve the terminal forward edge', + description: 'TeamDelete carries the complete edge shape', + status: 'completed', + blocks: [], + blockedBy: [], + }], + }, timestamp(boundary + 11)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + stale.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: stale.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + tasks: [{ + id: '1', + subject: 'Finish before deleting the Team', + description: 'The terminal frame owns the final state', + owner: 'Worker Agent', + status: 'completed', + blocks: ['2'], + }, { + id: '2', + subject: 'Preserve the terminal forward edge', + description: 'TeamDelete carries the complete edge shape', + status: 'completed', + blockedBy: ['1'], + }], + }) + }) + + it('consumes an equal-revision TeamDelete frame as the terminal boundary', async () => { + const teamName = 'equal-revision-terminal-frame' + const leadSessionId = 'equal-revision-terminal-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'No mutation during cleanup', + description: 'The terminal frame still closes the lifecycle', + status: 'completed', + blocks: [], + blockedBy: [], + }) + const snapshot = await service.getWorkbench(teamName) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('equal-revision-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('equal-revision-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('equal-revision-delete', 'TeamDelete', {}, timestamp(boundary + 1)), + transcriptToolResult('equal-revision-delete', { + success: true, + team_name: teamName, + taskListSnapshotAt: snapshot.generatedAt, + taskListSnapshotRevision: snapshot.taskListRevision, + finalTasks: snapshot.tasks, + }, timestamp(boundary + 2)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + terminalTaskFrameId: 'equal-revision-delete', + taskListRevision: snapshot.taskListRevision, + tasks: [expect.objectContaining({ id: '1', status: 'completed' })], + }) + }) + + it('enriches an existing tombstone when the terminal TeamDelete frame arrives late', async () => { + const transcripts = new Map() + service = new TeamService({ + sessionReader: { + getSessionMessages: async (sessionId) => transcripts.get(sessionId) ?? [], + }, + }) + + for (const refreshMode of ['remark', 'reopen'] as const) { + const teamName = `late-terminal-${refreshMode}` + const leadSessionId = `${teamName}-lead` + const createdAt = Date.now() - 1_000 + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Complete before transcript flush', + description: 'The first tombstone is stale', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const stale = await service.getWorkbench(teamName) + const boundary = Date.parse(stale.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + const createMessages = [ + transcriptToolUse(`${teamName}-create`, 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult(`${teamName}-create`, { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + ] + transcripts.set(leadSessionId, createMessages) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + stale.team.incarnationId, + ) + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + const before = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: stale.team.incarnationId, + }) + const beforeLatest = before!.snapshots.at(-1)! + expect(beforeLatest.tasks[0]?.status).toBe('pending') + + transcripts.set(leadSessionId, [ + ...createMessages, + transcriptToolUse(`${teamName}-delete`, 'TeamDelete', {}, timestamp(boundary + 1)), + transcriptToolResult(`${teamName}-delete`, { + success: true, + team_name: teamName, + taskListSnapshotAt: timestamp(boundary), + taskListSnapshotRevision: (stale.taskListRevision ?? 0) + 1, + finalTasks: [{ + id: '1', + subject: 'Complete before transcript flush', + description: 'The terminal frame is authoritative', + owner: 'Worker Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }], + }, timestamp(boundary + 2)), + ]) + if (refreshMode === 'remark') { + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + stale.team.incarnationId, + ) + } + const enriched = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: stale.team.incarnationId, + }) + const enrichedLatest = enriched!.snapshots.at(-1)! + expect(enrichedLatest).toMatchObject({ + deletedAt: beforeLatest.deletedAt, + terminalTaskFrameId: `${teamName}-delete`, + taskListRevision: (stale.taskListRevision ?? 0) + 1, + tasks: [{ id: '1', owner: 'Worker Agent', status: 'completed' }], + }) + expect(enriched?.snapshots).toHaveLength(before?.snapshots.length ?? 0) + + const repeated = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: stale.team.incarnationId, + }) + expect(repeated?.snapshots).toHaveLength(enriched?.snapshots.length ?? 0) + expect(repeated?.snapshots.at(-1)).toEqual(enrichedLatest) + } + }) + + it('captures the archive replay watermark after the locked task read', async () => { + const teamName = 'workbench-watermark' + await writeTeamConfig(teamName, makeTeamConfig({ name: teamName })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Race the watcher', + description: 'A transcript update may land while this read is blocked', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const listLockPath = path.join(tmpDir, 'tasks', teamName, '.lock') + await fs.writeFile(listLockPath, '', { flag: 'a' }) + let releaseBarrier: (() => Promise) | undefined = await lockfile.lock(listLockPath) + try { + const pendingSnapshot = service.getWorkbench(teamName) + await new Promise(resolve => setTimeout(resolve, 10)) + const lockReleasedAt = Date.now() + await releaseBarrier() + releaseBarrier = undefined + const snapshot = await pendingSnapshot + + expect(Date.parse(snapshot.generatedAt)).toBeGreaterThanOrEqual(lockReleasedAt) + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ id: '1', subject: 'Race the watcher' }), + ]) + } finally { + await releaseBarrier?.() + } + }) + + it('serializes a joined watcher snapshot before whole-Team directory cleanup', async () => { + const teamName = 'workbench-cleanup-race' + await writeTeamConfig(teamName, makeTeamConfig({ name: teamName })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Keep the final DAG intact', + description: 'Cleanup must wait until the joined watcher read is archived', + owner: 'Worker Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }) + + const serviceInternals = service as unknown as { + readWorkbenchMessages(name: string): Promise + } + const originalReadMessages = serviceInternals.readWorkbenchMessages.bind(service) + let enteredMessages!: () => void + let releaseMessages!: () => void + const messagesEntered = new Promise(resolve => { + enteredMessages = resolve + }) + const messagesBarrier = new Promise(resolve => { + releaseMessages = resolve + }) + serviceInternals.readWorkbenchMessages = async (name) => { + enteredMessages() + await messagesBarrier + return originalReadMessages(name) + } + + try { + const snapshotPromise = service.getWorkbench(teamName) + await messagesEntered + let cleanupSettled = false + const cleanupPromise = cleanupTeamDirectories(teamName).finally(() => { + cleanupSettled = true + }) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(cleanupSettled).toBe(false) + expect(await fs.stat(path.join(tmpDir, 'teams', teamName))).toBeDefined() + expect(await fs.stat(path.join(tmpDir, 'tasks', teamName))).toBeDefined() + + releaseMessages() + const snapshot = await snapshotPromise + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ id: '1', status: 'completed' }), + ]) + const terminalTasks = await cleanupPromise + expect(terminalTasks.tasks).toEqual([ + expect.objectContaining({ id: '1', status: 'completed' }), + ]) + expect(Number.isFinite(Date.parse(terminalTasks.capturedAt))).toBe(true) + await expect(fs.stat(path.join(tmpDir, 'teams', teamName))).rejects.toThrow() + await expect(fs.stat(path.join(tmpDir, 'tasks', teamName))).rejects.toThrow() + } finally { + serviceInternals.readWorkbenchMessages = originalReadMessages + releaseMessages() + } + }) + + it('persists a terminal lifecycle receipt and rejects stale same-name cleanup and writes', async () => { + const directoryName = 'my-lifecycle-team' + const canonicalName = 'My Lifecycle Team' + const leadSessionId = 'lifecycle-receipt-lead' + const firstIdentity = { + teamName: canonicalName, + createdAt: Date.now() - 2_000, + leadSessionId, + } + const firstLifecycle = await beginTaskListLifecycle( + directoryName, + firstIdentity, + ) + await writeTeamConfig(directoryName, makeTeamConfig({ + name: canonicalName, + createdAt: firstIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(directoryName) + const created = await createTaskWithCommit(directoryName, { + subject: 'Persist the graceful-shutdown terminal state', + description: 'No TeamDelete transcript is required', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const live = await service.getWorkbench(directoryName) + await updateTask(directoryName, created.taskId, { + owner: 'Worker Agent', + status: 'completed', + }) + + const receipt = await cleanupTeamDirectories( + canonicalName, + firstLifecycle, + ) + expect(receipt).toMatchObject({ + generation: firstLifecycle.generation, + identity: firstIdentity, + revision: (live.taskListRevision ?? 0) + 1, + tasks: [{ + id: created.taskId, + owner: 'Worker Agent', + status: 'completed', + }], + }) + await service.markWorkbenchArchiveDeleted( + canonicalName, + leadSessionId, + live.team.incarnationId, + ) + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: live.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + terminalTaskFrameId: receipt.frameId, + taskListRevision: receipt.revision, + tasks: [{ + id: created.taskId, + owner: 'Worker Agent', + status: 'completed', + }], + }) + + await expect( + fs.stat(path.join(tmpDir, 'tasks', directoryName)), + ).rejects.toThrow() + expect(await readTaskListSnapshot(directoryName)).toMatchObject({ + revision: receipt.revision, + tasks: [{ id: created.taskId, status: 'completed' }], + }) + await expect( + fs.stat(path.join(tmpDir, 'tasks', directoryName)), + ).rejects.toThrow() + await expect(createTaskWithCommit(directoryName, { + subject: 'Queued after deletion', + description: 'A deleted Team task directory must stay deleted', + status: 'pending', + blocks: [], + blockedBy: [], + })).rejects.toThrow('deleted Team lifecycle') + await expect( + fs.stat(path.join(tmpDir, 'tasks', directoryName)), + ).rejects.toThrow() + + const secondIdentity = { + ...firstIdentity, + createdAt: firstIdentity.createdAt + 1_000, + } + const secondLifecycle = await beginTaskListLifecycle( + directoryName, + secondIdentity, + ) + await writeTeamConfig(directoryName, makeTeamConfig({ + name: canonicalName, + createdAt: secondIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(directoryName) + const secondTask = await createTaskWithCommit(directoryName, { + subject: 'Belongs only to generation two', + description: 'A late generation-one writer must not enter this list', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + await expect( + cleanupTeamDirectories(canonicalName, firstLifecycle), + ).rejects.toThrow(/changed/) + expect(await readTaskListSnapshot(directoryName)).toMatchObject({ + revision: 1, + tasks: [{ + id: secondTask.taskId, + subject: 'Belongs only to generation two', + }], + }) + expect(secondLifecycle.generation).toBe(firstLifecycle.generation + 1) + }) + + it('keeps Team lifecycle cleanup canonical under a standalone task-list override', async () => { + const directoryName = 'my-override-team' + const canonicalName = 'My Override Team' + const leadSessionId = 'override-team-lead' + const identity = { + teamName: canonicalName, + createdAt: Date.now(), + leadSessionId, + } + const previousOverride = process.env.CLAUDE_CODE_TASK_LIST_ID + process.env.CLAUDE_CODE_TASK_LIST_ID = 'unrelated-explicit-list' + try { + const lifecycle = await beginTaskListLifecycle(directoryName, identity) + await writeTeamConfig(directoryName, makeTeamConfig({ + name: canonicalName, + createdAt: identity.createdAt, + leadSessionId, + })) + await resetTaskList(directoryName) + const teamTask = await createTaskWithCommit(directoryName, { + subject: 'Canonical Team task', + description: 'Must be archived and deleted with this Team', + status: 'completed', + blocks: [], + blockedBy: [], + }) + await resetTaskList('unrelated-explicit-list') + const unrelatedTask = await createTaskWithCommit('unrelated-explicit-list', { + subject: 'Standalone task-list task', + description: 'Must survive Team cleanup', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + const live = await service.getWorkbench(directoryName) + expect(live.tasks.map(task => task.id)).toEqual([teamTask.taskId]) + const receipt = await cleanupTeamDirectories(canonicalName, lifecycle) + expect(receipt.tasks.map(task => task.subject)).toEqual([ + 'Canonical Team task', + ]) + expect(await readTaskListSnapshot('unrelated-explicit-list')).toMatchObject({ + tasks: [{ + id: unrelatedTask.taskId, + subject: 'Standalone task-list task', + }], + }) + await expect( + fs.stat(path.join(tmpDir, 'tasks', 'unrelated-explicit-list')), + ).resolves.toBeDefined() + + await service.markWorkbenchArchiveDeleted( + canonicalName, + leadSessionId, + live.team.incarnationId, + ) + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: live.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + terminalTaskFrameId: receipt.frameId, + tasks: [{ subject: 'Canonical Team task' }], + }) + } finally { + if (previousOverride === undefined) { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + } else { + process.env.CLAUDE_CODE_TASK_LIST_ID = previousOverride + } + } + }) + + it('retries runtime and physical cleanup from an already durable terminal receipt', async () => { + const directoryName = 'retry-terminal-cleanup' + const canonicalName = 'Retry Terminal Cleanup' + const worktreePath = path.join(tmpDir, 'runtime-worktrees', 'retry-worker') + const identity = { + teamName: canonicalName, + createdAt: Date.now(), + leadSessionId: 'retry-terminal-lead', + } + const lifecycle = await beginTaskListLifecycle(directoryName, identity) + const config = makeTeamConfig({ + name: canonicalName, + createdAt: identity.createdAt, + leadSessionId: identity.leadSessionId, + }) + await fs.mkdir(worktreePath, { recursive: true }) + await writeTeamConfig(directoryName, { + ...config, + members: config.members.map(member => ( + member.agentId === 'agent-worker' + ? { ...member, worktreePath } + : member + )), + }) + await resetTaskList(directoryName) + await createTaskWithCommit(directoryName, { + subject: 'Terminal task survives the first cleanup attempt', + description: 'The receipt exists before physical deletion is retried', + status: 'completed', + blocks: [], + blockedBy: [], + }) + const terminal = await completeTaskListLifecycle( + directoryName, + await readTaskListSnapshot(directoryName), + lifecycle, + ) + + expect(await fs.stat(path.join(tmpDir, 'teams', directoryName))).toBeDefined() + expect(await fs.stat(path.join(tmpDir, 'tasks', directoryName))).toBeDefined() + expect(await fs.stat(worktreePath)).toBeDefined() + const retried = await cleanupTeamDirectories(canonicalName, lifecycle) + expect(retried.frameId).toBe(terminal.frameId) + await expect(fs.stat(worktreePath)).rejects.toThrow() + await expect( + fs.stat(path.join(tmpDir, 'teams', directoryName)), + ).rejects.toThrow() + await expect( + fs.stat(path.join(tmpDir, 'tasks', directoryName)), + ).rejects.toThrow() + }) + + it('repairs an unobserved graceful terminal receipt after same-name recreation', async () => { + const directoryName = 'graceful-reopen-team' + const canonicalName = 'Graceful Reopen Team' + const leadSessionId = 'graceful-reopen-lead' + const firstIdentity = { + teamName: canonicalName, + createdAt: Date.now() - 1_000, + leadSessionId, + } + const firstLifecycle = await beginTaskListLifecycle( + directoryName, + firstIdentity, + ) + await writeTeamConfig(directoryName, makeTeamConfig({ + name: canonicalName, + createdAt: firstIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(directoryName) + const task = await createTaskWithCommit(directoryName, { + subject: 'Finish before graceful shutdown', + description: 'The watcher archived this before the final update', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const live = await service.getWorkbench(directoryName) + await updateTask(directoryName, task.taskId, { + owner: 'Worker Agent', + status: 'completed', + }) + const terminal = await cleanupTeamDirectories( + canonicalName, + firstLifecycle, + ) + + // Recreate the same canonical name before the old lead archive is opened. + // The current lifecycle is active again, but its retained terminal history + // must still repair the exact older incarnation. + const secondIdentity = { + ...firstIdentity, + createdAt: firstIdentity.createdAt + 2_000, + } + await beginTaskListLifecycle(directoryName, secondIdentity) + await writeTeamConfig(directoryName, makeTeamConfig({ + name: canonicalName, + createdAt: secondIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(directoryName) + + service = new TeamService() + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: live.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + terminalTaskFrameId: terminal.frameId, + taskListRevision: terminal.revision, + tasks: [{ + id: task.taskId, + owner: 'Worker Agent', + status: 'completed', + }], + }) + }) + + it('cleans registered session Teams without dropping a recreated lifecycle', async () => { + const teamName = 'session-cleanup-lifecycle' + const firstIdentity = { + teamName, + createdAt: Date.now() - 1_000, + leadSessionId: 'session-cleanup-lead', + } + const firstLifecycle = await beginTaskListLifecycle(teamName, firstIdentity) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + createdAt: firstIdentity.createdAt, + leadSessionId: firstIdentity.leadSessionId, + })) + await resetTaskList(teamName) + registerTeamForSessionCleanup(teamName, firstLifecycle) + + const cleanupResource = new AsyncResource('session-team-cleanup') + let cleanupPromise!: Promise + let cleanupSettled = false + let secondLifecycle: Awaited> | undefined + try { + await withTaskListLifecycleLock(teamName, async () => { + cleanupPromise = cleanupResource.runInAsyncScope(() => ( + cleanupSessionTeams() + )) + void cleanupPromise.finally(() => { + cleanupSettled = true + }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(cleanupSettled).toBe(false) + + await cleanupTeamDirectories(teamName, firstLifecycle) + const secondIdentity = { + ...firstIdentity, + createdAt: firstIdentity.createdAt + 2_000, + } + secondLifecycle = await beginTaskListLifecycle(teamName, secondIdentity) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + createdAt: secondIdentity.createdAt, + leadSessionId: secondIdentity.leadSessionId, + })) + await resetTaskList(teamName) + registerTeamForSessionCleanup(teamName, secondLifecycle) + }) + await cleanupPromise + + expect(getSessionCreatedTeams().get(teamName)).toEqual(secondLifecycle) + unregisterTeamForSessionCleanup(teamName, firstLifecycle) + expect(getSessionCreatedTeams().get(teamName)).toEqual(secondLifecycle) + expect(await fs.stat(path.join(tmpDir, 'teams', teamName))).toBeDefined() + expect(await fs.stat(path.join(tmpDir, 'tasks', teamName))).toBeDefined() + } finally { + getSessionCreatedTeams().delete(teamName) + } + }) + + it('compare-deletes a fulfilled session cleanup without unregistering its replacement', async () => { + const firstName = 'fulfilled-session-cleanup-a' + const barrierName = 'fulfilled-session-cleanup-b' + const leadSessionId = 'fulfilled-session-cleanup-lead' + const firstIdentity = { + teamName: firstName, + createdAt: Date.now() - 2_000, + leadSessionId, + } + const barrierIdentity = { + teamName: barrierName, + createdAt: Date.now() - 1_000, + leadSessionId, + } + const firstLifecycle = await beginTaskListLifecycle(firstName, firstIdentity) + const barrierLifecycle = await beginTaskListLifecycle( + barrierName, + barrierIdentity, + ) + await writeTeamConfig(firstName, makeTeamConfig({ + name: firstName, + createdAt: firstIdentity.createdAt, + leadSessionId, + })) + await writeTeamConfig(barrierName, makeTeamConfig({ + name: barrierName, + createdAt: barrierIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(firstName) + await resetTaskList(barrierName) + registerTeamForSessionCleanup(firstName, firstLifecycle) + registerTeamForSessionCleanup(barrierName, barrierLifecycle) + + const cleanupResource = new AsyncResource('fulfilled-session-cleanup') + let cleanupPromise!: Promise + let replacement: Awaited> | undefined + try { + await withTaskListLifecycleLock(barrierName, async () => { + cleanupPromise = cleanupResource.runInAsyncScope(() => ( + cleanupSessionTeams() + )) + + let firstDeleted = false + for (let attempt = 0; attempt < 100; attempt++) { + firstDeleted = (await readTaskListLifecycleState(firstName)).deleted + if (firstDeleted) break + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(firstDeleted).toBe(true) + + const replacementIdentity = { + ...firstIdentity, + createdAt: firstIdentity.createdAt + 4_000, + } + replacement = await beginTaskListLifecycle( + firstName, + replacementIdentity, + ) + await writeTeamConfig(firstName, makeTeamConfig({ + name: firstName, + createdAt: replacementIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(firstName) + registerTeamForSessionCleanup(firstName, replacement) + }) + await cleanupPromise + + expect(getSessionCreatedTeams().get(firstName)).toEqual(replacement) + expect(getSessionCreatedTeams().has(barrierName)).toBe(false) + expect(await fs.stat(path.join(tmpDir, 'teams', firstName))).toBeDefined() + expect(await fs.stat(path.join(tmpDir, 'tasks', firstName))).toBeDefined() + } finally { + getSessionCreatedTeams().delete(firstName) + getSessionCreatedTeams().delete(barrierName) + } + }) + + it('rejects a real TaskCreate queued across cleanup and same-name recreation', async () => { + const teamName = 'queued-lifecycle-writer' + const leadSessionId = 'queued-lifecycle-writer-lead' + const firstIdentity = { + teamName, + createdAt: Date.now() - 2_000, + leadSessionId, + } + const firstLifecycle = await beginTaskListLifecycle(teamName, firstIdentity) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + createdAt: firstIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(teamName) + + const writerResource = new AsyncResource('queued-task-writer') + let queuedWriter!: Promise + let writerSettled = false + let secondTaskId = '' + try { + await withTaskListLifecycleLock(teamName, async () => { + queuedWriter = writerResource.runInAsyncScope(() => ( + createTaskWithCommit(teamName, { + subject: 'Old generation queued writer', + description: 'Must not enter the recreated Team', + status: 'pending', + blocks: [], + blockedBy: [], + }) + )) + void queuedWriter.finally(() => { + writerSettled = true + }).catch(() => {}) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(writerSettled).toBe(false) + + await cleanupTeamDirectories(teamName, firstLifecycle) + const secondIdentity = { + ...firstIdentity, + createdAt: firstIdentity.createdAt + 1_000, + } + await beginTaskListLifecycle(teamName, secondIdentity) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + createdAt: secondIdentity.createdAt, + leadSessionId, + })) + await resetTaskList(teamName) + secondTaskId = (await createTaskWithCommit(teamName, { + subject: 'Only generation-two task', + description: 'The old queued writer must leave this list untouched', + status: 'pending', + blocks: [], + blockedBy: [], + })).taskId + }) + + await expect(queuedWriter).rejects.toThrow( + 'lifecycle changed while waiting for the lock', + ) + expect(await readTaskListSnapshot(teamName)).toMatchObject({ + revision: 1, + tasks: [{ + id: secondTaskId, + subject: 'Only generation-two task', + }], + }) + + // The positive direction stays live: a writer that starts in generation + // two commits normally and advances only that generation's revision. + await createTaskWithCommit(teamName, { + subject: 'Active generation-two writer', + description: 'Must still be accepted', + status: 'pending', + blocks: [], + blockedBy: [], + }) + expect(await readTaskListSnapshot(teamName)).toMatchObject({ + revision: 2, + tasks: [ + { id: '1', subject: 'Only generation-two task' }, + { id: '2', subject: 'Active generation-two writer' }, + ], + }) + } finally { + writerResource.emitDestroy() + await queuedWriter?.catch(() => {}) + } + }) + + it('does not let an older concurrent workbench write replace a newer archive frame', async () => { + const teamName = 'archive-arrival-order' + const leadSessionId = 'archive-arrival-order-lead' + await writeTeamConfig(teamName, makeTeamConfig({ name: teamName, leadSessionId })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Keep the newest archive state', + description: 'An older request may finish after a newer request', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + const base = await service.getWorkbench(teamName) + const baseAt = Date.parse(base.generatedAt) + const older: TeamWorkbenchSnapshot = { + ...base, + version: 'arrival-order-older', + generatedAt: new Date(baseAt + 10).toISOString(), + taskListRevision: (base.taskListRevision ?? 0) + 1, + tasks: base.tasks.map(task => ({ ...task, status: 'pending' })), + } + const newer: TeamWorkbenchSnapshot = { + ...base, + version: 'arrival-order-newer', + generatedAt: new Date(baseAt + 10).toISOString(), + taskListRevision: (base.taskListRevision ?? 0) + 2, + tasks: base.tasks.map(task => ({ ...task, status: 'completed' })), + } + + await service.archiveWorkbenchSnapshot(newer) + await service.archiveWorkbenchSnapshot(older) + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: base.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + version: 'arrival-order-newer', + generatedAt: newer.generatedAt, + tasks: [{ id: '1', status: 'completed' }], + }) + }) + + it('tombstones the newest archive frame and rejects late live resurrection', async () => { + const teamName = 'archive-tombstone-race' + const leadSessionId = 'archive-tombstone-race-lead' + await writeTeamConfig(teamName, makeTeamConfig({ name: teamName, leadSessionId })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Preserve the final state through deletion', + description: 'The tombstone must re-read the archive under its write lock', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const base = await service.getWorkbench(teamName) + + let readerEntered!: () => void + let releaseReader!: () => void + const entered = new Promise(resolve => { + readerEntered = resolve + }) + const readerBarrier = new Promise(resolve => { + releaseReader = resolve + }) + service = new TeamService({ + sessionReader: { + getSessionMessages: async () => { + readerEntered() + await readerBarrier + return [] + }, + }, + }) + + const tombstonePromise = service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + base.team.incarnationId, + ) + await entered + await new Promise(resolve => setTimeout(resolve, 2)) + const newest: TeamWorkbenchSnapshot = { + ...base, + version: 'tombstone-race-newest', + generatedAt: new Date().toISOString(), + tasks: base.tasks.map(task => ({ ...task, status: 'completed' })), + } + await service.archiveWorkbenchSnapshot(newest) + releaseReader() + await tombstonePromise + + const lateLive: TeamWorkbenchSnapshot = { + ...base, + version: 'tombstone-race-late-live', + generatedAt: new Date(Date.now() + 1_000).toISOString(), + tasks: base.tasks.map(task => ({ ...task, status: 'pending' })), + } + await service.archiveWorkbenchSnapshot(lateLive) + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: base.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + tasks: [{ id: '1', status: 'completed' }], + }) + expect(reopened?.snapshots.at(-1)?.version).not.toBe('tombstone-race-late-live') + }) + + it('does not let an older TaskList result roll a newer disk snapshot backward', async () => { + const teamName = 'stale-task-list-tail' + const leadSessionId = 'stale-task-list-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Keep the newer terminal state', + description: 'The disk snapshot already includes teammate completion', + owner: 'worker', + status: 'completed', + blocks: [], + blockedBy: [], + }) + + const firstSnapshot = await service.getWorkbench(teamName) + await new Promise(resolve => setTimeout(resolve, 10)) + const snapshot = await service.getWorkbench(teamName) + expect(snapshot.version).toBe(firstSnapshot.version) + expect(Date.parse(snapshot.generatedAt)).toBeGreaterThan( + Date.parse(firstSnapshot.generatedAt), + ) + const boundary = Date.parse(snapshot.generatedAt) + const previousBoundary = Date.parse(firstSnapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('stale-list-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('stale-list-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + // This read started before the locked disk snapshot, but its result was + // streamed afterward. It cannot overwrite the completed disk state. + transcriptToolUse('stale-list', 'TaskList', {}, timestamp(previousBoundary + 1)), + transcriptToolResult('stale-list', { + tasks: [{ + id: '1', + subject: 'Keep the newer terminal state', + owner: 'worker', + status: 'pending', + }], + }, timestamp(boundary + 5)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)?.tasks).toEqual([ + expect.objectContaining({ + id: '1', + owner: 'worker', + status: 'completed', + }), + ]) + }) + + it('lets a causally newer full TaskList clear an archived stale owner', async () => { + const teamName = 'ownerless-task-list-tail' + const leadSessionId = 'ownerless-task-list-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Return to the unclaimed pool', + description: 'The final full list intentionally omits owner', + owner: 'exited-worker', + status: 'in_progress', + blocks: [], + blockedBy: [], + }) + + const snapshot = await service.getWorkbench(teamName) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('ownerless-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('ownerless-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('ownerless-list', 'TaskList', {}, timestamp(boundary + 5)), + transcriptToolResult('ownerless-list', { + tasks: [{ + id: '1', + subject: 'Return to the unclaimed pool', + status: 'pending', + }], + }, timestamp(boundary + 6)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)?.tasks).toEqual([ + expect.objectContaining({ id: '1', status: 'pending' }), + ]) + expect(reopened?.snapshots.at(-1)?.tasks[0]?.owner).toBeUndefined() + }) + + it('rebuilds archived dependency edges from a legacy blockedBy-only TaskList', async () => { + const teamName = 'legacy-task-list-edges' + const leadSessionId = 'legacy-task-list-edges-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Release the dependent task', + description: 'The archived forward edge is stale', + status: 'pending', + blocks: ['2'], + blockedBy: [], + }) + await writeTeamTask(teamName, { + id: '2', + subject: 'Continue after the blocker finishes', + description: 'The legacy TaskList has the authoritative reverse edge', + status: 'pending', + blocks: [], + blockedBy: ['1'], + }) + + const snapshot = await service.getWorkbench(teamName) + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ id: '1', blocks: ['2'] }), + expect.objectContaining({ id: '2', blockedBy: ['1'] }), + ]) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('legacy-edges-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('legacy-edges-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + // Legacy TaskList results have no causal marker and serialize only the + // reverse dependency side. Both timestamps are after the disk boundary, + // so this successful full list is the authoritative transition. + transcriptToolUse('legacy-edges-list', 'TaskList', {}, timestamp(boundary + 5)), + transcriptToolResult('legacy-edges-list', { + success: true, + tasks: [{ + id: '1', + subject: 'Release the dependent task', + status: 'completed', + blockedBy: [], + }, { + id: '2', + subject: 'Continue after the blocker finishes', + status: 'pending', + blockedBy: [], + }], + }, timestamp(boundary + 6)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId, { + incarnationId: snapshot.team.incarnationId, + }) + expect(reopened?.snapshots.at(-1)?.tasks.map(task => ({ + id: task.id, + blocks: task.blocks, + blockedBy: task.blockedBy, + }))).toEqual([{ + id: '1', + blocks: [], + blockedBy: [], + }, { + id: '2', + blocks: [], + blockedBy: [], + }]) + }) + + it('orders transcript tail repair by locked task-list causality', async () => { + const teamName = 'causal-task-tail' + const leadSessionId = 'causal-task-tail-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Apply the actual commit order', + description: 'Tool-use order is not task-list commit order', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + const snapshot = await service.getWorkbench(teamName) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('causal-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('causal-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + // The mutation was invoked first but committed after the intervening + // full-list read. Replay must use the lock markers, not tool-use order. + transcriptToolUse('late-completion', 'TaskUpdate', { + taskId: '1', + status: 'completed', + }, timestamp(boundary + 1)), + transcriptToolUse('earlier-list-read', 'TaskList', {}, timestamp(boundary + 2)), + transcriptToolResult('earlier-list-read', { + success: true, + taskListSnapshotAt: timestamp(boundary), + taskListSnapshotRevision: (snapshot.taskListRevision ?? 0) + 1, + tasks: [{ + id: '1', + subject: 'Apply the actual commit order', + status: 'pending', + }], + }, timestamp(boundary + 8)), + transcriptToolResult('late-completion', { + success: true, + taskListMutationAt: timestamp(boundary), + taskListMutationRevision: (snapshot.taskListRevision ?? 0) + 2, + }, timestamp(boundary + 11)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)?.tasks).toEqual([ + expect.objectContaining({ id: '1', status: 'completed' }), + ]) + expect(reopened?.snapshots.at(-1)?.taskListRevision).toBe( + (snapshot.taskListRevision ?? 0) + 2, + ) + }) + + it('keeps a locked snapshot over an ambiguous cross-boundary TaskUpdate', async () => { + const teamName = 'ambiguous-task-update-tail' + const leadSessionId = 'ambiguous-task-update-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Do not regress the locked terminal state', + description: 'A legacy result timestamp is not its commit timestamp', + owner: 'worker', + status: 'completed', + blocks: [], + blockedBy: [], + }) + + const snapshot = await service.getWorkbench(teamName) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('ambiguous-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('ambiguous-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('ambiguous-update', 'TaskUpdate', { + taskId: '1', + status: 'in_progress', + }, timestamp(boundary - 5)), + transcriptToolResult('ambiguous-update', { + success: true, + }, timestamp(boundary + 5)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)?.tasks).toEqual([ + expect.objectContaining({ id: '1', status: 'completed' }), + ]) + }) + + it('uses a causally newer empty TaskList to remove archived ghost tasks', async () => { + const teamName = 'empty-task-list-tail' + const leadSessionId = 'empty-task-list-lead' + const createdAt = Date.now() - 1_000 + let transcript: MessageEntry[] = [] + service = new TeamService({ + sessionReader: { getSessionMessages: async () => transcript }, + }) + await writeTeamConfig(teamName, makeTeamConfig({ + name: teamName, + leadSessionId, + createdAt, + })) + await writeTeamTask(teamName, { + id: '1', + subject: 'Disappear from the terminal full list', + description: 'The mutation result may be missing in a legacy transcript', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + const snapshot = await service.getWorkbench(teamName) + const boundary = Date.parse(snapshot.generatedAt) + const timestamp = (value: number) => new Date(value).toISOString() + transcript = [ + transcriptToolUse('empty-list-team-create', 'TeamCreate', { + team_name: teamName, + }, timestamp(createdAt - 1)), + transcriptToolResult('empty-list-team-create', { + success: true, + team_name: teamName, + lead_agent_id: `team-lead@${teamName}`, + }, timestamp(createdAt + 1)), + transcriptToolUse('empty-list', 'TaskList', {}, timestamp(boundary + 1)), + transcriptToolResult('empty-list', { + success: true, + taskListSnapshotAt: timestamp(boundary + 2), + tasks: [], + }, timestamp(boundary + 3)), + ] + + await fs.rm(path.join(tmpDir, 'teams', teamName), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', teamName), { recursive: true, force: true }) + await service.markWorkbenchArchiveDeleted( + teamName, + leadSessionId, + snapshot.team.incarnationId, + ) + + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)?.tasks).toEqual([]) + }) + + it('carries exited members through roster removal so completed owners and transcripts stay attributable', async () => { + const leadSessionId = 'shrinking-roster-lead' + const initialConfig = makeTeamConfig({ + name: 'shrinking-roster', + leadSessionId, + members: [ + ...(makeTeamConfig().members as Array>), + { + agentId: 'agent-observer', + name: 'Observer Agent', + agentType: 'observer', + joinedAt: 1700000002000, + cwd: '/tmp/project', + isActive: false, + }, + ], + }) + await writeTeamConfig('shrinking-roster', initialConfig) + await writeTeamTask('shrinking-roster', { + id: '1', + subject: 'Finish the owned task', + description: 'Keep the attribution after shutdown removes the teammate', + owner: 'Worker Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }) + await writeTranscriptFile('-tmp-project', 'session-worker-001', [{ + type: 'assistant', + uuid: 'worker-before-roster-removal', + teamName: 'shrinking-roster', + agentName: 'Worker Agent', + message: { role: 'assistant', content: 'Finished before shutdown' }, + timestamp: '2024-01-01T00:00:00.000Z', + }]) + + const beforeRemoval = await service.getWorkbench('shrinking-roster') + expect(beforeRemoval.team.members.map(member => member.name)).toEqual([ + 'Lead Agent', + 'Worker Agent', + 'Observer Agent', + ]) + + await writeTeamConfig('shrinking-roster', { + ...initialConfig, + members: [ + (initialConfig.members as Array>)[0], + (initialConfig.members as Array>)[2], + ], + }) + const afterRemoval = await service.getWorkbench('shrinking-roster') + expect(afterRemoval.team.members.map(member => member.name)).toEqual([ + 'Lead Agent', + 'Observer Agent', + ]) + + // The mutable team directory still exists during shutdown, but the member + // has already left its live roster. Transcript lookup must fall back to the + // archive in this intermediate state rather than waiting for TeamDelete. + const liveShrinkingTranscript = await service.getMemberTranscriptPage( + 'shrinking-roster', + 'agent-worker', + { + leadSessionId, + incarnationId: beforeRemoval.team.incarnationId, + }, + ) + expect(liveShrinkingTranscript.messages.map(message => message.id)).toEqual([ + 'worker-before-roster-removal', + ]) + + await service.markWorkbenchArchiveDeleted( + 'shrinking-roster', + leadSessionId, + beforeRemoval.team.incarnationId, + ) + const archivePath = path.join( + tmpDir, + 'cc-haha', + 'agent-teams', + `${crypto.createHash('sha256').update(leadSessionId).digest('hex')}.json`, + ) + const archive = JSON.parse(await fs.readFile(archivePath, 'utf8')) as { + teams: Array<{ snapshots: TeamWorkbenchSnapshot[] }> + } + expect(archive.teams[0]?.snapshots.at(-1)?.team.members.map(member => member.name)).toContain( + 'Worker Agent', + ) + + // Recreate the pre-fix persisted transition: early snapshots know the + // worker, but shutdown removes it from every later roster while its task + // keeps the durable owner string. + archive.teams[0]!.snapshots = archive.teams[0]!.snapshots.map((snapshot, index) => ( + index === 0 + ? snapshot + : { + ...snapshot, + team: { + ...snapshot.team, + memberCount: snapshot.team.memberCount - 1, + members: snapshot.team.members.filter(member => member.name !== 'Worker Agent'), + }, + } + )) + const orphanedVersion = archive.teams[0]!.snapshots.at(-1)!.version + await fs.writeFile(archivePath, JSON.stringify(archive), 'utf8') + await fs.rm(path.join(tmpDir, 'teams', 'shrinking-roster'), { recursive: true, force: true }) + await fs.rm(path.join(tmpDir, 'tasks', 'shrinking-roster'), { recursive: true, force: true }) + + const timeline = await service.getWorkbenchForSession(leadSessionId) + const completed = timeline?.snapshots.at(-1) + expect(completed?.team.members.map(member => ({ + name: member.name, + status: member.status, + }))).toEqual([ + { name: 'Lead Agent', status: 'completed' }, + { name: 'Worker Agent', status: 'completed' }, + { name: 'Observer Agent', status: 'completed' }, + ]) + expect(completed?.team.memberCount).toBe(3) + expect(completed?.version).not.toBe(orphanedVersion) + expect(completed?.tasks[0]).toMatchObject({ + status: 'completed', + owner: 'Worker Agent', + }) + + const transcript = await service.getMemberTranscriptPage( + 'shrinking-roster', + 'agent-worker', + { + leadSessionId, + incarnationId: beforeRemoval.team.incarnationId, + }, + ) + expect(transcript.messages.map(message => message.id)).toEqual([ + 'worker-before-roster-removal', + ]) + + const reopenedAgain = await service.getWorkbenchForSession(leadSessionId) + expect(reopenedAgain?.snapshots.at(-1)).toEqual(completed) + }) + it('keeps same-name incarnations separate when an old delete arrives late', async () => { const oldConfig = makeTeamConfig({ name: 'recreated-team', @@ -1254,6 +2986,247 @@ describe('TeamService', () => { expect(snapshot?.generatedAt).toBe('2026-08-08T00:00:05.000Z') }) + it('keeps legacy dependency edges when TaskList fails or has no result', () => { + const messages: MessageEntry[] = [ + transcriptToolUse('edge-team-create', 'TeamCreate', { + team_name: 'legacy-edge-failure-team', + }, '2026-08-08T00:05:00.000Z'), + transcriptToolResult('edge-team-create', { + success: true, + team_name: 'legacy-edge-failure-team', + lead_agent_id: 'team-lead@legacy-edge-failure-team', + }, '2026-08-08T00:05:00.100Z'), + transcriptToolUse('edge-task-one', 'TaskCreate', { + subject: 'Keep the forward edge', + }, '2026-08-08T00:05:01.000Z'), + transcriptToolResult('edge-task-one', { + success: true, + task: { id: '1', subject: 'Keep the forward edge' }, + }, '2026-08-08T00:05:01.100Z'), + transcriptToolUse('edge-task-two', 'TaskCreate', { + subject: 'Keep the reverse edge', + }, '2026-08-08T00:05:02.000Z'), + transcriptToolResult('edge-task-two', { + success: true, + task: { id: '2', subject: 'Keep the reverse edge' }, + }, '2026-08-08T00:05:02.100Z'), + transcriptToolUse('add-edge', 'TaskUpdate', { + taskId: '1', + addBlocks: ['2'], + }, '2026-08-08T00:05:03.000Z'), + transcriptToolResult('add-edge', { + success: true, + }, '2026-08-08T00:05:03.100Z'), + transcriptToolUse('failed-edge-list', 'TaskList', {}, '2026-08-08T00:05:04.000Z'), + transcriptToolResult('failed-edge-list', { + success: false, + tasks: [{ + id: '1', + subject: 'Keep the forward edge', + status: 'pending', + blockedBy: [], + }, { + id: '2', + subject: 'Keep the reverse edge', + status: 'pending', + blockedBy: [], + }], + }, '2026-08-08T00:05:04.100Z'), + transcriptToolUse('missing-edge-list', 'TaskList', {}, '2026-08-08T00:05:05.000Z'), + ] + + const [snapshot] = projectTeamWorkbenchesFromTranscript('legacy-session', messages) + + expect(snapshot?.tasks).toEqual([ + expect.objectContaining({ id: '1', blocks: ['2'], blockedBy: [] }), + expect.objectContaining({ id: '2', blocks: [], blockedBy: ['1'] }), + ]) + }) + + it('treats legacy TaskList ownership and successful task deletion as authoritative', () => { + const messages: MessageEntry[] = [ + transcriptToolUse('team-create', 'TeamCreate', { + team_name: 'legacy-task-terminal-team', + }, '2026-08-08T00:10:00.000Z'), + transcriptToolResult('team-create', { + success: true, + team_name: 'legacy-task-terminal-team', + lead_agent_id: 'team-lead@legacy-task-terminal-team', + }, '2026-08-08T00:10:00.100Z'), + transcriptToolUse('ownerless-task', 'TaskCreate', { + subject: 'Clear a stale owner', + }, '2026-08-08T00:10:01.000Z'), + transcriptToolResult('ownerless-task', { + success: true, + task: { id: '1', subject: 'Clear a stale owner' }, + }, '2026-08-08T00:10:01.100Z'), + transcriptToolUse('assign-owner', 'TaskUpdate', { + taskId: '1', + owner: 'exited-worker', + status: 'in_progress', + }, '2026-08-08T00:10:02.000Z'), + transcriptToolResult('assign-owner', { + success: true, + }, '2026-08-08T00:10:02.100Z'), + transcriptToolUse('full-ownerless-list', 'TaskList', {}, '2026-08-08T00:10:03.000Z'), + transcriptToolResult('full-ownerless-list', { + success: true, + tasks: [{ id: '1', subject: 'Clear a stale owner', status: 'pending' }], + }, '2026-08-08T00:10:03.100Z'), + transcriptToolUse('deleted-task', 'TaskCreate', { + subject: 'Disappear after successful deletion', + }, '2026-08-08T00:10:04.000Z'), + transcriptToolResult('deleted-task', { + success: true, + task: { id: '2', subject: 'Disappear after successful deletion' }, + }, '2026-08-08T00:10:04.100Z'), + transcriptToolUse('delete-task', 'TaskUpdate', { + taskId: '2', + status: 'deleted', + }, '2026-08-08T00:10:05.000Z'), + transcriptToolResult('delete-task', { + success: true, + }, '2026-08-08T00:10:05.100Z'), + transcriptToolUse('kept-task', 'TaskCreate', { + subject: 'Remain after failed deletion', + }, '2026-08-08T00:10:06.000Z'), + transcriptToolResult('kept-task', { + success: true, + task: { id: '3', subject: 'Remain after failed deletion' }, + }, '2026-08-08T00:10:06.100Z'), + transcriptToolUse('failed-delete-task', 'TaskUpdate', { + taskId: '3', + status: 'deleted', + }, '2026-08-08T00:10:07.000Z'), + transcriptToolResult('failed-delete-task', { + success: false, + }, '2026-08-08T00:10:07.100Z'), + ] + + const [snapshot] = projectTeamWorkbenchesFromTranscript('legacy-session', messages) + + expect(snapshot?.tasks.map(task => ({ + id: task.id, + owner: task.owner, + status: task.status, + }))).toEqual([{ + id: '1', + owner: undefined, + status: 'pending', + }, { + id: '3', + owner: undefined, + status: 'pending', + }]) + }) + + it('uses final Team identities and ignores structured failures when rebuilding old histories', () => { + const messages: MessageEntry[] = [ + transcriptToolUse('team-create', 'TeamCreate', { + team_name: 'requested-history-team', + }, '2026-08-08T01:00:00.000Z'), + transcriptToolResult('team-create', { + success: true, + team_name: 'requested-history-team-2', + lead_agent_id: 'team-lead@requested-history-team-2', + }, '2026-08-08T01:00:00.100Z'), + // AgentTool resolves a named Agent against the active Team even when the + // model omits team_name. An ordinary unnamed Agent remains a SubAgent. + transcriptToolUse('spawn-reviewer', 'Agent', { + name: 'reviewer', + description: 'Review the rebuilt history', + }, '2026-08-08T01:00:01.000Z'), + transcriptToolResult('spawn-reviewer', { + success: true, + agent_id: 'reviewer@requested-history-team-2', + }, '2026-08-08T01:00:01.100Z'), + transcriptToolUse('direct-agent', 'Agent', { + description: 'Remain a direct SubAgent', + }, '2026-08-08T01:00:01.200Z'), + transcriptToolResult('direct-agent', { + success: true, + agent_id: 'direct-agent-id', + }, '2026-08-08T01:00:01.300Z'), + transcriptToolUse('task-create', 'TaskCreate', { + subject: 'Keep the active Team after a failed delete', + }, '2026-08-08T01:00:02.000Z'), + transcriptToolResult('task-create', { + success: true, + task: { id: '1', subject: 'Keep the active Team after a failed delete' }, + }, '2026-08-08T01:00:02.100Z'), + transcriptToolUse('failed-task-update', 'TaskUpdate', { + taskId: '1', + owner: 'wrong-owner', + status: 'completed', + }, '2026-08-08T01:00:03.000Z'), + transcriptToolResult('failed-task-update', { + success: false, + }, '2026-08-08T01:00:03.100Z'), + transcriptToolUse('failed-team-delete', 'TeamDelete', {}, '2026-08-08T01:00:04.000Z'), + transcriptToolResult('failed-team-delete', { + success: false, + team_name: 'requested-history-team-2', + }, '2026-08-08T01:00:04.100Z'), + transcriptToolUse('successful-task-update', 'TaskUpdate', { + taskId: '1', + owner: 'reviewer', + status: 'completed', + }, '2026-08-08T01:00:05.000Z'), + transcriptToolResult('successful-task-update', { + success: true, + }, '2026-08-08T01:00:05.100Z'), + ] + + const [snapshot] = projectTeamWorkbenchesFromTranscript('legacy-session', messages) + + expect(snapshot?.team.name).toBe('requested-history-team-2') + expect(snapshot?.team.members.map(member => member.name)).toEqual([ + 'team-lead', + 'reviewer', + ]) + expect(snapshot?.tasks).toEqual([ + expect.objectContaining({ id: '1', owner: 'reviewer', status: 'completed' }), + ]) + }) + + it('preserves every same-name Team incarnation in a legacy transcript', () => { + const createTeam = (id: string, timestamp: string): MessageEntry[] => [ + transcriptToolUse(`${id}-create`, 'TeamCreate', { + team_name: 'reused-history-team', + }, timestamp), + transcriptToolResult(`${id}-create`, { + success: true, + team_name: 'reused-history-team', + lead_agent_id: 'team-lead@reused-history-team', + }, new Date(Date.parse(timestamp) + 1).toISOString()), + transcriptToolUse(`${id}-task`, 'TaskCreate', { + subject: `${id} task`, + }, new Date(Date.parse(timestamp) + 2).toISOString()), + transcriptToolResult(`${id}-task`, { + success: true, + task: { id: '1', subject: `${id} task` }, + }, new Date(Date.parse(timestamp) + 3).toISOString()), + ] + const messages: MessageEntry[] = [ + ...createTeam('first', '2026-08-08T02:00:00.000Z'), + transcriptToolUse('first-delete', 'TeamDelete', {}, '2026-08-08T02:00:01.000Z'), + transcriptToolResult('first-delete', { + success: true, + team_name: 'reused-history-team', + }, '2026-08-08T02:00:01.001Z'), + ...createTeam('second', '2026-08-08T02:00:02.000Z'), + ] + + const snapshots = projectTeamWorkbenchesFromTranscript('legacy-session', messages) + + expect(snapshots).toHaveLength(2) + expect(snapshots.map(snapshot => snapshot.tasks[0]?.subject)).toEqual([ + 'first task', + 'second task', + ]) + expect(new Set(snapshots.map(snapshot => snapshot.team.incarnationId)).size).toBe(2) + }) + it('should derive running status when isActive is undefined', async () => { const config = makeTeamConfig({ name: 'undef-team' }) // Remove isActive from the first member to simulate undefined @@ -2038,26 +4011,61 @@ describe('TeamService', () => { // deleteTeam // -------------------------------------------------------------------------- - it('should delete a team with no active members', async () => { - const config = makeTeamConfig({ name: 'deletable' }) - // Set all members to inactive - for (const member of config.members) { - ;(member as Record).isActive = false - } + it('should delete a team after every teammate has been removed', async () => { + const leadSessionId = 'deletable-lead-session' + const config = makeTeamConfig({ name: 'deletable', leadSessionId }) + config.members = config.members.filter( + member => member.agentId === config.leadAgentId, + ) + config.members[0]!.isActive = false await writeTeamConfig('deletable', config) + await writeTeamTask('deletable', { + id: '1', + subject: 'Archive the API deletion', + description: 'The task directory and final DAG must move together', + owner: 'Worker Agent', + status: 'completed', + blocks: [], + blockedBy: [], + }) await service.deleteTeam('deletable') - // Team dir should be gone const teamDir = path.join(tmpDir, 'teams', 'deletable') - expect(fs.access(teamDir)).rejects.toThrow() + const tasksDir = path.join(tmpDir, 'tasks', 'deletable') + await expect(fs.access(teamDir)).rejects.toThrow() + await expect(fs.access(tasksDir)).rejects.toThrow() + const reopened = await service.getWorkbenchForSession(leadSessionId) + expect(reopened?.snapshots.at(-1)).toMatchObject({ + deletedAt: expect.any(String), + terminalTaskFrameId: expect.stringContaining('lifecycle:'), + tasks: [{ + id: '1', + subject: 'Archive the API deletion', + status: 'completed', + }], + }) }) - it('should refuse to delete a team with active members', async () => { - await writeTeamConfig('active-team', makeTeamConfig({ name: 'active-team' })) + it('should refuse to delete a team while an idle teammate remains registered', async () => { + const config = makeTeamConfig({ name: 'active-team' }) + for (const member of config.members) member.isActive = false + await writeTeamConfig('active-team', config) expect(service.deleteTeam('active-team')).rejects.toThrow( - 'has active members', + 'teammates remain registered', + ) + }) + + it('should refuse to delete a lead-only team while its lead is active', async () => { + const config = makeTeamConfig({ name: 'active-lead-team' }) + config.members = config.members.filter( + member => member.agentId === config.leadAgentId, + ) + await writeTeamConfig('active-lead-team', config) + + expect(service.deleteTeam('active-lead-team')).rejects.toThrow( + 'lead is active', ) }) @@ -2585,9 +4593,10 @@ describe('Teams API', () => { it('DELETE /api/teams/:name should delete team', async () => { const config = makeTeamConfig({ name: 'del-team' }) - for (const member of (config as { members: Array> }).members) { - member.isActive = false - } + config.members = config.members.filter( + member => member.agentId === config.leadAgentId, + ) + config.members[0]!.isActive = false await writeTeamConfig('del-team', config) const res = await fetch(`${baseUrl}/api/teams/del-team`, { @@ -2603,8 +4612,10 @@ describe('Teams API', () => { expect(res2.status).toBe(404) }) - it('DELETE /api/teams/:name should 409 when team has active members', async () => { - await writeTeamConfig('active', makeTeamConfig({ name: 'active' })) + it('DELETE /api/teams/:name should 409 when an idle teammate remains registered', async () => { + const config = makeTeamConfig({ name: 'active' }) + for (const member of config.members) member.isActive = false + await writeTeamConfig('active', config) const res = await fetch(`${baseUrl}/api/teams/active`, { method: 'DELETE', @@ -2612,6 +4623,19 @@ describe('Teams API', () => { expect(res.status).toBe(409) }) + it('DELETE /api/teams/:name should 409 for a lead-only active Team', async () => { + const config = makeTeamConfig({ name: 'active-lead' }) + config.members = config.members.filter( + member => member.agentId === config.leadAgentId, + ) + await writeTeamConfig('active-lead', config) + + const res = await fetch(`${baseUrl}/api/teams/active-lead`, { + method: 'DELETE', + }) + expect(res.status).toBe(409) + }) + it('POST /api/teams should return 405', async () => { const res = await fetch(`${baseUrl}/api/teams`, { method: 'POST' }) expect(res.status).toBe(405) diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts index b968355a..8d946c7a 100644 --- a/src/server/services/teamService.ts +++ b/src/server/services/teamService.ts @@ -23,7 +23,15 @@ import { localIndexCoordinator } from './localIndex/coordinator.js' import { readSessionEntriesByLocator } from './localIndex/sessionEntries.js' import { deserializeSourceFingerprint } from './localIndex/sourceFingerprint.js' import type { LocalIndexGateway } from './localIndex/sessionIndex.js' -import { taskService, type TaskInfo } from './taskService.js' +import { + getCanonicalTeamTaskListId, + readTaskListLifecycleState, + readTaskListSnapshot, + withTaskListLifecycleLock, +} from '../../utils/tasks.js' +import type { TaskListLifecycleState } from '../../utils/tasks.js' +import { cleanupTeamDirectories } from '../../utils/swarm/teamHelpers.js' +import type { TaskInfo } from './taskService.js' // ─── Types ───────────────────────────────────────────────────────────────── @@ -72,6 +80,8 @@ export type TeamWorkbenchMessage = { export type TeamWorkbenchSnapshot = { version: string generatedAt: string + taskListRevision?: number + terminalTaskFrameId?: string team: TeamDetail tasks: TaskInfo[] messages: TeamWorkbenchMessage[] @@ -197,6 +207,61 @@ type ProjectedTeamState = { deletedAt?: string } +function memberArchiveIdentity(member: TeamMember): string { + return member.agentId || member.name +} + +/** + * CLI team config is a live roster and removes a teammate as soon as it exits. + * A workbench archive is a run history, so dropping that member would also + * orphan every durable task owner and make the member transcript unreachable. + */ +function carryForwardArchivedMembers( + snapshot: TeamWorkbenchSnapshot, + previous: TeamWorkbenchSnapshot | undefined, +): TeamWorkbenchSnapshot { + if (!previous) return snapshot + + const currentByIdentity = new Map( + snapshot.team.members.map(member => [memberArchiveIdentity(member), member]), + ) + const historicalIdentities = new Set() + const members = previous.team.members.map((historicalMember) => { + const identity = memberArchiveIdentity(historicalMember) + historicalIdentities.add(identity) + const currentMember = currentByIdentity.get(identity) + if (currentMember) return { ...historicalMember, ...currentMember } + return { + ...historicalMember, + status: historicalMember.status === 'failed' + ? 'failed' as const + : 'completed' as const, + } + }) + for (const member of snapshot.team.members) { + if (!historicalIdentities.has(memberArchiveIdentity(member))) members.push(member) + } + + const team = { + ...snapshot.team, + memberCount: members.length, + activeMemberCount: members.filter(member => member.status === 'running').length, + members, + } + if (JSON.stringify(team) === JSON.stringify(snapshot.team)) return snapshot + + return { + ...snapshot, + version: hash(JSON.stringify({ + team, + tasks: snapshot.tasks, + messages: snapshot.messages, + deletedAt: snapshot.deletedAt, + })), + team, + } +} + function hash(bytes: Buffer | string): string { return crypto.createHash('sha256').update(bytes).digest('hex') } @@ -405,10 +470,16 @@ function taskStatus(value: unknown): TaskInfo['status'] | undefined { : undefined } +type ProjectedToolResult = { + value: Record + isError: boolean + timestamp?: string +} + function resultRecordsByToolUseId( messages: SessionMessageEntry[], -): Map; isError: boolean }> { - const results = new Map; isError: boolean }>() +): Map { + const results = new Map() for (const message of messages) { if (message.type !== 'tool_result') continue const blocks = Array.isArray(message.content) ? message.content : [] @@ -421,6 +492,7 @@ function resultRecordsByToolUseId( results.set(toolUseId, { value: structured ?? record!, isError: previous?.isError === true || record?.is_error === true, + timestamp: message.timestamp || previous?.timestamp, }) } } @@ -463,6 +535,68 @@ function applyProjectedTaskUpdate(task: TaskInfo, input: Record if (metadata) task.metadata = { ...(task.metadata ?? {}), ...metadata } } +function replaceProjectedTaskList( + tasks: Map, + rawTasks: unknown[], + taskListId: string, + edgeShape: 'complete' | 'blockedBy-only' = 'complete', +): void { + const listedTaskRecords = rawTasks + .map(rawTask => objectValue(rawTask)) + .filter((record): record is Record => ( + record !== null && stringValue(record.id) !== undefined + )) + // TaskList historically serializes only blockedBy. Treat that shape as a + // complete edge projection when every task carries the field: clear the + // stale forward edges first, then finalizeProjectedTaskEdges rebuilds them + // from the authoritative reverse edges. Older partial results that omitted + // blockedBy entirely retain their best-effort compatibility behavior. + const rebuildEdgesFromBlockedBy = edgeShape === 'blockedBy-only' && + listedTaskRecords.every(record => Array.isArray(record.blockedBy)) + const listedIds = new Set() + for (const record of listedTaskRecords) { + const id = stringValue(record?.id) + if (!id) continue + listedIds.add(id) + const task = tasks.get(id) ?? { + id, + subject: `Task #${id}`, + description: '', + status: 'pending' as const, + blocks: [], + blockedBy: [], + taskListId, + } + const status = taskStatus(record.status) + const subject = stringValue(record.subject) + const description = typeof record.description === 'string' + ? record.description + : undefined + const activeForm = stringValue(record.activeForm) + const owner = stringValue(record.owner) + if (status) task.status = status + if (subject) task.subject = subject + if (description !== undefined) task.description = description + if (activeForm !== undefined) task.activeForm = activeForm + // TaskList and TeamDelete terminal frames are authoritative full-list + // reads. An omitted owner must clear a stale archived assignment. + task.owner = owner + if (rebuildEdgesFromBlockedBy) { + task.blocks = [] + task.blockedBy = stringArray(record.blockedBy) + } else { + if (Array.isArray(record.blocks)) task.blocks = stringArray(record.blocks) + if (Array.isArray(record.blockedBy)) task.blockedBy = stringArray(record.blockedBy) + } + const metadata = objectValue(record.metadata) + if (metadata) task.metadata = metadata + tasks.set(id, task) + } + for (const id of tasks.keys()) { + if (!listedIds.has(id)) tasks.delete(id) + } +} + function finalizeProjectedTaskEdges(tasks: Map): void { for (const task of tasks.values()) { for (const blockedId of task.blocks) { @@ -476,6 +610,377 @@ function finalizeProjectedTaskEdges(tasks: Map): void { } } +const TEAM_CREATE_TRANSCRIPT_CLOCK_SKEW_MS = 5_000 + +type OrderedSessionMessage = { + message: SessionMessageEntry + index: number +} + +type TranscriptTeamCreation = { + position: number + teamName: string + startedAt: number + completedAt: number +} + +function orderedSessionMessages( + messages: SessionMessageEntry[], +): OrderedSessionMessage[] { + return messages + .map((message, index) => ({ message, index })) + .sort((left, right) => ( + Date.parse(left.message.timestamp) - Date.parse(right.message.timestamp) || + left.index - right.index + )) +} + +function successfulToolResult( + result: ProjectedToolResult | undefined, +): boolean { + return Boolean( + result && + !result.isError && + result.value.success !== false, + ) +} + +function transcriptTeamCreations( + ordered: OrderedSessionMessage[], + results: ReturnType, +): TranscriptTeamCreation[] { + const creations: TranscriptTeamCreation[] = [] + for (let position = 0; position < ordered.length; position++) { + const message = ordered[position]?.message + if (message?.type !== 'tool_use' || !Array.isArray(message.content)) continue + for (const block of message.content) { + const tool = objectValue(block) + if (tool?.type !== 'tool_use' || stringValue(tool.name) !== 'TeamCreate') continue + const toolUseId = stringValue(tool.id) + if (!toolUseId) continue + const result = results.get(toolUseId) + if (!result || !successfulToolResult(result)) continue + const input = objectValue(tool.input) ?? {} + // TeamCreate may uniquify a requested name. The successful result owns + // the durable identity used by the config, task list, and archive. + const teamName = stringValue(result.value.team_name) ?? stringValue(input.team_name) + const startedAt = Date.parse(message.timestamp) + const completedAt = result.timestamp ? Date.parse(result.timestamp) : startedAt + if (!teamName || !Number.isFinite(startedAt) || !Number.isFinite(completedAt)) continue + creations.push({ position, teamName, startedAt, completedAt }) + } + } + return creations +} + +function transcriptCreationDistance( + creation: TranscriptTeamCreation, + createdAt: number, +): number { + const lower = Math.min(creation.startedAt, creation.completedAt) + const upper = Math.max(creation.startedAt, creation.completedAt) + if (createdAt < lower) return lower - createdAt + if (createdAt > upper) return createdAt - upper + return 0 +} + +function transcriptTeamLifecycleEnd( + creation: TranscriptTeamCreation, + ordered: OrderedSessionMessage[], + results: ReturnType, + creations: TranscriptTeamCreation[], +): number { + const nextCreationPosition = creations.find(candidate => ( + candidate.position > creation.position + ))?.position ?? ordered.length + for (let position = creation.position + 1; position < nextCreationPosition; position++) { + const message = ordered[position]?.message + if (message?.type !== 'tool_use' || !Array.isArray(message.content)) continue + for (const block of message.content) { + const tool = objectValue(block) + if (tool?.type !== 'tool_use' || stringValue(tool.name) !== 'TeamDelete') continue + const toolUseId = stringValue(tool.id) + if (!toolUseId) continue + const result = results.get(toolUseId) + const input = objectValue(tool.input) + const deletedTeamName = stringValue(input?.team_name) ?? stringValue(result?.value.team_name) + if ( + successfulToolResult(result) && + (!deletedTeamName || deletedTeamName === creation.teamName) + ) return position + 1 + } + } + return nextCreationPosition +} + +function cloneTask(task: TaskInfo): TaskInfo { + return { + ...task, + blocks: [...task.blocks], + blockedBy: [...task.blockedBy], + ...(task.metadata ? { metadata: { ...task.metadata } } : {}), + } +} + +/** + * Replays only successful task mutations that completed after the last disk + * snapshot and before the next Team incarnation. TeamDelete can remove the + * task directory before TeamWatcher observes its final contents, while the + * lead transcript still has the durable TaskUpdate/TaskList results. + */ +type ReconciledArchivedTasks = { + tasks: TaskInfo[] + taskListRevision?: number + terminalTaskFrameId?: string +} + +function terminalLifecycleTasks( + snapshot: TeamWorkbenchSnapshot, + lifecycle: TaskListLifecycleState | undefined, +): ReconciledArchivedTasks | null { + if (!lifecycle) return null + const terminal = [...lifecycle.terminals].reverse().find(receipt => ( + receipt.identity.teamName === snapshot.team.name && + receipt.identity.createdAt === snapshot.team.createdAt && + receipt.identity.leadSessionId === snapshot.team.leadSessionId + )) + if (!terminal) return null + return { + tasks: terminal.tasks + .filter(task => !task.metadata?._internal) + .map(task => ({ + ...task, + blocks: [...task.blocks], + blockedBy: [...task.blockedBy], + taskListId: snapshot.team.name, + ...(task.metadata ? { metadata: { ...task.metadata } } : {}), + })) + .sort((left, right) => { + const leftNumber = Number.parseInt(left.id, 10) + const rightNumber = Number.parseInt(right.id, 10) + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + return leftNumber - rightNumber + } + return left.id.localeCompare(right.id) + }), + taskListRevision: terminal.revision, + terminalTaskFrameId: terminal.frameId, + } +} + +function preferTerminalTaskFrame( + transcript: ReconciledArchivedTasks | null, + lifecycle: ReconciledArchivedTasks | null, +): ReconciledArchivedTasks | null { + if (!lifecycle) return transcript + if (!transcript) return lifecycle + if ( + transcript.taskListRevision !== undefined && + lifecycle.taskListRevision !== undefined && + transcript.taskListRevision > lifecycle.taskListRevision + ) return transcript + return { + ...lifecycle, + terminalTaskFrameId: transcript.terminalTaskFrameId ?? + lifecycle.terminalTaskFrameId, + } +} + +function reconcileArchivedTasksFromTranscript( + snapshot: TeamWorkbenchSnapshot, + messages: SessionMessageEntry[], +): ReconciledArchivedTasks | null { + const snapshotGeneratedAt = Date.parse(snapshot.generatedAt) + const snapshotRevision = Number.isSafeInteger(snapshot.taskListRevision) && + (snapshot.taskListRevision ?? -1) >= 0 + ? snapshot.taskListRevision + : undefined + const teamCreatedAt = snapshot.team.createdAt + if (!Number.isFinite(snapshotGeneratedAt) || !Number.isFinite(teamCreatedAt)) return null + + const results = resultRecordsByToolUseId(messages) + const ordered = orderedSessionMessages(messages) + const creations = transcriptTeamCreations(ordered, results) + const creation = creations + .filter(candidate => candidate.teamName === snapshot.team.name) + .map(candidate => ({ + candidate, + distance: transcriptCreationDistance(candidate, teamCreatedAt), + })) + .filter(candidate => candidate.distance <= TEAM_CREATE_TRANSCRIPT_CLOCK_SKEW_MS) + .sort((left, right) => ( + left.distance - right.distance || + Math.abs(left.candidate.startedAt - teamCreatedAt) - + Math.abs(right.candidate.startedAt - teamCreatedAt) + ))[0]?.candidate + if (!creation) return null + + const endPosition = transcriptTeamLifecycleEnd(creation, ordered, results, creations) + const tasks = new Map(snapshot.tasks.map(task => [task.id, cloneTask(task)])) + let changed = false + + type ReplayEvent = { + name: string + toolUseId: string + input: Record + result: ProjectedToolResult + replayAt: number + revision?: number + order: number + } + const replayEvents: ReplayEvent[] = [] + + for (let position = creation.position; position < endPosition; position++) { + const message = ordered[position]?.message + if (message?.type !== 'tool_use' || !Array.isArray(message.content)) continue + for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) { + const block = message.content[blockIndex] + const tool = objectValue(block) + if (tool?.type !== 'tool_use') continue + const toolUseId = stringValue(tool.id) + const name = stringValue(tool.name) + const input = objectValue(tool.input) ?? {} + if (!toolUseId || !name) continue + const result = results.get(toolUseId) + if (!result || !successfulToolResult(result)) continue + const startedAt = Date.parse(message.timestamp) + const completedAt = result.timestamp ? Date.parse(result.timestamp) : Number.NaN + const causalMarker = name === 'TaskList' || name === 'TeamDelete' + ? stringValue(result.value.taskListSnapshotAt) + : stringValue(result.value.taskListMutationAt) + const rawRevision = name === 'TaskList' || name === 'TeamDelete' + ? result.value.taskListSnapshotRevision + : result.value.taskListMutationRevision + const revision = typeof rawRevision === 'number' && + Number.isSafeInteger(rawRevision) && rawRevision >= 0 + ? rawRevision + : undefined + const causalAt = causalMarker ? Date.parse(causalMarker) : Number.NaN + const hasCausalMarker = Number.isFinite(causalAt) + // New tool results carry the task-list lock timestamp. Legacy results + // lack it, so only replay operations whose invocation and completion are + // both strictly after the locked disk snapshot; a cross-boundary result + // is ambiguous and must not overwrite the known snapshot. + if (snapshotRevision !== undefined && revision !== undefined) { + if ( + revision < snapshotRevision || + (revision === snapshotRevision && name !== 'TeamDelete') + ) continue + } else if (hasCausalMarker) { + if (causalAt <= snapshotGeneratedAt) continue + } else if ( + !Number.isFinite(startedAt) || + !Number.isFinite(completedAt) || + startedAt <= snapshotGeneratedAt || + completedAt <= snapshotGeneratedAt + ) continue + replayEvents.push({ + name, + toolUseId, + input, + result, + replayAt: hasCausalMarker ? causalAt : completedAt, + revision, + order: position * 1_000 + blockIndex, + }) + } + } + + const allEventsHaveRevision = replayEvents.every( + event => event.revision !== undefined, + ) + replayEvents.sort((left, right) => { + if (allEventsHaveRevision) { + return left.revision! - right.revision! || left.order - right.order + } + return left.replayAt - right.replayAt || left.order - right.order + }) + + let appliedRevision = snapshotRevision + let terminalTaskFrameId: string | undefined + for (const { name, toolUseId, input, result, revision } of replayEvents) { + if (revision !== undefined) { + appliedRevision = Math.max(appliedRevision ?? revision, revision) + } + if (name === 'TaskCreate') { + const resultTask = objectValue(result.value.task) + const id = stringValue(resultTask?.id) + if (!id) continue + const task = tasks.get(id) ?? { + id, + subject: `Task #${id}`, + description: '', + status: 'pending' as const, + blocks: [], + blockedBy: [], + taskListId: snapshot.team.name, + } + applyProjectedTaskUpdate(task, input) + task.subject = stringValue(resultTask?.subject) ?? task.subject + const resultDescription = stringValue(resultTask?.description) + if (resultDescription !== undefined) task.description = resultDescription + tasks.set(id, task) + changed = true + continue + } + + if (name === 'TaskUpdate') { + const id = stringValue(input.taskId) + if (!id) continue + if (input.status === 'deleted') { + changed = tasks.delete(id) || changed + continue + } + const task = tasks.get(id) ?? { + id, + subject: `Task #${id}`, + description: '', + status: 'pending' as const, + blocks: [], + blockedBy: [], + taskListId: snapshot.team.name, + } + applyProjectedTaskUpdate(task, input) + tasks.set(id, task) + changed = true + continue + } + + const authoritativeTasks = name === 'TaskList' + ? result.value.tasks + : name === 'TeamDelete' + ? result.value.finalTasks + : undefined + if (Array.isArray(authoritativeTasks)) { + replaceProjectedTaskList( + tasks, + authoritativeTasks, + snapshot.team.name, + name === 'TaskList' ? 'blockedBy-only' : 'complete', + ) + if (name === 'TeamDelete') terminalTaskFrameId = toolUseId + changed = true + } + } + + if (!changed) return null + finalizeProjectedTaskEdges(tasks) + return { + tasks: [...tasks.values()].sort((left, right) => { + const leftNumber = Number.parseInt(left.id, 10) + const rightNumber = Number.parseInt(right.id, 10) + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + return leftNumber - rightNumber + } + return left.id.localeCompare(right.id) + }), + ...(appliedRevision !== undefined + ? { taskListRevision: appliedRevision } + : {}), + ...(terminalTaskFrameId ? { terminalTaskFrameId } : {}), + } +} + /** * Rebuilds a read-only final workbench from durable transcript identities. * This is the migration path for Team runs created before workbench archives @@ -492,31 +997,32 @@ export function projectTeamWorkbenchesFromTranscript( Date.parse(left.message.timestamp) - Date.parse(right.message.timestamp) || left.index - right.index )) - const teams = new Map() - let currentTeamName: string | null = null + // A lead session may delete and recreate the same Team name. Keep each + // creation as its own ordered incarnation instead of keying history by the + // mutable display name. + const teams: ProjectedTeamState[] = [] + let currentTeam: ProjectedTeamState | null = null for (const { message } of ordered) { const timestamp = timestampOrNow(message.timestamp) - if (message.type === 'user' && currentTeamName) { - const team = teams.get(currentTeamName) - if (team) { - const text = transcriptText(message.content) - const teammatePattern = /]*>([\s\S]*?)<\/teammate-message>/g - for (const match of text.matchAll(teammatePattern)) { - const body = match[3]?.trim() - if (!body) continue - team.messages.push({ - id: `${message.id}:teammate:${team.messages.length}`, - from: match[1]!, - to: 'team-lead', - recipients: ['team-lead'], - kind: 'direct', - text: body, - timestamp, - ...(match[2] ? { color: match[2] } : {}), - }) - team.updatedAt = timestamp - } + if (message.type === 'user' && currentTeam) { + const team = currentTeam + const text = transcriptText(message.content) + const teammatePattern = /]*>([\s\S]*?)<\/teammate-message>/g + for (const match of text.matchAll(teammatePattern)) { + const body = match[3]?.trim() + if (!body) continue + team.messages.push({ + id: `${message.id}:teammate:${team.messages.length}`, + from: match[1]!, + to: 'team-lead', + recipients: ['team-lead'], + kind: 'direct', + text: body, + timestamp, + ...(match[2] ? { color: match[2] } : {}), + }) + team.updatedAt = timestamp } } @@ -532,10 +1038,9 @@ export function projectTeamWorkbenchesFromTranscript( const result = toolResult?.value ?? {} if (name === 'TeamCreate') { - if (toolResult?.isError) continue - const teamName = stringValue(input.team_name) ?? stringValue(result.team_name) + if (toolResult && !successfulToolResult(toolResult)) continue + const teamName = stringValue(result.team_name) ?? stringValue(input.team_name) if (!teamName) continue - currentTeamName = teamName const leadAgentId = stringValue(result.lead_agent_id) ?? `team-lead@${teamName}` const team: ProjectedTeamState = { name: teamName, @@ -557,25 +1062,33 @@ export function projectTeamWorkbenchesFromTranscript( cwd: stringValue(message.cwd) ?? '', sessionId, }) - teams.set(teamName, team) + teams.push(team) + currentTeam = team continue } const explicitTeamName = stringValue(input.team_name) - const teamName = explicitTeamName ?? currentTeamName - if (!teamName) continue - const team = teams.get(teamName) + const team = currentTeam && (!explicitTeamName || currentTeam.name === explicitTeamName) + ? currentTeam + : [...teams].reverse().find(candidate => candidate.name === explicitTeamName) if (!team) continue + const teamName = team.name team.updatedAt = timestamp - if (toolResult?.isError) continue + // Old transcripts may omit tool results, so keep best-effort replay for + // that shape. An explicit non-error failure (`success: false`) is still + // authoritative and must never mutate the reconstructed Team. + if (toolResult && !successfulToolResult(toolResult)) continue if (name === 'TeamDelete') { + if (Array.isArray(result.finalTasks)) { + replaceProjectedTaskList(team.tasks, result.finalTasks, teamName) + } team.deletedAt = timestamp - if (currentTeamName === teamName) currentTeamName = null + if (currentTeam === team) currentTeam = null continue } - if (name === 'Agent' && explicitTeamName) { + if (name === 'Agent') { const memberName = stringValue(input.name) if (!memberName) continue const agentId = stringValue(result.agent_id) ?? @@ -610,24 +1123,17 @@ export function projectTeamWorkbenchesFromTranscript( if (name === 'TaskUpdate') { const id = stringValue(input.taskId) if (!id) continue + if (input.status === 'deleted') { + team.tasks.delete(id) + continue + } applyProjectedTaskUpdate(ensureProjectedTask(team, id), input) continue } if (name === 'TaskList') { - const listed = Array.isArray(result.tasks) ? result.tasks : [] - for (const rawTask of listed) { - const record = objectValue(rawTask) - const id = stringValue(record?.id) - if (!record || !id) continue - const task = ensureProjectedTask(team, id) - const status = taskStatus(record.status) - const subject = stringValue(record.subject) - const owner = stringValue(record.owner) - if (status) task.status = status - if (subject) task.subject = subject - if (owner) task.owner = owner - } + if (!Array.isArray(result.tasks)) continue + replaceProjectedTaskList(team.tasks, result.tasks, teamName, 'blockedBy-only') continue } @@ -654,7 +1160,7 @@ export function projectTeamWorkbenchesFromTranscript( } } - return [...teams.values()].map((team) => { + return teams.map((team) => { finalizeProjectedTaskEdges(team.tasks) const members = [...team.members.values()] const tasks = [...team.tasks.values()].sort((left, right) => { @@ -965,37 +1471,45 @@ export class TeamService { transcriptStartedAt = config.createdAt } catch (error) { configError = error - if (leadSessionId) { - const archive = await this.readArchiveDocument(leadSessionId) - const archivedEntry = this.archiveEntryForTeam( - archive, - teamName, - options.incarnationId, - ) - const archivedSnapshot = archivedEntry?.snapshots.at(-1) - const archivedMember = archivedSnapshot?.team.members.find((candidate) => ( + } + + // A live team config is only the current roster. Members disappear from it + // during shutdown before the team directory itself is removed, so a + // successful config load can still require the durable archive identity. + if (!memberName && leadSessionId) { + const archive = await this.readArchiveDocument(leadSessionId) + const archivedEntry = this.archiveEntryForTeam( + archive, + teamName, + options.incarnationId, + ) + const archivedSnapshot = archivedEntry?.snapshots.at(-1) + const archivedMember = archivedEntry?.snapshots + .slice() + .reverse() + .flatMap(snapshot => snapshot.team.members) + .find((candidate) => ( candidate.agentId === agentId || candidate.name === agentId )) - memberName = archivedMember?.name ?? null - memberSessionId = archivedMember?.sessionId - leadSessionId = archivedSnapshot?.team.leadSessionId ?? leadSessionId - transcriptStartedAt = archivedSnapshot?.team.createdAt - const deletedAt = archivedSnapshot?.deletedAt - ? Date.parse(archivedSnapshot.deletedAt) - : undefined - const nextIncarnationStartedAt = archive?.teams - .filter((entry) => entry.teamName === teamName) - .map((entry) => entry.snapshots.at(-1)?.team.createdAt) - .filter((createdAt): createdAt is number => ( - Number.isFinite(createdAt) && - Number.isFinite(transcriptStartedAt) && - createdAt! > transcriptStartedAt! - )) - .sort((left, right) => left - right)[0] - transcriptEndedAt = [deletedAt, nextIncarnationStartedAt] - .filter((value): value is number => Number.isFinite(value)) - .sort((left, right) => left - right)[0] - } + memberName = archivedMember?.name ?? null + memberSessionId = archivedMember?.sessionId + leadSessionId = archivedSnapshot?.team.leadSessionId ?? leadSessionId + transcriptStartedAt = archivedSnapshot?.team.createdAt + const deletedAt = archivedSnapshot?.deletedAt + ? Date.parse(archivedSnapshot.deletedAt) + : undefined + const nextIncarnationStartedAt = archive?.teams + .filter((entry) => entry.teamName === teamName) + .map((entry) => entry.snapshots.at(-1)?.team.createdAt) + .filter((createdAt): createdAt is number => ( + Number.isFinite(createdAt) && + Number.isFinite(transcriptStartedAt) && + createdAt! > transcriptStartedAt! + )) + .sort((left, right) => left - right)[0] + transcriptEndedAt = [deletedAt, nextIncarnationStartedAt] + .filter((value): value is number => Number.isFinite(value)) + .sort((left, right) => left - right)[0] } if (!memberName) { @@ -1141,29 +1655,47 @@ export class TeamService { * inbox entry as read or otherwise mutates CLI state. */ async getWorkbench(name: string): Promise { - const config = await this.loadTeamConfig(name) - const discoveredTeam = await this.getTeam(name) - const rosterIds = new Set(config.members.map((member) => member.agentId)) - const members = discoveredTeam.members.filter((member) => rosterIds.has(member.agentId)) - const team = { - ...discoveredTeam, - memberCount: members.length, - activeMemberCount: members.filter((member) => member.status === 'running').length, - members, - } - const tasks = await taskService.getTasksForList(name) - const messages = await this.readWorkbenchMessages(name) - const canonical = JSON.stringify({ team, tasks, messages }) + return withTaskListLifecycleLock(getCanonicalTeamTaskListId(name), async () => { + const config = await this.loadTeamConfig(name) + const taskListId = getCanonicalTeamTaskListId(config.name) + const discoveredTeam = await this.getTeam(name) + const rosterIds = new Set(config.members.map((member) => member.agentId)) + const members = discoveredTeam.members.filter((member) => rosterIds.has(member.agentId)) + const team = { + ...discoveredTeam, + memberCount: members.length, + activeMemberCount: members.filter((member) => member.status === 'running').length, + members, + } + const taskSnapshot = await readTaskListSnapshot(taskListId) + const tasks: TaskInfo[] = taskSnapshot.tasks + .filter(task => !task.metadata?._internal) + .map(task => ({ ...task, taskListId })) + .sort((left, right) => { + const leftNumber = Number.parseInt(left.id, 10) + const rightNumber = Number.parseInt(right.id, 10) + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + return leftNumber - rightNumber + } + return left.id.localeCompare(right.id) + }) + const messages = await this.readWorkbenchMessages(name) + const canonical = JSON.stringify({ team, tasks, messages }) - const snapshot: TeamWorkbenchSnapshot = { - version: hash(canonical), - generatedAt: new Date().toISOString(), - team, - tasks, - messages, - } - await this.archiveWorkbenchSnapshot(snapshot) - return snapshot + const snapshot: TeamWorkbenchSnapshot = { + version: hash(canonical), + // This is the task-list replay boundary, captured after the locked read. + // It orders transcript tail repair against the mutable DAG rather than + // against unrelated roster or mailbox I/O. + generatedAt: taskSnapshot.capturedAt, + taskListRevision: taskSnapshot.revision, + team, + tasks, + messages, + } + await this.archiveWorkbenchSnapshot(snapshot) + return snapshot + }) } /** @@ -1190,6 +1722,36 @@ export class TeamService { let archive = await this.readArchiveDocument(sessionId) let archived = this.archiveEntryForLookup(archive, lookup) + const archivedLatest = archived?.snapshots.at(-1) + let shouldRefreshArchive = Boolean( + archivedLatest?.deletedAt && !archivedLatest.terminalTaskFrameId, + ) + if ( + archivedLatest && + !archivedLatest.deletedAt && + !liveIncarnations.has(archivedLatest.team.incarnationId) + ) { + try { + const lifecycle = await readTaskListLifecycleState( + getCanonicalTeamTaskListId(archivedLatest.team.name), + ) + shouldRefreshArchive = Boolean( + terminalLifecycleTasks(archivedLatest, lifecycle), + ) + } catch { + // Malformed lifecycle state is never evidence that a live archive + // frame ended; legacy transcript/watcher paths remain available. + } + } + if (archived && shouldRefreshArchive) { + await this.markWorkbenchArchiveDeleted( + archived.teamName, + sessionId, + archived.incarnationId, + ) + archive = await this.readArchiveDocument(sessionId) + archived = this.archiveEntryForLookup(archive, lookup) + } if (archived) { return { sessionId, @@ -1244,15 +1806,48 @@ export class TeamService { const teams = [...current.teams] const index = teams.findIndex((entry) => entry.incarnationId === incarnationId) const existing = index >= 0 ? teams[index] : undefined - const snapshots = existing?.snapshots.at(-1)?.version === normalizedSnapshot.version - ? existing.snapshots - : [...(existing?.snapshots ?? []), normalizedSnapshot] + const snapshotWithRoster = carryForwardArchivedMembers( + normalizedSnapshot, + existing?.snapshots.at(-1), + ) + const latest = existing?.snapshots.at(-1) + const incomingAt = Date.parse(snapshotWithRoster.generatedAt) + const latestAt = latest ? Date.parse(latest.generatedAt) : Number.NEGATIVE_INFINITY + const incomingRevision = snapshotWithRoster.taskListRevision + const latestRevision = latest?.taskListRevision + const comparableRevisions = Number.isSafeInteger(incomingRevision) && + Number.isSafeInteger(latestRevision) + // Archive entries are a monotonic state machine. A delayed watcher read + // must never move the replay boundary backward, and no live snapshot may + // resurrect an incarnation after its deletion tombstone was committed. + if ( + (latest?.deletedAt && !snapshotWithRoster.deletedAt) || + (comparableRevisions && incomingRevision! < latestRevision!) || + ( + ( + !comparableRevisions || + incomingRevision === latestRevision + ) && + Number.isFinite(incomingAt) && + Number.isFinite(latestAt) && + ( + incomingAt < latestAt || + ( + incomingAt === latestAt && + latest?.version !== snapshotWithRoster.version + ) + ) + ) + ) return + const snapshots = latest?.version === snapshotWithRoster.version + ? [...existing.snapshots.slice(0, -1), snapshotWithRoster] + : [...(existing?.snapshots ?? []), snapshotWithRoster] .slice(-TEAM_WORKBENCH_ARCHIVE_HISTORY_LIMIT) const nextEntry: TeamWorkbenchArchiveEntry = { ...(existing ?? {}), - teamName: normalizedSnapshot.team.name, + teamName: snapshotWithRoster.team.name, incarnationId, - updatedAt: normalizedSnapshot.generatedAt, + updatedAt: snapshotWithRoster.generatedAt, snapshots, } if (index >= 0) teams[index] = nextEntry @@ -1273,24 +1868,116 @@ export class TeamService { incarnationId?: string, ): Promise { if (!sessionId) return - const document = await this.readArchiveDocument(sessionId) - const entry = this.archiveEntryForTeam(document, teamName, incarnationId) - const latest = entry?.snapshots.at(-1) - if (!latest || latest.deletedAt) return - const deletedAt = new Date().toISOString() - await this.archiveWorkbenchSnapshot({ - ...latest, - version: `${latest.version}:deleted`, - generatedAt: deletedAt, - deletedAt, - team: { - ...latest.team, - activeMemberCount: 0, - members: latest.team.members.map((member) => ({ - ...member, - status: 'completed' as const, - })), - }, + let messages: SessionMessageEntry[] | undefined + let lifecycle: TaskListLifecycleState | undefined + try { + messages = await this.sessionReader.getSessionMessages(sessionId) + } catch { + // The archive tombstone must still be written when a legacy or partially + // persisted lead transcript cannot be read. + } + try { + lifecycle = await readTaskListLifecycleState( + getCanonicalTeamTaskListId(teamName), + ) + } catch { + // A malformed lifecycle receipt is never trusted. Transcript repair and + // the last archived snapshot remain available as legacy fallbacks. + } + const filePath = this.getWorkbenchArchivePath(sessionId) + await this.withArchiveWriteLock(filePath, async () => { + const current = await this.readArchiveDocument(sessionId) + const entry = this.archiveEntryForTeam(current, teamName, incarnationId) + const latest = entry?.snapshots.at(-1) + if (!current || !entry || !latest) return + const base = latest.deletedAt + ? [...entry.snapshots].reverse().find(snapshot => !snapshot.deletedAt) + : latest + if (!base) return + const transcriptReconciled = messages + ? reconcileArchivedTasksFromTranscript(base, messages) + : null + const reconciled = preferTerminalTaskFrame( + transcriptReconciled, + terminalLifecycleTasks(base, lifecycle), + ) + + if (latest.deletedAt) { + // A watcher can observe directory deletion before the TeamDelete tool + // result is durable. The tombstone remains terminal, but a later GET + // or mark may enrich its payload from that authoritative final frame. + if (!reconciled?.terminalTaskFrameId) return + if ( + latest.terminalTaskFrameId === reconciled.terminalTaskFrameId && + (latest.taskListRevision ?? -1) >= + (reconciled.taskListRevision ?? -1) + ) return + if ( + latest.taskListRevision !== undefined && + reconciled.taskListRevision !== undefined && + reconciled.taskListRevision < latest.taskListRevision + ) return + const enriched: TeamWorkbenchSnapshot = { + ...latest, + version: `${base.version}:tasks:${hash(JSON.stringify(reconciled.tasks))}:deleted`, + tasks: reconciled.tasks, + ...(reconciled.taskListRevision !== undefined + ? { taskListRevision: reconciled.taskListRevision } + : {}), + terminalTaskFrameId: reconciled.terminalTaskFrameId, + } + const index = current.teams.indexOf(entry) + const teams = [...current.teams] + teams[index] = { + ...entry, + snapshots: [...entry.snapshots.slice(0, -1), enriched], + } + await this.writeArchiveDocument(filePath, { ...current, teams }) + return + } + + const reconciledLatest = reconciled + ? { + ...latest, + version: `${latest.version}:tasks:${hash(JSON.stringify(reconciled.tasks))}`, + tasks: reconciled.tasks, + ...(reconciled.taskListRevision !== undefined + ? { taskListRevision: reconciled.taskListRevision } + : {}), + ...(reconciled.terminalTaskFrameId + ? { terminalTaskFrameId: reconciled.terminalTaskFrameId } + : {}), + } + : latest + + const deletedAt = new Date().toISOString() + const tombstone: TeamWorkbenchSnapshot = { + ...reconciledLatest, + version: `${reconciledLatest.version}:deleted`, + generatedAt: deletedAt, + deletedAt, + team: { + ...reconciledLatest.team, + activeMemberCount: 0, + members: reconciledLatest.team.members.map((member) => ({ + ...member, + status: 'completed' as const, + })), + }, + } + const index = current.teams.indexOf(entry) + const teams = [...current.teams] + teams[index] = { + ...entry, + updatedAt: deletedAt, + snapshots: [...entry.snapshots, tombstone] + .slice(-TEAM_WORKBENCH_ARCHIVE_HISTORY_LIMIT), + } + await this.writeArchiveDocument(filePath, { + ...current, + updatedAt: deletedAt, + teams, + }) }) } @@ -1413,7 +2100,7 @@ export class TeamService { createdAt: Number.isFinite(createdAt) ? createdAt : 0, leadSessionId: stringValue(teamRecord.leadSessionId), }) - const snapshot = { + const rawSnapshot = { ...record, team: { ...teamRecord, @@ -1422,6 +2109,10 @@ export class TeamService { } as TeamWorkbenchSnapshot const key = `${teamName}\u0000${incarnationId}` const existing = groupedTeams.get(key) + const snapshot = carryForwardArchivedMembers( + rawSnapshot, + existing?.snapshots.at(-1), + ) const snapshots = existing?.snapshots.at(-1)?.version === snapshot.version ? existing.snapshots : [...(existing?.snapshots ?? []), snapshot] @@ -1481,25 +2172,32 @@ export class TeamService { // ── Delete team ───────────────────────────────────────────────────────── async deleteTeam(name: string): Promise { - const config = await this.loadTeamConfig(name) + return withTaskListLifecycleLock( + getCanonicalTeamTaskListId(name), + async () => { + const config = await this.loadTeamConfig(name) - const hasActive = config.members.some( - (m) => m.isActive === undefined || m.isActive === true, - ) - if (hasActive) { - throw ApiError.conflict( - `Cannot delete team "${name}": has active members`, + const remainingTeammates = config.members.filter( + member => member.agentId !== config.leadAgentId, ) - } + const lead = config.members.find( + member => member.agentId === config.leadAgentId, + ) + if (lead?.isActive !== false || remainingTeammates.length > 0) { + throw ApiError.conflict( + `Cannot delete team "${name}": lead is active or teammates remain registered`, + ) + } - await this.getWorkbench(name) - await this.markWorkbenchArchiveDeleted( - name, - config.leadSessionId, - teamIncarnationId(config), + await this.getWorkbench(name) + await cleanupTeamDirectories(config.name) + await this.markWorkbenchArchiveDeleted( + config.name, + config.leadSessionId, + teamIncarnationId(config), + ) + }, ) - const teamDir = path.join(this.getTeamsDir(), name) - await fs.rm(teamDir, { recursive: true, force: true }) } // ── Internal helpers ──────────────────────────────────────────────────── diff --git a/src/server/services/teamWatcher.ts b/src/server/services/teamWatcher.ts index 0a337bcf..e1870bab 100644 --- a/src/server/services/teamWatcher.ts +++ b/src/server/services/teamWatcher.ts @@ -30,6 +30,7 @@ function getTeamsDir(): string { } type WatchedTeamIdentity = { + teamName: string incarnationId: string createdAt: number leadSessionId?: string @@ -111,7 +112,7 @@ export class TeamWatcher { for (const [name] of this.lastSnapshots) { const identity = this.lastTeamIdentities.get(name) await this.archive.markWorkbenchArchiveDeleted( - name, + identity?.teamName ?? name, identity?.leadSessionId ?? this.lastLeadSessionIds.get(name), identity?.incarnationId, ).catch(() => {}) @@ -170,7 +171,7 @@ export class TeamWatcher { previousIdentity.incarnationId !== currentIdentity.incarnationId ) { await this.archive.markWorkbenchArchiveDeleted( - teamName, + previousIdentity.teamName, previousIdentity.leadSessionId, previousIdentity.incarnationId, ).catch(() => {}) @@ -245,7 +246,7 @@ export class TeamWatcher { const identity = this.lastTeamIdentities.get(name) const leadSessionId = identity?.leadSessionId ?? this.lastLeadSessionIds.get(name) await this.archive.markWorkbenchArchiveDeleted( - name, + identity?.teamName ?? name, leadSessionId, identity?.incarnationId, ).catch(() => {}) @@ -318,14 +319,18 @@ export class TeamWatcher { const leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId ? config.leadSessionId : undefined + const canonicalTeamName = typeof config.name === 'string' && config.name + ? config.name + : teamName const identity = { + teamName: canonicalTeamName, createdAt, ...(leadSessionId ? { leadSessionId } : {}), } return { ...identity, incarnationId: teamIncarnationId({ - name: teamName, + name: canonicalTeamName, createdAt, leadSessionId, }), diff --git a/src/tools/TaskCreateTool/TaskCreateTool.ts b/src/tools/TaskCreateTool/TaskCreateTool.ts index 6ed390a2..82efb383 100644 --- a/src/tools/TaskCreateTool/TaskCreateTool.ts +++ b/src/tools/TaskCreateTool/TaskCreateTool.ts @@ -6,7 +6,7 @@ import { } from '../../utils/hooks.js' import { lazySchema } from '../../utils/lazySchema.js' import { - createTask, + createTaskWithCommit, deleteTask, getTaskListId, isTodoV2Enabled, @@ -39,6 +39,8 @@ const outputSchema = lazySchema(() => id: z.string(), subject: z.string(), }), + taskListMutationAt: z.string().optional(), + taskListMutationRevision: z.number().int().nonnegative().optional(), }), ) type OutputSchema = ReturnType @@ -78,7 +80,7 @@ export const TaskCreateTool = buildTool({ return null }, async call({ subject, description, activeForm, metadata }, context) { - const taskId = await createTask(getTaskListId(context?.agentId), { + const creation = await createTaskWithCommit(getTaskListId(context?.agentId), { subject, description, activeForm, @@ -88,6 +90,7 @@ export const TaskCreateTool = buildTool({ blockedBy: [], metadata, }) + const taskId = creation.taskId const blockingErrors: string[] = [] const generator = executeTaskCreatedHooks( @@ -124,6 +127,8 @@ export const TaskCreateTool = buildTool({ id: taskId, subject, }, + taskListMutationAt: creation.committedAt, + taskListMutationRevision: creation.revision, }, } }, diff --git a/src/tools/TaskListTool/TaskListTool.ts b/src/tools/TaskListTool/TaskListTool.ts index 27cef8ad..16ddf95f 100644 --- a/src/tools/TaskListTool/TaskListTool.ts +++ b/src/tools/TaskListTool/TaskListTool.ts @@ -4,7 +4,7 @@ import { lazySchema } from '../../utils/lazySchema.js' import { getTaskListId, isTodoV2Enabled, - listTasks, + readTaskListSnapshot, TaskStatusSchema, } from '../../utils/tasks.js' import { TASK_LIST_TOOL_NAME } from './constants.js' @@ -24,6 +24,8 @@ const outputSchema = lazySchema(() => blockedBy: z.array(z.string()), }), ), + taskListSnapshotAt: z.string().optional(), + taskListSnapshotRevision: z.number().int().nonnegative().optional(), }), ) type OutputSchema = ReturnType @@ -65,7 +67,8 @@ export const TaskListTool = buildTool({ async call(_input, context) { const taskListId = getTaskListId(context?.agentId) - const allTasks = (await listTasks(taskListId)).filter( + const snapshot = await readTaskListSnapshot(taskListId) + const allTasks = snapshot.tasks.filter( t => !t.metadata?._internal, ) @@ -85,6 +88,8 @@ export const TaskListTool = buildTool({ return { data: { tasks, + taskListSnapshotAt: snapshot.capturedAt, + taskListSnapshotRevision: snapshot.revision, }, } }, diff --git a/src/tools/TaskTools.eager.test.ts b/src/tools/TaskTools.eager.test.ts index d5fd6e88..21f6b194 100644 --- a/src/tools/TaskTools.eager.test.ts +++ b/src/tools/TaskTools.eager.test.ts @@ -1,8 +1,35 @@ import { describe, expect, it } from 'bun:test' +import { AsyncResource } from 'node:async_hooks' +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ToolUseContext } from '../Tool.js' +import { getSessionCreatedTeams } from '../bootstrap/state.js' +import { + mutateTeamFileAsync, + removeTeammateFromTeamFile, +} from '../utils/swarm/teamHelpers.js' +import { + blockTask, + beginTaskListLifecycle, + claimTask, + clearLeaderTeamName, + createTaskWithCommit, + deleteTaskWithCommit, + getCanonicalTeamTaskListId, + getTasksDir, + readTaskListLifecycleState, + readTaskListSnapshot, + resetTaskList, + updateTask, + withTaskListLifecycleLock, +} from '../utils/tasks.js' import { TaskCreateTool } from './TaskCreateTool/TaskCreateTool.js' import { TaskGetTool } from './TaskGetTool/TaskGetTool.js' import { TaskListTool } from './TaskListTool/TaskListTool.js' import { TaskUpdateTool } from './TaskUpdateTool/TaskUpdateTool.js' +import { TeamDeleteTool } from './TeamDeleteTool/TeamDeleteTool.js' +import { TeamCreateTool } from './TeamCreateTool/TeamCreateTool.js' import { isDeferredTool } from './ToolSearchTool/prompt.js' describe('Task tool discovery', () => { @@ -24,4 +51,474 @@ describe('Task tool execution ordering', () => { expect(TaskGetTool.isConcurrencySafe({ taskId: '1' })).toBe(false) expect(TaskListTool.isConcurrencySafe({})).toBe(false) }) + + it('joins TeamCreate, shared TaskCreate, and TeamDelete through one lifecycle token', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'team-tool-lifecycle-')) + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.CLAUDE_CODE_TASK_LIST_ID + let appState: Record = { + expandedView: undefined, + inbox: { messages: [] }, + } + const context = { + abortController: new AbortController(), + getAppState: () => appState, + setAppState: (update: (prev: Record) => Record) => { + appState = update(appState) + }, + } as unknown as ToolUseContext + + try { + const createdTeam = await TeamCreateTool.call({ + team_name: 'My Tool Team', + description: 'Exercise the real lifecycle join', + }, context) + expect(createdTeam.data.team_name).toBe('My Tool Team') + const active = await readTaskListLifecycleState('my-tool-team') + expect(active).toMatchObject({ + generation: 1, + deleted: false, + activeIdentity: { + teamName: 'My Tool Team', + createdAt: expect.any(Number), + }, + }) + expect(getSessionCreatedTeams().get('My Tool Team')).toEqual({ + generation: 1, + identity: active.activeIdentity, + }) + + await mutateTeamFileAsync('My Tool Team', teamFile => ({ + ...teamFile, + members: [...teamFile.members, { + agentId: 'idle-worker@My Tool Team', + name: 'idle-worker', + agentType: 'worker', + model: 'claude-sonnet-4-20250514', + joinedAt: Date.now(), + tmuxPaneId: '', + cwd: configDir, + subscriptions: [], + isActive: false, + }], + })) + const deniedWhileIdle = await TeamDeleteTool.call({}, context) + expect(deniedWhileIdle.data).toMatchObject({ + success: false, + team_name: 'My Tool Team', + message: expect.stringContaining('registered teammate'), + }) + expect((await readTaskListLifecycleState('my-tool-team')).deleted).toBe(false) + expect(removeTeammateFromTeamFile('My Tool Team', { + agentId: 'idle-worker@My Tool Team', + })).toBe(true) + + const task = await TaskCreateTool.call({ + subject: 'Shared lifecycle task', + description: 'Must land in the Team task list', + activeForm: 'Testing lifecycle join', + }, context) + expect(task.data.taskListMutationRevision).toBe(1) + + const deleted = await TeamDeleteTool.call({}, context) + expect(deleted.data).toMatchObject({ + success: true, + team_name: 'My Tool Team', + taskListSnapshotRevision: 1, + finalTasks: [{ + id: task.data.task.id, + subject: 'Shared lifecycle task', + }], + }) + const terminal = await readTaskListLifecycleState('my-tool-team') + expect(terminal).toMatchObject({ + generation: 1, + deleted: true, + terminals: [{ + generation: 1, + revision: 1, + identity: { teamName: 'My Tool Team' }, + }], + }) + expect(getSessionCreatedTeams().has('My Tool Team')).toBe(false) + await expect(stat(getTasksDir('my-tool-team'))).rejects.toThrow() + } finally { + getSessionCreatedTeams().delete('My Tool Team') + clearLeaderTeamName() + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID + else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('atomically reserves distinct names for concurrent TeamCreate calls', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'team-create-race-')) + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.CLAUDE_CODE_TASK_LIST_ID + const states: Record[] = [ + { expandedView: undefined, inbox: { messages: [] } }, + { expandedView: undefined, inbox: { messages: [] } }, + ] + const contextFor = (index: number) => ({ + abortController: new AbortController(), + getAppState: () => states[index], + setAppState: ( + update: (prev: Record) => Record, + ) => { + states[index] = update(states[index]!) + }, + }) as unknown as ToolUseContext + const createdNames: string[] = [] + const firstResource = new AsyncResource('concurrent-team-create-1') + const secondResource = new AsyncResource('concurrent-team-create-2') + + try { + let pending!: Array> + await withTaskListLifecycleLock('concurrent-team', async () => { + // Both calls cross the old pre-lock existence check while the real + // candidate lock is held. Once released, only a lock-inside recheck + // can stop the second call from overwriting the first generation. + pending = [ + firstResource.runInAsyncScope(() => TeamCreateTool.call({ + team_name: 'Concurrent Team', + description: 'First independent leader', + }, contextFor(0))), + secondResource.runInAsyncScope(() => TeamCreateTool.call({ + team_name: 'Concurrent Team', + description: 'Second independent leader', + }, contextFor(1))), + ] + }) + const results = await Promise.all(pending) + createdNames.push(...results.map(result => result.data.team_name)) + + expect(new Set(createdNames).size).toBe(2) + expect(createdNames).toContain('Concurrent Team') + for (const name of createdNames) { + const taskListId = getCanonicalTeamTaskListId(name) + const lifecycle = await readTaskListLifecycleState(taskListId) + expect(lifecycle).toMatchObject({ + generation: 1, + deleted: false, + activeIdentity: { teamName: name }, + }) + expect(getSessionCreatedTeams().get(name)).toEqual({ + generation: 1, + identity: lifecycle.activeIdentity, + }) + await expect( + stat(join(configDir, 'teams', taskListId, 'config.json')), + ).resolves.toBeDefined() + } + } finally { + for (const name of createdNames) getSessionCreatedTeams().delete(name) + clearLeaderTeamName() + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID + else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('keeps the real Team tool chain off a standalone task-list override', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'team-tool-override-')) + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.CLAUDE_CODE_TASK_LIST_ID = 'standalone-explicit-list' + let appState: Record = { + expandedView: undefined, + inbox: { messages: [] }, + } + const context = { + abortController: new AbortController(), + getAppState: () => appState, + setAppState: (update: (prev: Record) => Record) => { + appState = update(appState) + }, + } as unknown as ToolUseContext + + try { + const standalone = await createTaskWithCommit('standalone-explicit-list', { + subject: 'Standalone task survives Team cleanup', + description: 'The Team lifecycle must never delete this list', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const created = await TeamCreateTool.call({ + team_name: 'Override Safe Team', + description: 'Use the canonical Team DAG', + }, context) + const teamTask = await TaskCreateTool.call({ + subject: 'Canonical Team task', + description: 'Must appear in the TeamDelete terminal frame', + }, context) + const deleted = await TeamDeleteTool.call({}, context) + + expect(deleted.data).toMatchObject({ + success: true, + team_name: created.data.team_name, + finalTasks: [{ + id: teamTask.data.task.id, + subject: 'Canonical Team task', + }], + }) + expect(await readTaskListSnapshot('standalone-explicit-list')).toMatchObject({ + tasks: [{ + id: standalone.taskId, + subject: 'Standalone task survives Team cleanup', + }], + }) + } finally { + getSessionCreatedTeams().delete('Override Safe Team') + clearLeaderTeamName() + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID + else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('advances the persistent task revision only for real list mutations', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'task-list-revision-')) + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = configDir + const taskListId = 'revision-contract' + + try { + await resetTaskList(taskListId) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(0) + const first = await createTaskWithCommit(taskListId, { + subject: 'First revision task', + description: 'Exercise mutation receipts', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const second = await createTaskWithCommit(taskListId, { + subject: 'Second revision task', + description: 'Exercise reciprocal edges', + status: 'pending', + blocks: [], + blockedBy: [], + }) + expect([first.revision, second.revision]).toEqual([1, 2]) + + await updateTask(taskListId, first.taskId, { + subject: 'First revision task', + }) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(2) + expect(await blockTask(taskListId, first.taskId, second.taskId)).toBe(true) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(3) + expect(await blockTask(taskListId, first.taskId, second.taskId)).toBe(true) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(3) + + expect((await claimTask(taskListId, first.taskId, 'worker')).success).toBe(true) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(4) + expect((await claimTask(taskListId, first.taskId, 'worker')).success).toBe(true) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(4) + + const deleted = await deleteTaskWithCommit(taskListId, first.taskId) + expect(deleted).toMatchObject({ deleted: true, revision: 5 }) + expect((await readTaskListSnapshot(taskListId))).toMatchObject({ + revision: 5, + tasks: [{ id: second.taskId, blockedBy: [] }], + }) + expect(await deleteTaskWithCommit(taskListId, first.taskId)).toEqual({ + deleted: false, + }) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(5) + + await resetTaskList(taskListId) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(6) + await resetTaskList(taskListId) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(6) + + const busyTarget = await createTaskWithCommit(taskListId, { + subject: 'Busy claim target', + description: 'The busy claim is one transaction', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const blockedTarget = await createTaskWithCommit(taskListId, { + subject: 'Busy claim rejection', + description: 'A rejected claim is not a mutation', + status: 'pending', + blocks: [], + blockedBy: [], + }) + expect((await claimTask( + taskListId, + busyTarget.taskId, + 'busy-worker', + { checkAgentBusy: true, markInProgress: true }, + )).success).toBe(true) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(9) + expect(await claimTask( + taskListId, + blockedTarget.taskId, + 'busy-worker', + { checkAgentBusy: true, markInProgress: true }, + )).toMatchObject({ success: false, reason: 'agent_busy' }) + expect((await readTaskListSnapshot(taskListId)).revision).toBe(9) + } finally { + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('fails closed on malformed lifecycle state and preserves forward fields', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'task-lifecycle-state-')) + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = configDir + const stateDir = join(configDir, 'tasks', '.lifecycle-locks') + + try { + await mkdir(stateDir, { recursive: true }) + await writeFile( + join(stateDir, 'malformed.state.json'), + JSON.stringify({ schemaVersion: 1, generation: 'bad' }), + ) + await expect(createTaskWithCommit('malformed', { + subject: 'Must not write through malformed state', + description: 'Lifecycle corruption is fail-closed', + status: 'pending', + blocks: [], + blockedBy: [], + })).rejects.toThrow('Invalid task-list lifecycle state') + await expect(stat(getTasksDir('malformed'))).rejects.toThrow() + + await writeFile( + join(stateDir, 'forward-state.state.json'), + JSON.stringify({ + schemaVersion: 1, + generation: 4, + deleted: true, + terminals: [], + futureField: { preserved: true }, + }), + ) + await beginTaskListLifecycle('forward-state', { + teamName: 'Forward State', + createdAt: 42, + }) + expect(await readTaskListLifecycleState('forward-state')).toMatchObject({ + generation: 5, + deleted: false, + futureField: { preserved: true }, + }) + + await resetTaskList('empty-revision') + await writeFile(join(getTasksDir('empty-revision'), '.revision'), ' ') + await expect(readTaskListSnapshot('empty-revision')).rejects.toThrow( + 'Invalid empty task-list revision', + ) + } finally { + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + await rm(configDir, { recursive: true, force: true }) + } + }) + + it('persists causal markers and the TeamDelete terminal task frame', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'task-tool-markers-')) + const taskListId = 'task-tool-marker-team' + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId + let appState: Record = { + expandedView: undefined, + inbox: { messages: [] }, + } + const context = { + abortController: new AbortController(), + getAppState: () => appState, + setAppState: (update: (prev: Record) => Record) => { + appState = update(appState) + }, + } as unknown as ToolUseContext + + try { + const created = await TaskCreateTool.call({ + subject: 'Persist causal identity', + description: 'Archive repair must order the real task-list commits', + activeForm: 'Persisting causal identity', + }, context) + const taskId = created.data.task.id + const dependent = await TaskCreateTool.call({ + subject: 'Observe reciprocal dependency', + description: 'The same transaction must update both task files', + activeForm: 'Observing reciprocal dependency', + }, context) + const listed = await TaskListTool.call({}, context) + const updated = await TaskUpdateTool.call({ + taskId, + status: 'in_progress', + addBlocks: [dependent.data.task.id], + }, context) + const afterUpdate = await TaskListTool.call({}, context) + + expect(Number.isFinite(Date.parse(created.data.taskListMutationAt ?? ''))).toBe(true) + expect(Number.isFinite(Date.parse(listed.data.taskListSnapshotAt ?? ''))).toBe(true) + expect(Number.isFinite(Date.parse(updated.data.taskListMutationAt ?? ''))).toBe(true) + expect(created.data.taskListMutationRevision).toBe(1) + expect(dependent.data.taskListMutationRevision).toBe(2) + expect(listed.data.taskListSnapshotRevision).toBe(2) + expect(updated.data.taskListMutationRevision).toBe(3) + expect(afterUpdate.data.taskListSnapshotRevision).toBe(3) + expect(listed.data.tasks).toEqual([ + expect.objectContaining({ id: taskId, status: 'pending' }), + expect.objectContaining({ id: dependent.data.task.id, status: 'pending' }), + ]) + expect(afterUpdate.data.tasks).toEqual([ + expect.objectContaining({ id: taskId, status: 'in_progress' }), + expect.objectContaining({ + id: dependent.data.task.id, + blockedBy: [taskId], + }), + ]) + + appState = { + ...appState, + teamContext: { teamName: taskListId }, + } + const deleted = await TeamDeleteTool.call({}, context) + expect(deleted.data).toMatchObject({ + success: true, + team_name: taskListId, + finalTasks: [ + expect.objectContaining({ + id: taskId, + status: 'in_progress', + blocks: [dependent.data.task.id], + }), + expect.objectContaining({ + id: dependent.data.task.id, + blockedBy: [taskId], + }), + ], + }) + expect(Number.isFinite(Date.parse(deleted.data.taskListSnapshotAt ?? ''))).toBe(true) + expect(deleted.data.taskListSnapshotRevision).toBe(3) + await expect(stat(getTasksDir(taskListId))).rejects.toThrow() + } finally { + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID + else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId + await rm(configDir, { recursive: true, force: true }) + } + }) }) diff --git a/src/tools/TaskUpdateTool/TaskUpdateTool.ts b/src/tools/TaskUpdateTool/TaskUpdateTool.ts index 92a4f13f..65ea1a32 100644 --- a/src/tools/TaskUpdateTool/TaskUpdateTool.ts +++ b/src/tools/TaskUpdateTool/TaskUpdateTool.ts @@ -9,15 +9,14 @@ import { } from '../../utils/hooks.js' import { lazySchema } from '../../utils/lazySchema.js' import { - blockTask, - deleteTask, + deleteTaskWithCommit, getTask, getTaskListId, isTodoV2Enabled, listTasks, - type TaskStatus, + type Task, TaskStatusSchema, - updateTask, + updateTaskAtomically, } from '../../utils/tasks.js' import { getAgentId, @@ -79,6 +78,8 @@ const outputSchema = lazySchema(() => }) .optional(), verificationNudgeNeeded: z.boolean().optional(), + taskListMutationAt: z.string().optional(), + taskListMutationRevision: z.number().int().nonnegative().optional(), }), ) type OutputSchema = ReturnType @@ -156,63 +157,11 @@ export const TaskUpdateTool = buildTool({ } const updatedFields: string[] = [] - - // Update basic fields if provided and different from current value - const updates: { - subject?: string - description?: string - activeForm?: string - status?: TaskStatus - owner?: string - metadata?: Record - } = {} - if (subject !== undefined && subject !== existingTask.subject) { - updates.subject = subject - updatedFields.push('subject') - } - if (description !== undefined && description !== existingTask.description) { - updates.description = description - updatedFields.push('description') - } - if (activeForm !== undefined && activeForm !== existingTask.activeForm) { - updates.activeForm = activeForm - updatedFields.push('activeForm') - } - if (owner !== undefined && owner !== existingTask.owner) { - updates.owner = owner - updatedFields.push('owner') - } - // Auto-set owner when a teammate marks a task as in_progress without - // explicitly providing an owner. This ensures the task list can match - // todo items to teammates for showing activity status. - if ( - isAgentSwarmsEnabled() && - status === 'in_progress' && - owner === undefined && - !existingTask.owner - ) { - const agentName = getAgentName() - if (agentName) { - updates.owner = agentName - updatedFields.push('owner') - } - } - if (metadata !== undefined) { - const merged = { ...(existingTask.metadata ?? {}) } - for (const [key, value] of Object.entries(metadata)) { - if (value === null) { - delete merged[key] - } else { - merged[key] = value - } - } - updates.metadata = merged - updatedFields.push('metadata') - } if (status !== undefined) { // Handle deletion - delete the task file and return early if (status === 'deleted') { - const deleted = await deleteTask(taskListId, taskId) + const deletion = await deleteTaskWithCommit(taskListId, taskId) + const deleted = deletion.deleted return { data: { success: deleted, @@ -222,11 +171,15 @@ export const TaskUpdateTool = buildTool({ statusChange: deleted ? { from: existingTask.status, to: 'deleted' } : undefined, + taskListMutationAt: deletion.committedAt, + taskListMutationRevision: deletion.revision, }, } } - // For regular status updates, validate and apply if different + // For regular status updates, run completion hooks before the atomic + // commit. The commit re-reads the task so stale ownership decisions are + // never carried across this asynchronous hook boundary. if (status !== existingTask.status) { // Run TaskCompleted hooks when marking a task as completed if (status === 'completed') { @@ -264,29 +217,93 @@ export const TaskUpdateTool = buildTool({ } } - updates.status = status - updatedFields.push('status') } } - if (Object.keys(updates).length > 0) { - await updateTask(taskListId, taskId, updates) + const automaticOwner = ( + isAgentSwarmsEnabled() && + status === 'in_progress' && + owner === undefined + ) ? getAgentName() : undefined + const mutation = await updateTaskAtomically(taskListId, taskId, (current) => { + const updates: Partial> = {} + if (subject !== undefined && subject !== current.subject) updates.subject = subject + if (description !== undefined && description !== current.description) { + updates.description = description + } + if (activeForm !== undefined && activeForm !== current.activeForm) { + updates.activeForm = activeForm + } + if (owner !== undefined && owner !== current.owner) { + updates.owner = owner + } else if (owner === undefined && automaticOwner && !current.owner) { + updates.owner = automaticOwner + } + if (metadata !== undefined) { + const merged = { ...(current.metadata ?? {}) } + for (const [key, value] of Object.entries(metadata)) { + if (value === null) delete merged[key] + else merged[key] = value + } + updates.metadata = merged + } + if (status !== undefined && status !== 'deleted' && status !== current.status) { + updates.status = status + } + return updates + }, { + addBlocks, + addBlockedBy, + }) + if (!mutation) { + return { + data: { + success: false, + taskId, + updatedFields: [], + error: 'Task not found', + }, + } } + const committedTask = mutation.task + const previousTask = mutation.previous + if (previousTask.subject !== committedTask.subject) updatedFields.push('subject') + if (previousTask.description !== committedTask.description) updatedFields.push('description') + if (previousTask.activeForm !== committedTask.activeForm) updatedFields.push('activeForm') + if (previousTask.owner !== committedTask.owner) updatedFields.push('owner') + if (JSON.stringify(previousTask.metadata) !== JSON.stringify(committedTask.metadata)) { + updatedFields.push('metadata') + } + if (previousTask.status !== committedTask.status) updatedFields.push('status') + if (JSON.stringify(previousTask.blocks) !== JSON.stringify(committedTask.blocks)) { + updatedFields.push('blocks') + } + if ( + JSON.stringify(previousTask.blockedBy) !== + JSON.stringify(committedTask.blockedBy) + ) updatedFields.push('blockedBy') + const statusChange = previousTask.status !== committedTask.status + ? { from: previousTask.status, to: committedTask.status } + : undefined // Notify new owner via mailbox when ownership changes - if (updates.owner && isAgentSwarmsEnabled()) { + if ( + committedTask.owner && + committedTask.owner !== previousTask.owner && + isAgentSwarmsEnabled() + ) { const senderName = getAgentName() || 'team-lead' const senderColor = getTeammateColor() const assignmentMessage = JSON.stringify({ type: 'task_assignment', taskId, - subject: existingTask.subject, - description: existingTask.description, + subject: committedTask.subject, + description: committedTask.description, assignedBy: senderName, timestamp: new Date().toISOString(), }) await writeToMailbox( - updates.owner, + committedTask.owner, { from: senderName, text: assignmentMessage, @@ -297,32 +314,6 @@ export const TaskUpdateTool = buildTool({ ) } - // Add blocks if provided and not already present - if (addBlocks && addBlocks.length > 0) { - const newBlocks = addBlocks.filter( - id => !existingTask.blocks.includes(id), - ) - for (const blockId of newBlocks) { - await blockTask(taskListId, taskId, blockId) - } - if (newBlocks.length > 0) { - updatedFields.push('blocks') - } - } - - // Add blockedBy if provided and not already present (reverse: the blocker blocks this task) - if (addBlockedBy && addBlockedBy.length > 0) { - const newBlockedBy = addBlockedBy.filter( - id => !existingTask.blockedBy.includes(id), - ) - for (const blockerId of newBlockedBy) { - await blockTask(taskListId, blockerId, taskId) - } - if (newBlockedBy.length > 0) { - updatedFields.push('blockedBy') - } - } - // Structural verification nudge: if the main-thread agent just closed // out a 3+ task list and none of those tasks was a verification step, // append a reminder to the tool result. Fires at the loop-exit moment @@ -335,7 +326,7 @@ export const TaskUpdateTool = buildTool({ feature('VERIFICATION_AGENT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) && !context.agentId && - updates.status === 'completed' + statusChange?.to === 'completed' ) { const allTasks = await listTasks(taskListId) const allDone = allTasks.every(t => t.status === 'completed') @@ -353,11 +344,10 @@ export const TaskUpdateTool = buildTool({ success: true, taskId, updatedFields, - statusChange: - updates.status !== undefined - ? { from: existingTask.status, to: updates.status } - : undefined, + statusChange, verificationNudgeNeeded, + taskListMutationAt: mutation.committedAt, + taskListMutationRevision: mutation.revision, }, } }, diff --git a/src/tools/TeamCreateTool/TeamCreateTool.ts b/src/tools/TeamCreateTool/TeamCreateTool.ts index 64a80186..e5e7b4eb 100644 --- a/src/tools/TeamCreateTool/TeamCreateTool.ts +++ b/src/tools/TeamCreateTool/TeamCreateTool.ts @@ -25,9 +25,12 @@ import { } from '../../utils/swarm/teamHelpers.js' import { assignTeammateColor } from '../../utils/swarm/teammateLayoutManager.js' import { + beginTaskListLifecycle, ensureTasksDir, + readTaskListLifecycleState, resetTaskList, setLeaderTeamName, + withTaskListLifecycleLock, } from '../../utils/tasks.js' import { generateWordSlug } from '../../utils/words.js' import { TEAM_CREATE_TOOL_NAME } from './constants.js' @@ -57,20 +60,6 @@ export type Output = { export type Input = z.infer -/** - * Generates a unique team name by checking if the provided name already exists. - * If the name already exists, generates a new word slug. - */ -function generateUniqueTeamName(providedName: string): string { - // If the team doesn't exist, use the provided name - if (!readTeamFile(providedName)) { - return providedName - } - - // Team exists, generate a new unique name - return generateWordSlug() -} - export const TeamCreateTool: Tool = buildTool({ name: TEAM_CREATE_TOOL_NAME, searchHint: 'create a multi-agent swarm team', @@ -139,11 +128,6 @@ export const TeamCreateTool: Tool = buildTool({ ) } - // If team already exists, generate a unique name instead of failing - const finalTeamName = generateUniqueTeamName(team_name) - - // Generate a deterministic agent ID for the team lead - const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName) const leadAgentType = agent_type || TEAM_LEAD_NAME // Get the team lead's current model from AppState (handles session model, settings, CLI override) const leadModel = parseUserSpecifiedModel( @@ -152,38 +136,72 @@ export const TeamCreateTool: Tool = buildTool({ getDefaultMainLoopModel(), ) - const teamFilePath = getTeamFilePath(finalTeamName) + let candidateName = team_name + let created: { + finalTeamName: string + leadAgentId: string + teamFile: TeamFile + } | undefined + while (!created) { + const finalTeamName = candidateName + const taskListId = sanitizeName(finalTeamName) + created = await withTaskListLifecycleLock(taskListId, async () => { + // Name reservation and lifecycle creation are one cross-process + // transaction. A pre-lock existence check allowed two leaders to both + // create the same absent Team and overwrite each other's generation. + const lifecycleState = await readTaskListLifecycleState(taskListId) + if ( + readTeamFile(finalTeamName) || + (!lifecycleState.deleted && lifecycleState.activeIdentity) + ) return undefined - const teamFile: TeamFile = { - name: finalTeamName, - description: _description, - createdAt: Date.now(), - leadAgentId, - leadSessionId: getSessionId(), // Store actual session ID for team discovery - members: [ - { - agentId: leadAgentId, - name: TEAM_LEAD_NAME, - agentType: leadAgentType, - model: leadModel, - joinedAt: Date.now(), - tmuxPaneId: '', - cwd: getCwd(), - subscriptions: [], - }, - ], + const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName) + const createdAt = Date.now() + const teamFile: TeamFile = { + name: finalTeamName, + description: _description, + createdAt, + leadAgentId, + leadSessionId: getSessionId(), // Store actual session ID for team discovery + members: [ + { + agentId: leadAgentId, + name: TEAM_LEAD_NAME, + agentType: leadAgentType, + model: leadModel, + joinedAt: createdAt, + tmuxPaneId: '', + cwd: getCwd(), + subscriptions: [], + }, + ], + } + + // The durable generation invalidates a writer or cleanup call queued + // by an older same-name Team before any new config/task state is exposed. + const lifecycle = await beginTaskListLifecycle(taskListId, { + teamName: finalTeamName, + createdAt: teamFile.createdAt, + ...(teamFile.leadSessionId + ? { leadSessionId: teamFile.leadSessionId } + : {}), + }) + await writeTeamFileAsync(finalTeamName, teamFile) + // Track for session-end cleanup — teams were left on disk forever + // unless explicitly TeamDelete'd (gh-32730). + registerTeamForSessionCleanup(finalTeamName, lifecycle) + + // Reset and create the corresponding task list directory (Team = + // Project = TaskList). This ensures task numbering starts fresh at 1. + await resetTaskList(taskListId) + await ensureTasksDir(taskListId) + return { finalTeamName, leadAgentId, teamFile } + }) + if (!created) candidateName = generateWordSlug() } - await writeTeamFileAsync(finalTeamName, teamFile) - // Track for session-end cleanup — teams were left on disk forever - // unless explicitly TeamDelete'd (gh-32730). - registerTeamForSessionCleanup(finalTeamName) - - // Reset and create the corresponding task list directory (Team = Project = TaskList) - // This ensures task numbering starts fresh at 1 for each new swarm - const taskListId = sanitizeName(finalTeamName) - await resetTaskList(taskListId) - await ensureTasksDir(taskListId) + const { finalTeamName, leadAgentId, teamFile } = created + const teamFilePath = getTeamFilePath(finalTeamName) // Register the team name so getTaskListId() returns it for the leader. // Without this, the leader falls through to getSessionId() and writes tasks diff --git a/src/tools/TeamDeleteTool/TeamDeleteTool.ts b/src/tools/TeamDeleteTool/TeamDeleteTool.ts index f09f78c3..a590f550 100644 --- a/src/tools/TeamDeleteTool/TeamDeleteTool.ts +++ b/src/tools/TeamDeleteTool/TeamDeleteTool.ts @@ -6,14 +6,14 @@ import { buildTool, type ToolDef } from '../../Tool.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { lazySchema } from '../../utils/lazySchema.js' import { jsonStringify } from '../../utils/slowOperations.js' -import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js' import { cleanupTeamDirectories, + getRegisteredTeamLifecycle, readTeamFile, unregisterTeamForSessionCleanup, } from '../../utils/swarm/teamHelpers.js' import { clearTeammateColors } from '../../utils/swarm/teammateLayoutManager.js' -import { clearLeaderTeamName } from '../../utils/tasks.js' +import { clearLeaderTeamName, type Task } from '../../utils/tasks.js' import { TEAM_DELETE_TOOL_NAME } from './constants.js' import { getPrompt } from './prompt.js' import { renderToolResultMessage, renderToolUseMessage } from './UI.js' @@ -25,6 +25,9 @@ export type Output = { success: boolean message: string team_name?: string + finalTasks?: Task[] + taskListSnapshotAt?: string + taskListSnapshotRevision?: number } export type Input = z.infer @@ -73,34 +76,47 @@ export const TeamDeleteTool: Tool = buildTool({ const appState = getAppState() const teamName = appState.teamContext?.teamName + let finalTasks: Task[] | undefined + let taskListSnapshotAt: string | undefined + let taskListSnapshotRevision: number | undefined + if (teamName) { // Read team config to check for active members const teamFile = readTeamFile(teamName) if (teamFile) { - // Filter out the team lead - only count non-lead members + // An idle teammate is still alive and polling its mailbox. Deleting + // the Team while it remains registered lets that old actor wake after + // a same-name recreation and mutate the new generation's task list. + // Shutdown approval removes teammates from the config; only that + // durable transition makes cleanup safe. const nonLeadMembers = teamFile.members.filter( - m => m.name !== TEAM_LEAD_NAME, + m => m.agentId !== teamFile.leadAgentId, ) - // Separate truly active members from idle/dead ones - // Members with isActive === false are idle (finished their turn or crashed) - const activeMembers = nonLeadMembers.filter(m => m.isActive !== false) - - if (activeMembers.length > 0) { - const memberNames = activeMembers.map(m => m.name).join(', ') + if (nonLeadMembers.length > 0) { + const memberNames = nonLeadMembers.map(m => m.name).join(', ') return { data: { success: false, - message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`, + message: `Cannot cleanup team with ${nonLeadMembers.length} registered teammate(s): ${memberNames}. Use requestShutdown and wait for shutdown approval first.`, team_name: teamName, }, } } } - await cleanupTeamDirectories(teamName) + const finalTaskSnapshot = await cleanupTeamDirectories( + teamName, + getRegisteredTeamLifecycle(teamName), + ) + finalTasks = finalTaskSnapshot.tasks.filter(task => !task.metadata?._internal) + taskListSnapshotAt = finalTaskSnapshot.capturedAt + taskListSnapshotRevision = finalTaskSnapshot.revision // Already cleaned — don't try again on gracefulShutdown. - unregisterTeamForSessionCleanup(teamName) + unregisterTeamForSessionCleanup(teamName, { + generation: finalTaskSnapshot.generation, + identity: finalTaskSnapshot.identity, + }) // Clear color assignments so new teams start fresh clearTeammateColors() @@ -130,6 +146,9 @@ export const TeamDeleteTool: Tool = buildTool({ ? `Cleaned up directories and worktrees for team "${teamName}"` : 'No team name found, nothing to clean up', team_name: teamName, + finalTasks, + taskListSnapshotAt, + taskListSnapshotRevision, }, } }, diff --git a/src/utils/swarm/inProcessRunner.test.ts b/src/utils/swarm/inProcessRunner.test.ts index dd9ca8b5..43386ef9 100644 --- a/src/utils/swarm/inProcessRunner.test.ts +++ b/src/utils/swarm/inProcessRunner.test.ts @@ -14,13 +14,28 @@ import type { PluginAgentDefinition, } from '../../tools/AgentTool/loadAgentsDir.js' import * as runAgentModule from '../../tools/AgentTool/runAgent.js' +import { TaskUpdateTool } from '../../tools/TaskUpdateTool/TaskUpdateTool.js' +import * as lockfile from '../lockfile.js' import { drainSdkEvents } from '../sdkEventQueue.js' -import { createTask, listTasks } from '../tasks.js' -import { createTeammateContext } from '../teammateContext.js' +import { + blockTask, + claimTask, + createTask, + deleteTask, + getTask, + getTaskPath, + getTasksDir, + listTasks, + unassignTeammateTasks, + updateTask, +} from '../tasks.js' +import { + createTeammateContext, + runWithTeammateContext, +} from '../teammateContext.js' import { buildInProcessTeammateAgentDefinition, claimNextInProcessTask, - composeInitialTeammatePrompt, runInProcessTeammate, withInProcessTeammateActivity, } from './inProcessRunner.js' @@ -50,6 +65,20 @@ async function withTempConfig( } } +async function waitForFileLock(filePath: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (await lockfile.check(filePath)) return + await new Promise(resolve => setImmediate(resolve)) + } + throw new Error(`Timed out waiting for lock: ${filePath}`) +} + +async function yieldEventLoop(turns = 1): Promise { + for (let turn = 0; turn < turns; turn++) { + await new Promise(resolve => setImmediate(resolve)) + } +} + function createMember( name: string, teamName: string, @@ -131,13 +160,415 @@ describe('buildInProcessTeammateAgentDefinition', () => { }) describe('in-process teammate task claiming', () => { - test('claims from the team list instead of the parent session list', async () => { + test('serializes both sides of concurrent dependency edges under one list lock', async () => { + await withTempConfig(async () => { + const taskListId = 'concurrent-dependency-edges' + const sourceId = await createTask(taskListId, { + subject: 'Source', description: 'Blocks both targets', status: 'pending', + blocks: [], blockedBy: [], + }) + const firstTargetId = await createTask(taskListId, { + subject: 'First target', description: 'First dependency', status: 'pending', + blocks: [], blockedBy: [], + }) + const secondTargetId = await createTask(taskListId, { + subject: 'Second target', description: 'Second dependency', status: 'pending', + blocks: [], blockedBy: [], + }) + const listLockPath = join(getTasksDir(taskListId), '.lock') + const releaseBarrier = await lockfile.lock(listLockPath) + + const firstEdge = blockTask(taskListId, sourceId, firstTargetId) + const secondEdge = blockTask(taskListId, sourceId, secondTargetId) + await yieldEventLoop(10) + await releaseBarrier() + + expect(await Promise.all([firstEdge, secondEdge])).toEqual([true, true]) + expect((await getTask(taskListId, sourceId))?.blocks.sort()).toEqual([ + firstTargetId, + secondTargetId, + ].sort()) + expect((await getTask(taskListId, firstTargetId))?.blockedBy).toEqual([sourceId]) + expect((await getTask(taskListId, secondTargetId))?.blockedBy).toEqual([sourceId]) + }) + }) + + test('rechecks automatic TaskUpdate ownership after a concurrent leader assignment', async () => { + await withTempConfig(async () => { + const taskListId = 'task-update-owner-race' + const taskId = await createTask(taskListId, { + subject: 'Keep the leader assignment', description: 'Do not auto-own from stale state', + status: 'pending', blocks: [], blockedBy: [], + }) + const taskPath = getTaskPath(taskListId, taskId) + const listLockPath = join(getTasksDir(taskListId), '.lock') + let releaseTaskBarrier: (() => Promise) | undefined = await lockfile.lock(taskPath) + const originalUserType = process.env.USER_TYPE + process.env.USER_TYPE = 'ant' + + try { + const leaderAssignment = updateTask(taskListId, taskId, { + owner: 'leader-assignee', + status: 'in_progress', + }) + await waitForFileLock(listLockPath) + + const teammateContext = createTeammateContext({ + agentId: 'idle-agent@task-update-owner-race', + agentName: 'idle-agent', + teamName: taskListId, + planModeRequired: false, + parentSessionId: 'leader-session', + abortController: new AbortController(), + }) + const toolContext = { + agentId: 'idle-agent@task-update-owner-race', + abortController: new AbortController(), + setAppState: () => {}, + } as unknown as ToolUseContext + const teammateUpdate = runWithTeammateContext( + teammateContext, + () => TaskUpdateTool.call({ taskId, status: 'in_progress' }, toolContext), + ) + await yieldEventLoop(4) + + await releaseTaskBarrier() + releaseTaskBarrier = undefined + await leaderAssignment + expect((await teammateUpdate).data).toMatchObject({ + success: true, + updatedFields: [], + }) + expect(await getTask(taskListId, taskId)).toMatchObject({ + owner: 'leader-assignee', + status: 'in_progress', + }) + } finally { + await releaseTaskBarrier?.() + if (originalUserType === undefined) delete process.env.USER_TYPE + else process.env.USER_TYPE = originalUserType + } + }) + }) + + test('automatically owns an unassigned task when a teammate starts it', async () => { + await withTempConfig(async () => { + const taskListId = 'task-update-auto-owner' + const taskId = await createTask(taskListId, { + subject: 'Start assigned work', + description: 'The first in-progress transition should record its teammate', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const originalUserType = process.env.USER_TYPE + process.env.USER_TYPE = 'ant' + const teammateContext = createTeammateContext({ + agentId: 'worker@task-update-auto-owner', + agentName: 'worker', + teamName: taskListId, + planModeRequired: false, + parentSessionId: 'leader-session', + abortController: new AbortController(), + }) + const toolContext = { + agentId: 'worker@task-update-auto-owner', + abortController: new AbortController(), + setAppState: () => {}, + } as unknown as ToolUseContext + + try { + const result = await runWithTeammateContext( + teammateContext, + () => TaskUpdateTool.call({ taskId, status: 'in_progress' }, toolContext), + ) + + expect(result.data).toMatchObject({ + success: true, + updatedFields: ['owner', 'status'], + statusChange: { from: 'pending', to: 'in_progress' }, + }) + expect(await getTask(taskListId, taskId)).toMatchObject({ + owner: 'worker', + status: 'in_progress', + }) + } finally { + if (originalUserType === undefined) delete process.env.USER_TYPE + else process.env.USER_TYPE = originalUserType + } + }) + }) + + test('does not unassign a task after another actor reassigns it', async () => { + await withTempConfig(async () => { + const taskListId = 'shutdown-reassignment-race' + const taskId = await createTask(taskListId, { + subject: 'Preserve reassignment', description: 'Shutdown cleanup must use owner CAS', + owner: 'exiting-agent', status: 'in_progress', blocks: [], blockedBy: [], + }) + const taskPath = getTaskPath(taskListId, taskId) + const listLockPath = join(getTasksDir(taskListId), '.lock') + let releaseTaskBarrier: (() => Promise) | undefined = await lockfile.lock(taskPath) + + try { + const reassignment = updateTask(taskListId, taskId, { + owner: 'replacement-agent', + status: 'in_progress', + }) + await waitForFileLock(listLockPath) + const cleanup = unassignTeammateTasks( + taskListId, + 'exiting-agent@shutdown-reassignment-race', + 'exiting-agent', + 'shutdown', + ) + await yieldEventLoop(4) + + await releaseTaskBarrier() + releaseTaskBarrier = undefined + await reassignment + expect(await cleanup).toMatchObject({ unassignedTasks: [] }) + expect(await getTask(taskListId, taskId)).toMatchObject({ + owner: 'replacement-agent', + status: 'in_progress', + }) + } finally { + await releaseTaskBarrier?.() + } + }) + }) + + test('returns genuinely unassigned teammate work to the pending pool', async () => { + await withTempConfig(async () => { + const taskListId = 'shutdown-positive-unassign' + const taskId = await createTask(taskListId, { + subject: 'Hand work back to the team', + description: 'Shutdown cleanup should release work still owned by the exiting member', + owner: 'exiting-agent', + status: 'in_progress', + blocks: [], + blockedBy: [], + }) + + const result = await unassignTeammateTasks( + taskListId, + 'exiting-agent@shutdown-positive-unassign', + 'exiting-agent', + 'shutdown', + ) + + expect(result.unassignedTasks).toEqual([{ + id: taskId, + subject: 'Hand work back to the team', + }]) + expect(result.notificationMessage).toContain('1 task(s) were unassigned') + expect(await getTask(taskListId, taskId)).toMatchObject({ status: 'pending' }) + expect((await getTask(taskListId, taskId))?.owner).toBeUndefined() + }) + }) + + test('marks a claim in progress only when the caller explicitly requests it', async () => { + await withTempConfig(async () => { + const taskListId = 'claim-status-contract' + const pendingClaim = await createTask(taskListId, { + subject: 'Keep pending', + description: 'Legacy claim callers only assign an owner', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const activeClaim = await createTask(taskListId, { + subject: 'Start immediately', + description: 'In-process idle claiming activates atomically', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + expect(await claimTask(taskListId, pendingClaim, 'legacy-agent')) + .toMatchObject({ success: true, task: { owner: 'legacy-agent', status: 'pending' } }) + expect(await claimTask(taskListId, activeClaim, 'idle-agent', { + markInProgress: true, + })).toMatchObject({ + success: true, + task: { owner: 'idle-agent', status: 'in_progress' }, + }) + }) + }) + + test('does not overwrite a leader owner/status update committed before a busy claim', async () => { + await withTempConfig(async () => { + const taskListId = 'claim-after-owner-update' + const taskId = await createTask(taskListId, { + subject: 'Leader-owned task', + description: 'The idle claimant must observe the serialized assignment', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const taskPath = getTaskPath(taskListId, taskId) + const listLockPath = join(getTasksDir(taskListId), '.lock') + let releaseTaskBarrier: (() => Promise) | undefined = await lockfile.lock(taskPath) + + try { + const leaderUpdate = updateTask(taskListId, taskId, { + owner: 'leader-assignee', + status: 'in_progress', + }) + await waitForFileLock(listLockPath) + + let claimSettled = false + const idleClaim = claimTask(taskListId, taskId, 'idle-claimant', { + checkAgentBusy: true, + markInProgress: true, + }).finally(() => { + claimSettled = true + }) + await yieldEventLoop(4) + expect(claimSettled).toBe(false) + + await releaseTaskBarrier() + releaseTaskBarrier = undefined + expect(await leaderUpdate).toMatchObject({ + owner: 'leader-assignee', + status: 'in_progress', + }) + expect(await idleClaim).toMatchObject({ + success: false, + reason: 'already_claimed', + task: { + owner: 'leader-assignee', + status: 'in_progress', + }, + }) + expect(await getTask(taskListId, taskId)).toMatchObject({ + owner: 'leader-assignee', + status: 'in_progress', + }) + } finally { + await releaseTaskBarrier?.() + } + }) + }) + + test('serializes a new busy assignment before claiming different follow-up work', async () => { + await withTempConfig(async () => { + const taskListId = 'claim-after-busy-update' + const busyTaskId = await createTask(taskListId, { + subject: 'Fresh leader assignment', + description: 'This update must participate in the claim transaction', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const followUpTaskId = await createTask(taskListId, { + subject: 'Unowned follow-up', + description: 'Must remain available while the member is busy', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const busyTaskPath = getTaskPath(taskListId, busyTaskId) + const listLockPath = join(getTasksDir(taskListId), '.lock') + let releaseTaskBarrier: (() => Promise) | undefined = await lockfile.lock(busyTaskPath) + + try { + const leaderUpdate = updateTask(taskListId, busyTaskId, { + owner: 'idle-claimant', + status: 'in_progress', + }) + await waitForFileLock(listLockPath) + const idleClaim = claimTask( + taskListId, + followUpTaskId, + 'idle-claimant', + { checkAgentBusy: true, markInProgress: true }, + ) + + await releaseTaskBarrier() + releaseTaskBarrier = undefined + await leaderUpdate + expect(await idleClaim).toMatchObject({ + success: false, + reason: 'agent_busy', + busyWithTasks: [busyTaskId], + }) + expect(await getTask(taskListId, followUpTaskId)).toMatchObject({ + status: 'pending', + }) + expect((await getTask(taskListId, followUpTaskId))?.owner).toBeUndefined() + } finally { + await releaseTaskBarrier?.() + } + }) + }) + + test('does not let delete turn a serialized busy claim into success without a task', async () => { + await withTempConfig(async () => { + const taskListId = 'delete-during-claim' + const taskId = await createTask(taskListId, { + subject: 'Claim or delete atomically', + description: 'A successful claim must always carry the committed task', + status: 'pending', + blocks: [], + blockedBy: [], + }) + const taskPath = getTaskPath(taskListId, taskId) + const listLockPath = join(getTasksDir(taskListId), '.lock') + let releaseTaskBarrier: (() => Promise) | undefined = await lockfile.lock(taskPath) + + try { + const idleClaim = claimTask(taskListId, taskId, 'idle-claimant', { + checkAgentBusy: true, + markInProgress: true, + }) + await waitForFileLock(listLockPath) + + let deleteSettled = false + const deletion = deleteTask(taskListId, taskId).finally(() => { + deleteSettled = true + }) + await yieldEventLoop(20) + expect(deleteSettled).toBe(false) + expect(await getTask(taskListId, taskId)).not.toBeNull() + + await releaseTaskBarrier() + releaseTaskBarrier = undefined + expect(await idleClaim).toMatchObject({ + success: true, + task: { + id: taskId, + owner: 'idle-claimant', + status: 'in_progress', + }, + }) + expect(await deletion).toBe(true) + expect(await getTask(taskListId, taskId)).toBeNull() + } finally { + await releaseTaskBarrier?.() + } + }) + }) + + test('claims follow-up work from the team list instead of the parent session list', async () => { await withTempConfig(async () => { const teamName = 'Release_Audit' const taskListId = 'release-audit' const parentSessionId = 'leader-session' const agentName = 'workflow-analyzer' + const explicitAssignment = await createTask(taskListId, { + subject: 'Completed explicit assignment', + description: 'Establish that the lead assigned this teammate', + status: 'pending', + blocks: [], + blockedBy: [], + }) + await updateTask(taskListId, explicitAssignment, { + owner: agentName, + status: 'in_progress', + }) + await updateTask(taskListId, explicitAssignment, { status: 'completed' }) await createTask(taskListId, { subject: 'Audit workflow', description: 'Audit workflow changes', @@ -157,10 +588,8 @@ describe('in-process teammate task claiming', () => { expect(prompt).toContain('Audit workflow') const claimedTasks = await listTasks(taskListId) - expect(claimedTasks[0]?.owner).toBe(agentName) - expect(claimedTasks.every(task => task.status === 'in_progress')).toBe( - true, - ) + expect(claimedTasks[1]?.owner).toBe(agentName) + expect(claimedTasks[1]?.status).toBe('in_progress') const unrelatedTasks = await listTasks(parentSessionId) expect(unrelatedTasks).toHaveLength(1) @@ -175,6 +604,16 @@ describe('in-process teammate task claiming', () => { await withTempConfig(async () => { const teamName = 'parallel-audit' const agentNames = ['workflow-analyzer', 'desktop-analyzer', 'provider-analyzer'] + for (const agentName of agentNames) { + await createTask(teamName, { + subject: `Completed assignment for ${agentName}`, + description: 'Establish prior explicit ownership', + status: 'completed', + owner: agentName, + blocks: [], + blockedBy: [], + }) + } for (const subject of ['Audit workflow', 'Audit desktop', 'Audit providers']) { await createTask(teamName, { subject, @@ -191,19 +630,27 @@ describe('in-process teammate task claiming', () => { expect(prompts.every(Boolean)).toBe(true) const claimedTasks = await listTasks(teamName) - expect(claimedTasks.map(task => task.owner).sort()).toEqual([...agentNames].sort()) - expect(claimedTasks.every(task => task.status === 'in_progress')).toBe(true) + const followUpTasks = claimedTasks.filter(task => task.status === 'in_progress') + expect(followUpTasks.map(task => task.owner).sort()).toEqual([...agentNames].sort()) }) }) - test('honors the explicit task list override used by the leader', async () => { + test('keeps teammate claims on the canonical Team list despite a standalone override', async () => { await withTempConfig(async () => { const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID process.env.CLAUDE_CODE_TASK_LIST_ID = 'explicit-team-list' try { - await createTask('explicit-team-list', { - subject: 'Audit explicit list', - description: 'Use the configured list', + await createTask('ignored-team-name', { + subject: 'Completed canonical assignment', + description: 'Establish prior explicit ownership', + status: 'completed', + owner: 'auditor', + blocks: [], + blockedBy: [], + }) + await createTask('ignored-team-name', { + subject: 'Audit canonical Team list', + description: 'Do not split Team work into the standalone override', status: 'pending', blocks: [], blockedBy: [], @@ -212,8 +659,14 @@ describe('in-process teammate task claiming', () => { expect(await claimNextInProcessTask({ agentName: 'auditor', teamName: 'Ignored_Team_Name', - })).toContain('Audit explicit list') - expect((await listTasks('explicit-team-list'))[0]?.owner).toBe('auditor') + })).toContain('Audit canonical Team list') + const canonicalTask = (await listTasks('ignored-team-name')) + .find(task => task.subject === 'Audit canonical Team list') + expect(canonicalTask).toMatchObject({ + owner: 'auditor', + status: 'in_progress', + }) + expect(await listTasks('explicit-team-list')).toEqual([]) } finally { if (previousTaskListId === undefined) { delete process.env.CLAUDE_CODE_TASK_LIST_ID @@ -224,12 +677,54 @@ describe('in-process teammate task claiming', () => { }) }) - test('delivers the claimed task instructions in the first teammate turn', () => { - expect(composeInitialTeammatePrompt( - 'Review the release', - 'Complete task #2: Audit desktop changes', - )).toContain('Complete task #2: Audit desktop changes') - expect(composeInitialTeammatePrompt('Review the release', undefined)).toBe('Review the release') + test('waits for a lead owner assignment before opportunistically claiming work', async () => { + await withTempConfig(async () => { + const teamName = 'assignment-barrier' + await createTask(teamName, { + subject: 'Feature analysis', + description: 'Must not be attached to an arbitrary spawn prompt', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + expect(await claimNextInProcessTask({ + agentName: 'bug-analyst', + teamName, + })).toBeUndefined() + expect((await listTasks(teamName))[0]).toMatchObject({ + status: 'pending', + }) + expect((await listTasks(teamName))[0]?.owner).toBeUndefined() + }) + }) + + test('does not claim another task while the teammate owns unfinished work', async () => { + await withTempConfig(async () => { + const teamName = 'busy-claim-guard' + const agentName = 'reviewer' + await createTask(teamName, { + subject: 'Current review', + description: 'The teammate is still working on this assignment', + status: 'in_progress', + owner: agentName, + blocks: [], + blockedBy: [], + }) + await createTask(teamName, { + subject: 'Unclaimed follow-up', + description: 'Must stay available for another idle teammate', + status: 'pending', + blocks: [], + blockedBy: [], + }) + + expect(await claimNextInProcessTask({ agentName, teamName })).toBeUndefined() + const followUp = (await listTasks(teamName)) + .find(task => task.subject === 'Unclaimed follow-up') + expect(followUp?.owner).toBeUndefined() + expect(followUp?.status).toBe('pending') + }) }) }) @@ -275,7 +770,7 @@ describe('in-process teammate activity synchronization', () => { } }) - test('delivers the claimed task during the first active turn and scopes its terminal event', async () => { + test('keeps the leader-assigned first turn separate from opportunistic task claiming', async () => { await withTempConfig(async () => { resetStateForTests() setIsInteractive(false) @@ -299,6 +794,14 @@ describe('in-process teammate activity synchronization', () => { leadAgentId: `team-lead@${teamName}`, members: [member], }) + await createTask(teamName, { + subject: 'Earlier explicit assignment', + description: 'Makes an accidental first-turn claim observable', + status: 'completed', + owner: agentName, + blocks: [], + blockedBy: [], + }) await createTask(teamName, { subject: 'Audit first-turn delivery', description: 'Verify the claimed task reaches runAgent', @@ -370,12 +873,12 @@ describe('in-process teammate activity synchronization', () => { expect(result.success).toBe(true) expect(firstPrompt).toContain('Review the release') - expect(firstPrompt).toContain('Audit first-turn delivery') + expect(firstPrompt).not.toContain('Audit first-turn delivery') expect(readTeamFile(teamName)?.members[0]?.isActive).toBe(false) - expect((await listTasks(teamName))[0]).toMatchObject({ - owner: agentName, - status: 'in_progress', - }) + const unclaimedTask = (await listTasks(teamName)) + .find(task => task.subject === 'Audit first-turn delivery') + expect(unclaimedTask?.owner).toBeUndefined() + expect(unclaimedTask?.status).toBe('pending') expect(drainSdkEvents()).toContainEqual(expect.objectContaining({ subtype: 'task_notification', task_id: taskId, diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index e314dafc..39ad9b67 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -89,10 +89,9 @@ import { jsonStringify } from '../slowOperations.js' import { asSystemPrompt } from '../systemPromptType.js' import { claimTask, - getTeamTaskListId, + getCanonicalTeamTaskListId, listTasks, type Task, - updateTask, } from '../tasks.js' import type { TeammateContext } from '../teammateContext.js' import { runWithTeammateContext } from '../teammateContext.js' @@ -689,19 +688,31 @@ function formatTaskAsPrompt(task: Task): string { } /** - * Try to claim an available task from the team's task list. - * Returns the formatted prompt if a task was claimed, or undefined if none available. + * Try to claim follow-up work from the team's task list. + * + * A newly spawned teammate must first receive an explicit owner assignment from + * the lead. Otherwise concurrent spawns race through the same unowned list and + * can attach another member's task to the teammate's first prompt. Once the + * member has appeared as an owner, completed ownership is the durable signal + * that it may autonomously continue with the next available task. */ export async function claimNextInProcessTask( identity: Pick, ): Promise { const { agentName } = identity - const taskListId = getTeamTaskListId(identity.teamName) + const taskListId = getCanonicalTeamTaskListId(identity.teamName) try { const tasks = await listTasks(taskListId) + if (!tasks.some(task => task.owner === agentName)) return undefined + for (const availableTask of findAvailableTasks(tasks)) { - const result = await claimTask(taskListId, availableTask.id, agentName) + const result = await claimTask( + taskListId, + availableTask.id, + agentName, + { checkAgentBusy: true, markInProgress: true }, + ) if (!result.success) { logForDebugging( `[inProcessRunner] Failed to claim task #${availableTask.id}: ${result.reason}`, @@ -709,9 +720,6 @@ export async function claimNextInProcessTask( continue } - // Also set status to in_progress so the UI reflects it immediately - await updateTask(taskListId, availableTask.id, { status: 'in_progress' }) - logForDebugging( `[inProcessRunner] Claimed task #${availableTask.id}: ${availableTask.subject}`, ) @@ -725,15 +733,6 @@ export async function claimNextInProcessTask( } } -export function composeInitialTeammatePrompt( - initialPrompt: string, - claimedTaskPrompt: string | undefined, -): string { - return claimedTaskPrompt - ? `${initialPrompt}\n\n${claimedTaskPrompt}` - : initialPrompt -} - /** * Result of waiting for messages. */ @@ -1069,13 +1068,6 @@ export async function runInProcessTeammate( let currentPrompt = wrappedInitialPrompt let shouldExit = false - // Try to claim an available task immediately so the UI can show activity - // from the very start. The idle loop handles claiming for subsequent tasks. - currentPrompt = composeInitialTeammatePrompt( - wrappedInitialPrompt, - await claimNextInProcessTask(identity), - ) - try { // Add initial prompt to task.messages for display (wrapped with XML) updateTaskState( diff --git a/src/utils/swarm/teamHelpers.ts b/src/utils/swarm/teamHelpers.ts index 4ef92b89..6f6684d6 100644 --- a/src/utils/swarm/teamHelpers.ts +++ b/src/utils/swarm/teamHelpers.ts @@ -12,7 +12,17 @@ import { lazySchema } from '../lazySchema.js' import * as lockfile from '../lockfile.js' import type { PermissionMode } from '../permissions/PermissionMode.js' import { jsonParse, jsonStringify } from '../slowOperations.js' -import { getTasksDir, notifyTasksUpdated } from '../tasks.js' +import { + completeTaskListLifecycle, + getCanonicalTeamTaskListId, + getTasksDir, + notifyTasksUpdated, + readTaskListLifecycleState, + readTaskListSnapshot, + type TaskListLifecycleToken, + type TaskListTerminalReceipt, + withTaskListLifecycleLock, +} from '../tasks.js' import { getAgentName, getTeamName, isTeammate } from '../teammate.js' import { type BackendType, isPaneBackend } from './backends/types.js' import { TEAM_LEAD_NAME } from './constants.js' @@ -615,16 +625,39 @@ async function destroyWorktree(worktreePath: string): Promise { * Backing Set lives in bootstrap/state.ts so resetStateForTests() * clears it between tests (avoids the PR #17615 cross-shard leak class). */ -export function registerTeamForSessionCleanup(teamName: string): void { - getSessionCreatedTeams().add(teamName) +export function registerTeamForSessionCleanup( + teamName: string, + lifecycle: TaskListLifecycleToken, +): void { + getSessionCreatedTeams().set(teamName, lifecycle) +} + +export function getRegisteredTeamLifecycle( + teamName: string, +): TaskListLifecycleToken | undefined { + return getSessionCreatedTeams().get(teamName) } /** * Remove a team from session cleanup tracking (e.g., after explicit * TeamDelete — already cleaned, don't try again on shutdown). */ -export function unregisterTeamForSessionCleanup(teamName: string): void { - getSessionCreatedTeams().delete(teamName) +export function unregisterTeamForSessionCleanup( + teamName: string, + expected?: TaskListLifecycleToken, +): void { + const teams = getSessionCreatedTeams() + const current = teams.get(teamName) + if ( + expected && + ( + current?.generation !== expected.generation || + current.identity.teamName !== expected.identity.teamName || + current.identity.createdAt !== expected.identity.createdAt || + current.identity.leadSessionId !== expected.identity.leadSessionId + ) + ) return + teams.delete(teamName) } /** @@ -634,17 +667,22 @@ export function unregisterTeamForSessionCleanup(teamName: string): void { export async function cleanupSessionTeams(): Promise { const sessionCreatedTeams = getSessionCreatedTeams() if (sessionCreatedTeams.size === 0) return - const teams = Array.from(sessionCreatedTeams) + const teams = Array.from(sessionCreatedTeams.entries()) logForDebugging( - `cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`, + `cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.map(([name]) => name).join(', ')}`, ) - // Kill panes first — on SIGINT the teammate processes are still running; - // deleting directories alone would orphan them in open tmux/iTerm2 panes. - // (TeamDeleteTool's path doesn't need this — by then teammates have - // gracefully exited and useInboxPoller has already closed their panes.) - await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name))) - await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name))) - sessionCreatedTeams.clear() + const results = await Promise.allSettled( + teams.map(([name, lifecycle]) => cleanupTeamDirectories( + name, + lifecycle, + { killOrphanedPanes: true }, + )), + ) + for (let index = 0; index < teams.length; index++) { + if (results[index]?.status !== 'fulfilled') continue + const [name, lifecycle] = teams[index]! + unregisterTeamForSessionCleanup(name, lifecycle) + } } /** @@ -691,44 +729,14 @@ async function killOrphanedTeammatePanes(teamName: string): Promise { ) } -/** - * Cleans up team and task directories for a given team name. - * Also cleans up git worktrees created for teammates. - * Called when a swarm session is terminated. - */ -export async function cleanupTeamDirectories(teamName: string): Promise { - const sanitizedName = sanitizeName(teamName) - - // Read team file to get worktree paths BEFORE deleting the team directory - const teamFile = readTeamFile(teamName) - const worktreePaths: string[] = [] - if (teamFile) { - for (const member of teamFile.members) { - if (member.worktreePath) { - worktreePaths.push(member.worktreePath) - } - } - } - - // Clean up worktrees first - for (const worktreePath of worktreePaths) { - await destroyWorktree(worktreePath) - } - - // Clean up team directory (~/.claude/teams/{team-name}/) - const teamDir = getTeamDir(teamName) - try { - await rm(teamDir, { recursive: true, force: true }) - logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`) - } catch (error) { - logForDebugging( - `[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`, - ) - } - - // Clean up tasks directory (~/.claude/tasks/{taskListId}/) - // The leader and teammates all store tasks under the sanitized team name. - const tasksDir = getTasksDir(sanitizedName) +async function removeTeamLifecycleDirectories( + teamName: string, + taskListId: string, +): Promise { + // Remove the task directory first. If that fails, retaining the Team config + // leaves the public cleanup entry retryable instead of stranding an + // invisible, lifecycle-fenced task directory. + const tasksDir = getTasksDir(taskListId) try { await rm(tasksDir, { recursive: true, force: true }) logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`) @@ -737,5 +745,147 @@ export async function cleanupTeamDirectories(teamName: string): Promise { logForDebugging( `[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`, ) + throw new Error( + `Failed to clean up Team task directory ${tasksDir}: ${errorMessage(error)}`, + ) + } + + const teamDir = getTeamDir(teamName) + try { + await rm(teamDir, { recursive: true, force: true }) + logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`) + } catch (error) { + logForDebugging( + `[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`, + ) + throw new Error( + `Failed to clean up Team directory ${teamDir}: ${errorMessage(error)}`, + ) } } + +async function cleanupTeamRuntimeResources( + teamName: string, + members: TeamFile['members'], + options: { killOrphanedPanes?: boolean }, +): Promise { + if (options.killOrphanedPanes) { + await killOrphanedTeammatePanes(teamName) + } + for (const member of members) { + if (member.worktreePath) await destroyWorktree(member.worktreePath) + } +} + +/** + * Cleans up team and task directories for a given team name. + * Also cleans up git worktrees created for teammates. + * Called when a swarm session is terminated. + */ +export async function cleanupTeamDirectories( + teamName: string, + expectedLifecycle?: TaskListLifecycleToken, + options: { killOrphanedPanes?: boolean } = {}, +): Promise { + const sanitizedName = getCanonicalTeamTaskListId(teamName) + const teamFile = readTeamFile(teamName) + const fallbackIdentity = { + teamName: teamFile?.name ?? teamName, + createdAt: teamFile?.createdAt ?? 0, + ...(teamFile?.leadSessionId ? { leadSessionId: teamFile.leadSessionId } : {}), + } + const observedLifecycle = await readTaskListLifecycleState(sanitizedName) + if (!expectedLifecycle && !teamFile && observedLifecycle.activeIdentity) { + throw new Error( + `Cannot infer which incarnation owns missing Team config ${teamName}`, + ) + } + const lifecycle = expectedLifecycle ?? { + generation: observedLifecycle.generation, + identity: observedLifecycle.activeIdentity ?? fallbackIdentity, + } + if ( + teamFile && + ( + lifecycle.identity.teamName !== teamFile.name || + lifecycle.identity.createdAt !== teamFile.createdAt || + lifecycle.identity.leadSessionId !== teamFile.leadSessionId + ) + ) { + throw new Error(`Refusing cleanup after Team ${teamName} changed incarnation`) + } + + return withTaskListLifecycleLock(sanitizedName, async () => { + const currentLifecycle = await readTaskListLifecycleState(sanitizedName) + const sameIdentity = currentLifecycle.activeIdentity?.teamName === + lifecycle.identity.teamName && + currentLifecycle.activeIdentity?.createdAt === lifecycle.identity.createdAt && + currentLifecycle.activeIdentity?.leadSessionId === + lifecycle.identity.leadSessionId + if ( + currentLifecycle.generation !== lifecycle.generation || + (currentLifecycle.activeIdentity && !sameIdentity) + ) { + throw new Error( + `Refusing stale cleanup for Team ${teamName}: task-list generation changed`, + ) + } + const currentTeamFile = readTeamFile(teamName) + if ( + currentTeamFile && + ( + currentTeamFile.name !== lifecycle.identity.teamName || + currentTeamFile.createdAt !== lifecycle.identity.createdAt || + currentTeamFile.leadSessionId !== lifecycle.identity.leadSessionId + ) + ) { + throw new Error(`Refusing stale cleanup for a newer Team ${teamName}`) + } + if (currentLifecycle.deleted) { + const terminal = currentLifecycle.terminals.find(receipt => ( + receipt.generation === lifecycle.generation && + receipt.identity.teamName === lifecycle.identity.teamName && + receipt.identity.createdAt === lifecycle.identity.createdAt && + receipt.identity.leadSessionId === lifecycle.identity.leadSessionId + )) + if (terminal) { + await cleanupTeamRuntimeResources( + teamName, + currentTeamFile?.members ?? teamFile?.members ?? [], + options, + ) + await removeTeamLifecycleDirectories(teamName, sanitizedName) + return terminal + } + throw new Error(`Team ${teamName} was deleted without a terminal task frame`) + } + + // Capture the terminal DAG under the same durable lock that excludes + // watcher reads and whole-directory removal. TeamDelete persists this + // frame in its result so archive repair never has to infer mutations that + // happened after the watcher's last poll. + const finalTaskSnapshot = await readTaskListSnapshot(sanitizedName) + const terminalReceipt = await completeTaskListLifecycle( + sanitizedName, + finalTaskSnapshot, + lifecycle, + ) + + // Persist the terminal frame before potentially slow pane/worktree + // teardown. Graceful shutdown has a hard time budget; once the deleted + // receipt exists, retries can finish external and physical cleanup without + // losing the final DAG or allowing new task writers. + await cleanupTeamRuntimeResources( + teamName, + currentTeamFile?.members ?? teamFile?.members ?? [], + options, + ) + + // The terminal receipt is durable before physical deletion. A failed rm + // therefore rejects and remains retryable; a later call reuses the receipt + // without recreating or rereading the deleted task directory. + await removeTeamLifecycleDirectories(teamName, sanitizedName) + + return terminalReceipt + }) +} diff --git a/src/utils/taskListId.test.ts b/src/utils/taskListId.test.ts index 9969a269..5b047539 100644 --- a/src/utils/taskListId.test.ts +++ b/src/utils/taskListId.test.ts @@ -58,11 +58,11 @@ describe('getTaskListId', () => { }) }) - test('canonicalizes teammate names and still honors an explicit list', () => { + test('keeps a teammate on the canonical Team list despite a standalone override', () => { runWithTeammateContext({ ...TEAMMATE, teamName: 'My_Team' }, () => { expect(getTaskListId('teammate-agent')).toBe('my-team') process.env.CLAUDE_CODE_TASK_LIST_ID = 'explicit-team-list' - expect(getTaskListId('teammate-agent')).toBe('explicit-team-list') + expect(getTaskListId('teammate-agent')).toBe('my-team') }) }) }) diff --git a/src/utils/tasks.ts b/src/utils/tasks.ts index 8c52a532..4c50abda 100644 --- a/src/utils/tasks.ts +++ b/src/utils/tasks.ts @@ -1,5 +1,6 @@ -import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' -import { join } from 'path' +import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { AsyncLocalStorage } from 'node:async_hooks' import { z } from 'zod/v4' import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js' import { uniq } from './array.js' @@ -16,6 +17,17 @@ import { getTeammateContext } from './teammateContext.js' // Listeners for task list updates (used for immediate UI refresh in same process) const tasksUpdated = createSignal() +type TaskListLifecycleContext = { + held: ReadonlySet + lease: { active: boolean } +} +const taskListLifecycleContext = new AsyncLocalStorage() + +function isTaskListLifecycleLockHeld(taskListId: string): boolean { + const context = taskListLifecycleContext.getStore() + return context?.lease.active === true && + context.held.has(sanitizePathComponent(taskListId)) +} /** * Team name set by the leader when creating a team. @@ -88,8 +100,36 @@ export const TaskSchema = lazySchema(() => ) export type Task = z.infer> +export type TaskListTerminalIdentity = { + teamName: string + createdAt: number + leadSessionId?: string +} + +export type TaskListLifecycleToken = { + generation: number + identity: TaskListTerminalIdentity +} + +export type TaskListTerminalReceipt = TaskListSnapshot & { + frameId: string + generation: number + identity: TaskListTerminalIdentity +} + +export type TaskListLifecycleState = { + schemaVersion: 1 + generation: number + deleted: boolean + activeIdentity?: TaskListTerminalIdentity + terminals: TaskListTerminalReceipt[] + [key: string]: unknown +} + // High water mark file name - stores the maximum task ID ever assigned const HIGH_WATER_MARK_FILE = '.highwatermark' +const TASK_LIST_REVISION_FILE = '.revision' +const TASK_LIST_LIFECYCLE_STATE_SCHEMA_VERSION = 1 // Lock options: retry with backoff so concurrent callers (multiple Claudes // in a swarm) wait for the lock instead of failing immediately. The sync @@ -111,6 +151,34 @@ function getHighWaterMarkPath(taskListId: string): string { return join(getTasksDir(taskListId), HIGH_WATER_MARK_FILE) } +function getTaskListRevisionPath(taskListId: string): string { + return join(getTasksDir(taskListId), TASK_LIST_REVISION_FILE) +} + +async function readTaskListRevisionLocked(taskListId: string): Promise { + try { + const content = (await readFile(getTaskListRevisionPath(taskListId), 'utf-8')).trim() + if (!content) { + throw new Error(`Invalid empty task-list revision for ${taskListId}`) + } + const revision = Number(content) + if (Number.isSafeInteger(revision) && revision >= 0) return revision + throw new Error(`Invalid task-list revision for ${taskListId}: ${content}`) + } catch (error) { + if (getErrnoCode(error) === 'ENOENT') return 0 + throw error + } +} + +async function bumpTaskListRevisionLocked(taskListId: string): Promise { + const revision = await readTaskListRevisionLocked(taskListId) + 1 + const revisionPath = getTaskListRevisionPath(taskListId) + const temporaryPath = `${revisionPath}.${process.pid}.tmp` + await writeFile(temporaryPath, String(revision)) + await rename(temporaryPath, revisionPath) + return revision +} + async function readHighWaterMark(taskListId: string): Promise { const path = getHighWaterMarkPath(taskListId) try { @@ -145,6 +213,10 @@ export function isTodoV2Enabled(): boolean { * Uses file locking to prevent race conditions when multiple Claudes run in parallel. */ export async function resetTaskList(taskListId: string): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => resetTaskList(taskListId)) + } + await assertTaskListLifecycleActive(taskListId) const dir = getTasksDir(taskListId) const lockPath = await ensureTaskListLockFile(taskListId) @@ -169,14 +241,16 @@ export async function resetTaskList(taskListId: string): Promise { } catch { files = [] } - for (const file of files) { - if (file.endsWith('.json') && !file.startsWith('.')) { - const filePath = join(dir, file) - try { - await unlink(filePath) - } catch { - // Ignore errors, file may already be deleted - } + const taskFiles = files.filter( + file => file.endsWith('.json') && !file.startsWith('.'), + ) + if (taskFiles.length > 0) await bumpTaskListRevisionLocked(taskListId) + for (const file of taskFiles) { + const filePath = join(dir, file) + try { + await unlink(filePath) + } catch { + // Ignore errors, file may already be deleted } } notifyTasksUpdated() @@ -190,10 +264,10 @@ export async function resetTaskList(taskListId: string): Promise { /** * Gets the task list ID based on the current context. * Priority: - * 1. CLAUDE_CODE_TASK_LIST_ID - explicit task list ID - * 2. In-process teammate: leader's team name (so teammates share the leader's task list) - * 3. Team name from the active team context - * 4. Leader team name - set when the leader creates a team via TeamCreate + * 1. In-process teammate: leader's canonical Team list + * 2. Team name from the active team context + * 3. Leader team name - set when the leader creates a team via TeamCreate + * 4. CLAUDE_CODE_TASK_LIST_ID - explicit standalone task list ID * 5. Agent ID - a subagent keeps its own list instead of writing the session's * 6. Session ID - fallback for standalone sessions * @@ -206,9 +280,6 @@ export async function resetTaskList(taskListId: string): Promise { * so keep sharing the leader's list, which is the point of a team. */ export function getTaskListId(agentId?: string): string { - if (process.env.CLAUDE_CODE_TASK_LIST_ID) { - return process.env.CLAUDE_CODE_TASK_LIST_ID - } // In-process teammates use the leader's team name so they share the same // task list that tmux/iTerm2 teammates also resolve to. const teammateCtx = getTeammateContext() @@ -216,12 +287,26 @@ export function getTaskListId(agentId?: string): string { return getTeamTaskListId(teammateCtx.teamName) } const teamName = getTeamName() || leaderTeamName - return teamName ? getTeamTaskListId(teamName) : agentId || getSessionId() + if (teamName) return getTeamTaskListId(teamName) + if (process.env.CLAUDE_CODE_TASK_LIST_ID) { + return process.env.CLAUDE_CODE_TASK_LIST_ID + } + return agentId || getSessionId() } /** Canonical task-list identity shared by the leader and every teammate backend. */ export function getTeamTaskListId(teamName: string): string { - return process.env.CLAUDE_CODE_TASK_LIST_ID || sanitizeName(teamName) + return getCanonicalTeamTaskListId(teamName) +} + +/** + * Stable on-disk identity for a Team lifecycle. Unlike getTeamTaskListId(), + * this must never follow the process-wide standalone task-list override: Team + * cleanup and archive repair may otherwise delete or tombstone an unrelated + * list while leaving the Team's own directory behind. + */ +export function getCanonicalTeamTaskListId(teamName: string): string { + return sanitizeName(teamName) } /** @@ -246,6 +331,15 @@ export function getTaskPath(taskListId: string, taskId: string): string { } export async function ensureTasksDir(taskListId: string): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + ensureTasksDir(taskListId) + )) + } + const lifecycle = await readTaskListLifecycleState(taskListId) + if (lifecycle.deleted) { + throw new Error(`Task list ${taskListId} belongs to a deleted Team lifecycle`) + } const dir = getTasksDir(taskListId) try { await mkdir(dir, { recursive: true }) @@ -296,10 +390,22 @@ async function findHighestTaskId(taskListId: string): Promise { * Uses file locking to prevent race conditions when multiple processes * create tasks concurrently. */ -export async function createTask( +export type TaskCreationResult = { + taskId: string + committedAt: string + revision: number +} + +export async function createTaskWithCommit( taskListId: string, taskData: Omit, -): Promise { +): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + createTaskWithCommit(taskListId, taskData) + )) + } + await assertTaskListLifecycleActive(taskListId) const lockPath = await ensureTaskListLockFile(taskListId) let release: (() => Promise) | undefined @@ -312,9 +418,10 @@ export async function createTask( const id = String(highestId + 1) const task: Task = { id, ...taskData } const path = getTaskPath(taskListId, id) + const revision = await bumpTaskListRevisionLocked(taskListId) await writeFile(path, jsonStringify(task, null, 2)) notifyTasksUpdated() - return id + return { taskId: id, committedAt: new Date().toISOString(), revision } } finally { if (release) { await release() @@ -322,6 +429,13 @@ export async function createTask( } } +export async function createTask( + taskListId: string, + taskData: Omit, +): Promise { + return (await createTaskWithCommit(taskListId, taskData)).taskId +} + export async function getTask( taskListId: string, taskId: string, @@ -382,36 +496,206 @@ async function updateTaskUnsafe( return updated } +/** + * Updates one task while the caller holds the task-list lock. Keeping the + * lock order list -> task makes owner/status transactions linearizable with + * create, reset, delete, and both claim paths. + */ +async function updateTaskWhileTaskListLocked( + taskListId: string, + taskId: string, + updates: Partial>, +): Promise { + const taskPath = getTaskPath(taskListId, taskId) + if (!await getTask(taskListId, taskId)) return null + + let releaseTask: (() => Promise) | undefined + try { + releaseTask = await lockfile.lock(taskPath, LOCK_OPTIONS) + return await updateTaskUnsafe(taskListId, taskId, updates) + } finally { + await releaseTask?.() + } +} + +export type AtomicTaskUpdateResult = { + previous: Task + task: Task + updated: boolean + committedAt: string + revision: number +} + +export type AtomicTaskUpdateOptions = { + addBlocks?: string[] + addBlockedBy?: string[] +} + +/** + * Re-reads a task inside the shared list -> task lock transaction before + * deciding what to write. Callers use this for conditional ownership and + * metadata merges that must not overwrite a concurrent claim or deletion. + */ +export async function updateTaskAtomically( + taskListId: string, + taskId: string, + resolveUpdates: ( + current: Readonly, + ) => Partial> | undefined, + options: AtomicTaskUpdateOptions = {}, +): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + updateTaskAtomically(taskListId, taskId, resolveUpdates, options) + )) + } + await assertTaskListLifecycleActive(taskListId) + const lockPath = await ensureTaskListLockFile(taskListId) + const taskPath = getTaskPath(taskListId, taskId) + let releaseList: (() => Promise) | undefined + let releaseTask: (() => Promise) | undefined + try { + releaseList = await lockfile.lock(lockPath, LOCK_OPTIONS) + if (!await getTask(taskListId, taskId)) return null + releaseTask = await lockfile.lock(taskPath, LOCK_OPTIONS) + const current = await getTask(taskListId, taskId) + if (!current) return null + const previous: Task = { + ...current, + blocks: [...current.blocks], + blockedBy: [...current.blockedBy], + ...(current.metadata ? { metadata: { ...current.metadata } } : {}), + } + const updates = { ...(resolveUpdates(previous) ?? {}) } + const nextBlocks = [...previous.blocks] + const nextBlockedBy = [...previous.blockedBy] + const relatedUpdates = new Map< + string, + { blocks?: string[]; blockedBy?: string[] } + >() + + for (const blockedTaskId of uniq(options.addBlocks ?? [])) { + const blockedTask = blockedTaskId === taskId + ? previous + : await getTask(taskListId, blockedTaskId) + if (!blockedTask) continue + if (!nextBlocks.includes(blockedTaskId)) nextBlocks.push(blockedTaskId) + if (blockedTaskId === taskId) { + if (!nextBlockedBy.includes(taskId)) nextBlockedBy.push(taskId) + continue + } + if (!blockedTask.blockedBy.includes(taskId)) { + const related = relatedUpdates.get(blockedTaskId) ?? {} + related.blockedBy = [...blockedTask.blockedBy, taskId] + relatedUpdates.set(blockedTaskId, related) + } + } + + for (const blockerTaskId of uniq(options.addBlockedBy ?? [])) { + const blockerTask = blockerTaskId === taskId + ? previous + : await getTask(taskListId, blockerTaskId) + if (!blockerTask) continue + if (!nextBlockedBy.includes(blockerTaskId)) nextBlockedBy.push(blockerTaskId) + if (blockerTaskId === taskId) { + if (!nextBlocks.includes(taskId)) nextBlocks.push(taskId) + continue + } + if (!blockerTask.blocks.includes(taskId)) { + const related = relatedUpdates.get(blockerTaskId) ?? {} + related.blocks = [...blockerTask.blocks, taskId] + relatedUpdates.set(blockerTaskId, related) + } + } + + if (JSON.stringify(nextBlocks) !== JSON.stringify(previous.blocks)) { + updates.blocks = nextBlocks + } + if (JSON.stringify(nextBlockedBy) !== JSON.stringify(previous.blockedBy)) { + updates.blockedBy = nextBlockedBy + } + + for (const key of Object.keys(updates) as Array>) { + if (JSON.stringify(updates[key]) === JSON.stringify(previous[key])) { + delete updates[key] + } + } + + const targetChanged = Object.keys(updates).length > 0 + const changed = targetChanged || relatedUpdates.size > 0 + const revision = changed + ? await bumpTaskListRevisionLocked(taskListId) + : await readTaskListRevisionLocked(taskListId) + const updated = targetChanged + ? await updateTaskUnsafe(taskListId, taskId, updates) + : current + if (!updated) return null + + // The list lock is the transaction boundary. Release the target file lock + // before touching reciprocal edge files, while still excluding every + // other list writer and watcher snapshot. + await releaseTask() + releaseTask = undefined + for (const [relatedTaskId, related] of relatedUpdates) { + const relatedTask = await getTask(taskListId, relatedTaskId) + if (!relatedTask) continue + const actualUpdates: Partial> = {} + if ( + related.blocks && + JSON.stringify(related.blocks) !== JSON.stringify(relatedTask.blocks) + ) actualUpdates.blocks = related.blocks + if ( + related.blockedBy && + JSON.stringify(related.blockedBy) !== JSON.stringify(relatedTask.blockedBy) + ) actualUpdates.blockedBy = related.blockedBy + if (Object.keys(actualUpdates).length === 0) continue + await updateTaskWhileTaskListLocked(taskListId, relatedTaskId, actualUpdates) + } + return { + previous, + task: updated, + updated: changed, + committedAt: new Date().toISOString(), + revision, + } + } finally { + await releaseTask?.() + await releaseList?.() + } +} + export async function updateTask( taskListId: string, taskId: string, updates: Partial>, ): Promise { - const path = getTaskPath(taskListId, taskId) - - // Check existence before locking — proper-lockfile throws if the - // target file doesn't exist, and we want a clean null result. - const taskBeforeLock = await getTask(taskListId, taskId) - if (!taskBeforeLock) { - return null - } - - let release: (() => Promise) | undefined - try { - release = await lockfile.lock(path, LOCK_OPTIONS) - return await updateTaskUnsafe(taskListId, taskId, updates) - } finally { - await release?.() - } + return (await updateTaskAtomically(taskListId, taskId, () => updates))?.task ?? null } -export async function deleteTask( +export type TaskDeletionResult = { + deleted: boolean + committedAt?: string + revision?: number +} + +export async function deleteTaskWithCommit( taskListId: string, taskId: string, -): Promise { +): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + deleteTaskWithCommit(taskListId, taskId) + )) + } + await assertTaskListLifecycleActive(taskListId) const path = getTaskPath(taskListId, taskId) + const lockPath = await ensureTaskListLockFile(taskListId) + let releaseList: (() => Promise) | undefined try { + releaseList = await lockfile.lock(lockPath, LOCK_OPTIONS) + if (!await getTask(taskListId, taskId)) return { deleted: false } + const revision = await bumpTaskListRevisionLocked(taskListId) // Update high water mark before deleting to prevent ID reuse const numericId = parseInt(taskId, 10) if (!isNaN(numericId)) { @@ -422,15 +706,7 @@ export async function deleteTask( } // Delete the task file - try { - await unlink(path) - } catch (e) { - const code = getErrnoCode(e) - if (code === 'ENOENT') { - return false - } - throw e - } + await unlink(path) // Remove references to this task from other tasks const allTasks = await listTasks(taskListId) @@ -441,7 +717,7 @@ export async function deleteTask( newBlocks.length !== task.blocks.length || newBlockedBy.length !== task.blockedBy.length ) { - await updateTask(taskListId, task.id, { + await updateTaskWhileTaskListLocked(taskListId, task.id, { blocks: newBlocks, blockedBy: newBlockedBy, }) @@ -449,12 +725,23 @@ export async function deleteTask( } notifyTasksUpdated() - return true - } catch { - return false + return { + deleted: true, + committedAt: new Date().toISOString(), + revision, + } + } finally { + await releaseList?.() } } +export async function deleteTask( + taskListId: string, + taskId: string, +): Promise { + return (await deleteTaskWithCommit(taskListId, taskId)).deleted +} + export async function listTasks(taskListId: string): Promise { const dir = getTasksDir(taskListId) let files: string[] @@ -470,34 +757,108 @@ export async function listTasks(taskListId: string): Promise { return results.filter((t): t is Task => t !== null) } +export type TaskListSnapshot = { + tasks: Task[] + capturedAt: string + revision: number +} + +/** + * Reads one causally ordered task-list snapshot. The timestamp is captured + * after every task has been read while holding the same list lock used by all + * writers, so later transcript projections can distinguish a genuinely newer + * TaskList from a read that merely finished streaming after this snapshot. + */ +export async function readTaskListSnapshot( + taskListId: string, +): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withTaskListLifecycleLock(taskListId, () => ( + readTaskListSnapshot(taskListId) + )) + } + const lifecycle = await readTaskListLifecycleState(taskListId) + if (lifecycle.deleted) { + const terminal = [...lifecycle.terminals].reverse().find(receipt => ( + receipt.generation === lifecycle.generation && + taskListLifecycleIdentityMatches( + receipt.identity, + lifecycle.activeIdentity, + ) + )) + if (!terminal) { + throw new Error(`Task list ${taskListId} was deleted without a terminal frame`) + } + return { + tasks: terminal.tasks.map(task => ({ + ...task, + blocks: [...task.blocks], + blockedBy: [...task.blockedBy], + ...(task.metadata ? { metadata: { ...task.metadata } } : {}), + })), + capturedAt: terminal.capturedAt, + revision: terminal.revision, + } + } + const lockPath = await ensureTaskListLockFile(taskListId) + let releaseList: (() => Promise) | undefined + try { + releaseList = await lockfile.lock(lockPath, LOCK_OPTIONS) + const tasks = await listTasks(taskListId) + const revision = await readTaskListRevisionLocked(taskListId) + return { + tasks, + capturedAt: new Date().toISOString(), + revision, + } + } finally { + await releaseList?.() + } +} + export async function blockTask( taskListId: string, fromTaskId: string, toTaskId: string, ): Promise { - const [fromTask, toTask] = await Promise.all([ - getTask(taskListId, fromTaskId), - getTask(taskListId, toTaskId), - ]) - if (!fromTask || !toTask) { - return false + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + blockTask(taskListId, fromTaskId, toTaskId) + )) } + await assertTaskListLifecycleActive(taskListId) + const lockPath = await ensureTaskListLockFile(taskListId) + let releaseList: (() => Promise) | undefined + try { + releaseList = await lockfile.lock(lockPath, LOCK_OPTIONS) + const [fromTask, toTask] = await Promise.all([ + getTask(taskListId, fromTaskId), + getTask(taskListId, toTaskId), + ]) + if (!fromTask || !toTask) return false + const addForward = !fromTask.blocks.includes(toTaskId) + const addReverse = !toTask.blockedBy.includes(fromTaskId) + if (addForward || addReverse) { + await bumpTaskListRevisionLocked(taskListId) + } - // Update source task: A blocks B - if (!fromTask.blocks.includes(toTaskId)) { - await updateTask(taskListId, fromTaskId, { - blocks: [...fromTask.blocks, toTaskId], - }) + // Both sides of the edge are one list transaction. Concurrent calls that + // add A->B and A->C now re-read A after serialization instead of replacing + // each other's absolute blocks arrays. + if (addForward) { + await updateTaskWhileTaskListLocked(taskListId, fromTaskId, { + blocks: [...fromTask.blocks, toTaskId], + }) + } + if (addReverse) { + await updateTaskWhileTaskListLocked(taskListId, toTaskId, { + blockedBy: [...toTask.blockedBy, fromTaskId], + }) + } + return true + } finally { + await releaseList?.() } - - // Update target task: B is blockedBy A - if (!toTask.blockedBy.includes(fromTaskId)) { - await updateTask(taskListId, toTaskId, { - blockedBy: [...toTask.blockedBy, fromTaskId], - }) - } - - return true } export type ClaimTaskResult = { @@ -520,6 +881,203 @@ function getTaskListLockPath(taskListId: string): string { return join(getTasksDir(taskListId), '.lock') } +function getTaskListLifecycleLockPath(taskListId: string): string { + return join( + getClaudeConfigHomeDir(), + 'tasks', + '.lifecycle-locks', + `${sanitizePathComponent(taskListId)}.lock`, + ) +} + +function getTaskListLifecycleStatePath(taskListId: string): string { + return join( + getClaudeConfigHomeDir(), + 'tasks', + '.lifecycle-locks', + `${sanitizePathComponent(taskListId)}.state.json`, + ) +} + +function emptyTaskListLifecycleState(): TaskListLifecycleState { + return { + schemaVersion: TASK_LIST_LIFECYCLE_STATE_SCHEMA_VERSION, + generation: 0, + deleted: false, + terminals: [], + } +} + +function taskListLifecycleIdentityMatches( + left: TaskListTerminalIdentity | undefined, + right: TaskListTerminalIdentity | undefined, +): boolean { + return left?.teamName === right?.teamName && + left?.createdAt === right?.createdAt && + left?.leadSessionId === right?.leadSessionId +} + +export async function readTaskListLifecycleState( + taskListId: string, +): Promise { + let raw: unknown + try { + raw = jsonParse( + await readFile(getTaskListLifecycleStatePath(taskListId), 'utf-8'), + ) + } catch (error) { + if (getErrnoCode(error) === 'ENOENT') return emptyTaskListLifecycleState() + throw error + } + + const parsed = z.object({ + schemaVersion: z.literal(TASK_LIST_LIFECYCLE_STATE_SCHEMA_VERSION), + generation: z.number().int().nonnegative(), + deleted: z.boolean(), + activeIdentity: z.object({ + teamName: z.string().min(1), + createdAt: z.number().finite(), + leadSessionId: z.string().optional(), + }).optional(), + terminals: z.array(z.object({ + frameId: z.string().min(1), + generation: z.number().int().nonnegative(), + identity: z.object({ + teamName: z.string().min(1), + createdAt: z.number().finite(), + leadSessionId: z.string().optional(), + }), + tasks: z.array(TaskSchema()), + capturedAt: z.string(), + revision: z.number().int().nonnegative(), + })).default([]), + }).passthrough().safeParse(raw) + if (!parsed.success) { + throw new Error( + `Invalid task-list lifecycle state for ${taskListId}: ${parsed.error.message}`, + ) + } + return parsed.data +} + +async function writeTaskListLifecycleStateLocked( + taskListId: string, + state: TaskListLifecycleState, +): Promise { + const statePath = getTaskListLifecycleStatePath(taskListId) + await mkdir(dirname(statePath), { recursive: true }) + const temporaryPath = `${statePath}.${process.pid}.tmp` + try { + await writeFile(temporaryPath, jsonStringify(state, null, 2)) + await rename(temporaryPath, statePath) + } catch (error) { + await unlink(temporaryPath).catch(() => {}) + throw error + } +} + +/** Starts a new Team incarnation and invalidates every queued old writer. */ +export async function beginTaskListLifecycle( + taskListId: string, + identity: TaskListTerminalIdentity, +): Promise { + return withTaskListLifecycleLock(taskListId, async () => { + const previous = await readTaskListLifecycleState(taskListId) + const generation = previous.generation + 1 + await writeTaskListLifecycleStateLocked(taskListId, { + ...previous, + schemaVersion: TASK_LIST_LIFECYCLE_STATE_SCHEMA_VERSION, + generation, + deleted: false, + activeIdentity: identity, + }) + return { generation, identity } + }) +} + +/** + * Commits the terminal DAG outside the task directory before cleanup removes + * it. The durable deleted bit prevents a queued TaskCreate from resurrecting + * an already-ended Team. + */ +export async function completeTaskListLifecycle( + taskListId: string, + snapshot: TaskListSnapshot, + expected: TaskListLifecycleToken, +): Promise { + return withTaskListLifecycleLock(taskListId, async () => { + const previous = await readTaskListLifecycleState(taskListId) + if ( + previous.generation !== expected.generation || + ( + previous.activeIdentity && + !taskListLifecycleIdentityMatches(previous.activeIdentity, expected.identity) + ) + ) { + throw new Error( + `Refusing stale cleanup for task list ${taskListId}: lifecycle identity changed`, + ) + } + const existing = previous.terminals.find(receipt => ( + receipt.generation === expected.generation && + taskListLifecycleIdentityMatches(receipt.identity, expected.identity) + )) + if (previous.deleted && existing) return existing + const terminal: TaskListTerminalReceipt = { + ...snapshot, + frameId: `lifecycle:${expected.generation}:${snapshot.revision}:${snapshot.capturedAt}`, + generation: expected.generation, + identity: expected.identity, + } + await writeTaskListLifecycleStateLocked(taskListId, { + ...previous, + schemaVersion: TASK_LIST_LIFECYCLE_STATE_SCHEMA_VERSION, + generation: expected.generation, + deleted: true, + activeIdentity: expected.identity, + terminals: [ + ...previous.terminals.filter(receipt => !( + receipt.generation === expected.generation && + taskListLifecycleIdentityMatches(receipt.identity, expected.identity) + )), + terminal, + ].slice(-8), + }) + return terminal + }) +} + +async function withActiveTaskListLifecycleLock( + taskListId: string, + run: () => Promise, +): Promise { + const expected = await readTaskListLifecycleState(taskListId) + if (expected.deleted) { + throw new Error(`Task list ${taskListId} belongs to a deleted Team lifecycle`) + } + return withTaskListLifecycleLock(taskListId, async () => { + const current = await readTaskListLifecycleState(taskListId) + if ( + current.deleted || + current.generation !== expected.generation || + !taskListLifecycleIdentityMatches( + current.activeIdentity, + expected.activeIdentity, + ) + ) { + throw new Error(`Task list ${taskListId} lifecycle changed while waiting for the lock`) + } + return run() + }) +} + +async function assertTaskListLifecycleActive(taskListId: string): Promise { + const lifecycle = await readTaskListLifecycleState(taskListId) + if (lifecycle.deleted) { + throw new Error(`Task list ${taskListId} belongs to a deleted Team lifecycle`) + } +} + /** * Ensures the lock file exists for a task list */ @@ -537,6 +1095,47 @@ async function ensureTaskListLockFile(taskListId: string): Promise { return lockPath } +async function ensureTaskListLifecycleLockFile( + taskListId: string, +): Promise { + const lockPath = getTaskListLifecycleLockPath(taskListId) + await mkdir(dirname(lockPath), { recursive: true }) + try { + await writeFile(lockPath, '', { flag: 'wx' }) + } catch { + // The durable lock target is shared across processes and Team deletion. + } + return lockPath +} + +/** + * Serializes a joined Team snapshot with whole-directory cleanup. The normal + * task-list lock lives inside the directory it protects, so it cannot guard + * against TeamDelete removing that directory and recreating an unrelated lock + * inode while a watcher is reading it. + */ +export async function withTaskListLifecycleLock( + taskListId: string, + run: () => Promise, +): Promise { + const identity = sanitizePathComponent(taskListId) + const held = taskListLifecycleContext.getStore() + if (held?.lease.active && held.held.has(identity)) return run() + const lockPath = await ensureTaskListLifecycleLockFile(taskListId) + let release: (() => Promise) | undefined + const context: TaskListLifecycleContext = { + held: new Set([...(held?.held ?? []), identity]), + lease: { active: true }, + } + try { + release = await lockfile.lock(lockPath, LOCK_OPTIONS) + return await taskListLifecycleContext.run(context, run) + } finally { + context.lease.active = false + await release?.() + } +} + export type ClaimTaskOptions = { /** * If true, checks whether the agent is already busy (owns other open tasks) @@ -544,6 +1143,8 @@ export type ClaimTaskOptions = { * using a task-list-level lock to prevent TOCTOU race conditions. */ checkAgentBusy?: boolean + /** Marks ownership and active status in the same lock transaction. */ + markInProgress?: boolean } /** @@ -559,6 +1160,12 @@ export async function claimTask( claimantAgentId: string, options: ClaimTaskOptions = {}, ): Promise { + if (!isTaskListLifecycleLockHeld(taskListId)) { + return withActiveTaskListLifecycleLock(taskListId, () => ( + claimTask(taskListId, taskId, claimantAgentId, options) + )) + } + await assertTaskListLifecycleActive(taskListId) const taskPath = getTaskPath(taskListId, taskId) // Check existence before locking — proper-lockfile.lock throws if the @@ -571,14 +1178,23 @@ export async function claimTask( // If we need to check agent busy status, use task-list-level lock // to prevent TOCTOU race conditions if (options.checkAgentBusy) { - return claimTaskWithBusyCheck(taskListId, taskId, claimantAgentId) + return claimTaskWithBusyCheck( + taskListId, + taskId, + claimantAgentId, + options, + ) } - // Otherwise, use task-level lock (original behavior) - let release: (() => Promise) | undefined + // All task mutations share the list lock; the nested task lock preserves + // compatibility with readers/writers that still coordinate per file. + const lockPath = await ensureTaskListLockFile(taskListId) + let releaseList: (() => Promise) | undefined + let releaseTask: (() => Promise) | undefined try { + releaseList = await lockfile.lock(lockPath, LOCK_OPTIONS) // Acquire exclusive lock on the task file - release = await lockfile.lock(taskPath, LOCK_OPTIONS) + releaseTask = await lockfile.lock(taskPath, LOCK_OPTIONS) // Read current task state const task = await getTask(taskListId, taskId) @@ -609,9 +1225,16 @@ export async function claimTask( } // Claim the task (already holding taskPath lock — use unsafe variant) - const updated = await updateTaskUnsafe(taskListId, taskId, { + const claimUpdates: Partial> = { owner: claimantAgentId, - }) + ...(options.markInProgress ? { status: 'in_progress' as const } : {}), + } + const needsClaimMutation = task.owner !== claimantAgentId || + (options.markInProgress && task.status !== 'in_progress') + if (needsClaimMutation) await bumpTaskListRevisionLocked(taskListId) + const updated = needsClaimMutation + ? await updateTaskUnsafe(taskListId, taskId, claimUpdates) + : task return { success: true, task: updated! } } catch (error) { logForDebugging( @@ -620,9 +1243,8 @@ export async function claimTask( logError(error) return { success: false, reason: 'task_not_found' } } finally { - if (release) { - await release() - } + await releaseTask?.() + await releaseList?.() } } @@ -634,6 +1256,7 @@ async function claimTaskWithBusyCheck( taskListId: string, taskId: string, claimantAgentId: string, + options: ClaimTaskOptions, ): Promise { const lockPath = await ensureTaskListLockFile(taskListId) @@ -688,11 +1311,20 @@ async function claimTaskWithBusyCheck( } } - // Claim the task - const updated = await updateTask(taskListId, taskId, { + // Claim the task inside the same list transaction used for the busy check. + const claimUpdates: Partial> = { owner: claimantAgentId, - }) - return { success: true, task: updated! } + ...(options.markInProgress ? { status: 'in_progress' as const } : {}), + } + const needsClaimMutation = task.owner !== claimantAgentId || + (options.markInProgress && task.status !== 'in_progress') + if (needsClaimMutation) await bumpTaskListRevisionLocked(taskListId) + const updated = needsClaimMutation + ? await updateTaskWhileTaskListLocked(taskListId, taskId, claimUpdates) + : task + return updated + ? { success: true, task: updated } + : { success: false, reason: 'task_not_found' } } catch (error) { logForDebugging( `[Tasks] Failed to claim task ${taskId} with busy check: ${errorMessage(error)}`, @@ -843,14 +1475,25 @@ export async function unassignTeammateTasks( (t.owner === teammateId || t.owner === teammateName), ) - // Unassign each task and reset status to open + // Re-check ownership inside the task-list transaction so shutdown cleanup + // cannot erase a reassignment that another actor committed after this scan. + const unassignedTasks: Array<{ id: string; subject: string }> = [] for (const task of unresolvedAssignedTasks) { - await updateTask(teamName, task.id, { owner: undefined, status: 'pending' }) + const result = await updateTaskAtomically(teamName, task.id, (current) => { + if ( + current.status === 'completed' || + (current.owner !== teammateId && current.owner !== teammateName) + ) return undefined + return { owner: undefined, status: 'pending' } + }) + if (result?.updated) { + unassignedTasks.push({ id: result.task.id, subject: result.task.subject }) + } } - if (unresolvedAssignedTasks.length > 0) { + if (unassignedTasks.length > 0) { logForDebugging( - `[Tasks] Unassigned ${unresolvedAssignedTasks.length} task(s) from ${teammateName}`, + `[Tasks] Unassigned ${unassignedTasks.length} task(s) from ${teammateName}`, ) } @@ -858,18 +1501,15 @@ export async function unassignTeammateTasks( const actionVerb = reason === 'terminated' ? 'was terminated' : 'has shut down' let notificationMessage = `${teammateName} ${actionVerb}.` - if (unresolvedAssignedTasks.length > 0) { - const taskList = unresolvedAssignedTasks + if (unassignedTasks.length > 0) { + const taskList = unassignedTasks .map(t => `#${t.id} "${t.subject}"`) .join(', ') - notificationMessage += ` ${unresolvedAssignedTasks.length} task(s) were unassigned: ${taskList}. Use TaskList to check availability and TaskUpdate with owner to reassign them to idle teammates.` + notificationMessage += ` ${unassignedTasks.length} task(s) were unassigned: ${taskList}. Use TaskList to check availability and TaskUpdate with owner to reassign them to idle teammates.` } return { - unassignedTasks: unresolvedAssignedTasks.map(t => ({ - id: t.id, - subject: t.subject, - })), + unassignedTasks, notificationMessage, } }