diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a3932ae0..cfdf47bf 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -3,7 +3,9 @@ reviews: auto_review: enabled: true - drafts: false + drafts: true + base_branches: + - '.*' auto_title_instructions: | Follow Conventional Commits format: ': ' Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 69b58351..179d4e1d 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -11,8 +11,10 @@ import { getUnreadCount } from '@renderer/services/commentReadStorage'; import { agentAvatarUrl, + buildMemberAvatarMap, buildMemberLaunchPresentation, getMemberRuntimeAdvisoryLabel, + resolveMemberAvatarUrl, } from '@renderer/utils/memberHelpers'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; @@ -143,6 +145,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); + const avatarMap = buildMemberAvatarMap(teamData.members); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -158,6 +161,7 @@ export class TeamGraphAdapter { teamData, teamName, leadName, + avatarMap, pendingApprovalAgents, leadActivity, leadContext, @@ -173,6 +177,7 @@ export class TeamGraphAdapter { teamData, teamName, memberNodeIdByAlias, + avatarMap, spawnStatuses, pendingApprovalAgents, activeTools, @@ -369,6 +374,7 @@ export class TeamGraphAdapter { data: TeamGraphData, teamName: string, leadName: string, + avatarMap: ReadonlyMap, pendingApprovalAgents?: Set, leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, @@ -428,7 +434,9 @@ export class TeamGraphAdapter { launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined, launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, - avatarUrl: agentAvatarUrl(leadName, 64), + avatarUrl: leadMember + ? resolveMemberAvatarUrl(leadMember, avatarMap, 64) + : agentAvatarUrl(leadName, 64), pendingApproval, activeTool: activeTool ? { @@ -465,6 +473,7 @@ export class TeamGraphAdapter { data: TeamGraphData, teamName: string, memberNodeIdByAlias: ReadonlyMap, + avatarMap: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -520,7 +529,7 @@ export class TeamGraphAdapter { spawnStatus: spawn?.status, launchVisualState: launchPresentation.launchVisualState ?? undefined, launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined, - avatarUrl: agentAvatarUrl(member.name, 64), + avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64), currentTaskId: member.currentTaskId ?? undefined, currentTaskSubject: member.currentTaskId ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index a25e3c84..f6794aa1 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -4,9 +4,15 @@ * composes project-specific UI, selectors, and presentation helpers. */ +import { useMemo } from 'react'; + import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; -import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers'; +import { + agentAvatarUrl, + buildMemberAvatarMap, + buildMemberLaunchPresentation, +} from '@renderer/utils/memberHelpers'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; @@ -291,7 +297,6 @@ const MemberPopoverContent = ({ node.domainRef.kind === 'member' || node.domainRef.kind === 'lead' ? node.domainRef.teamName : ''; - const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); const { teamData, teamMembers, @@ -301,6 +306,8 @@ const MemberPopoverContent = ({ memberSpawnSnapshot, memberSpawnStatuses, } = useGraphMemberPopoverContext(teamName, memberName); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64); const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; const provisioningPresentation = teamData && teamName diff --git a/src/main/index.ts b/src/main/index.ts index 42b9d15a..ff376759 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -111,6 +111,7 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { + ActiveTeamRegistry, BoardTaskActivityDetailService, BoardTaskActivityRecordSource, BoardTaskActivityService, @@ -130,6 +131,11 @@ import { TaskBoundaryParser, TeamDataService, TeamLogSourceTracker, + TeamTaskStallJournal, + TeamTaskStallMonitor, + TeamTaskStallNotifier, + TeamTaskStallPolicy, + TeamTaskStallSnapshotSource, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, @@ -415,6 +421,7 @@ let cliInstallerService: CliInstallerService; let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; let schedulerService: SchedulerService; +let teamTaskStallMonitor: TeamTaskStallMonitor | null = null; let skillsWatcherService: SkillsWatcherService | null = null; let teamBackupService: TeamBackupService | null = null; let branchStatusService: BranchStatusService | null = null; @@ -848,6 +855,13 @@ async function initializeServices(): Promise { const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); + teamTaskStallMonitor = new TeamTaskStallMonitor( + new ActiveTeamRegistry(teamDataService, teamLogSourceTracker), + new TeamTaskStallSnapshotSource(), + new TeamTaskStallPolicy(), + new TeamTaskStallJournal(), + new TeamTaskStallNotifier(teamDataService) + ); let teammateToolTracker: TeammateToolTracker | null = null; branchStatusService = new BranchStatusService((event) => { safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event); @@ -930,6 +944,7 @@ async function initializeServices(): Promise { // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). const teamChangeEmitter = (event: TeamChangeEvent): void => { forwardTeamChange(event); + teamTaskStallMonitor?.noteTeamChange(event); if (event.type === 'lead-activity' && event.detail === 'offline') { teammateToolTracker?.handleTeamOffline(event.teamName); } @@ -939,6 +954,7 @@ async function initializeServices(): Promise { teamLogSourceTracker.onLogSourceChange((teamName) => { teammateToolTracker?.handleLogSourceChange(teamName); }); + teamTaskStallMonitor.start(); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { @@ -1142,6 +1158,10 @@ function shutdownServices(): void { if (teamDataService) { teamDataService.stopProcessHealthPolling(); } + if (teamTaskStallMonitor) { + void teamTaskStallMonitor.stop(); + teamTaskStallMonitor = null; + } branchStatusService?.dispose(); branchStatusService = null; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 6540def1..39f2135a 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1075,6 +1075,9 @@ async function handleUpdateConfig( } return wrapTeamHandler('updateConfig', async () => { const tn = validated.value!; + const teamDataService = getTeamDataService(); + const previousDisplayName = await teamDataService.getTeamDisplayName(tn).catch(() => tn); + const requestedName = typeof name === 'string' ? name.trim() : ''; const result = await getTeamDataService().updateConfig(tn, { name, description, @@ -1085,10 +1088,10 @@ async function handleUpdateConfig( } // Notify running lead about the rename so it stays aware of current team name - if (typeof name === 'string' && name.trim()) { + if (requestedName && requestedName !== (previousDisplayName?.trim() || tn)) { const provisioning = getTeamProvisioningService(); if (provisioning.isTeamAlive(tn)) { - const msg = `The team has been renamed to "${name.trim()}". Please use this name when referring to the team going forward.`; + const msg = `The team has been renamed to "${requestedName}". Please use this name when referring to the team going forward.`; try { await provisioning.sendMessageToTeam(tn, msg); } catch { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5809c294..9b1e6518 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,9 +12,10 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; -import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; +import { parseNumericSuffixName, validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; @@ -130,6 +131,16 @@ interface FileWatchReconcileDiagnostics { lastPressureLogAt: number; } +function applyDistinctRosterColors( + members: readonly T[] +): T[] { + const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false }); + return members.map((member) => ({ + ...member, + color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name), + })); +} + function normalizePassiveUserReplyLinkText(value: string | undefined): string { if (typeof value !== 'string') return ''; return value @@ -500,6 +511,27 @@ export class TeamDataService { return this.configReader.listTeams(); } + async listAliveProcessTeams(): Promise { + const teams = await this.listTeams(); + const alive: string[] = []; + + for (const team of teams) { + if (team.deletedAt) { + continue; + } + try { + const processes = await this.readProcesses(team.teamName); + if (processes.some((process) => !process.stoppedAt)) { + alive.push(team.teamName); + } + } catch { + // best-effort per team + } + } + + return alive.sort((left, right) => left.localeCompare(right)); + } + async getAllTasks(): Promise { const rawTasks = await this.taskReader.getAllTasks(); const teams = await this.configReader.listTeams(); @@ -1161,7 +1193,7 @@ export class TeamDataService { role: configMember.role, workflow: configMember.workflow, agentType: configMember.agentType ?? 'general-purpose', - color: configMember.color ?? getMemberColorByName(configMember.name.trim()), + color: configMember.color, joinedAt: configMember.joinedAt ?? Date.now(), cwd: configMember.cwd, }; @@ -1176,13 +1208,13 @@ export class TeamDataService { member = { name: memberName, agentType: 'general-purpose', - color: getMemberColorByName(memberName), joinedAt: Date.now(), }; } - members.push(member); - await this.membersMetaStore.writeMembers(teamName, members); + const nextMembers = applyDistinctRosterColors([...members, member]); + member = nextMembers.find((m) => m.name === memberName) ?? member; + await this.membersMetaStore.writeMembers(teamName, nextMembers); } return { members, member }; @@ -1193,6 +1225,13 @@ export class TeamDataService { if (!name) { throw new Error('Member name cannot be empty'); } + const formatError = validateTeamMemberNameFormat(name); + if (formatError) { + throw new Error(`Member name "${name}" is invalid: ${formatError}`); + } + if (name.toLowerCase() === 'user') { + throw new Error('Member name "user" is reserved'); + } const suffixInfo = parseNumericSuffixName(name); if (suffixInfo && suffixInfo.suffix >= 2) { throw new Error( @@ -1224,12 +1263,11 @@ export class TeamDataService { ? request.effort : undefined, agentType: 'general-purpose', - color: getMemberColorByName(name), joinedAt: Date.now(), }; - members.push(newMember); - await this.membersMetaStore.writeMembers(teamName, members); + const nextMembers = applyDistinctRosterColors([...members, newMember]); + await this.membersMetaStore.writeMembers(teamName, nextMembers); } async updateMemberRole( @@ -1269,36 +1307,50 @@ export class TeamDataService { const joinedAt = Date.now(); const nextByName = new Set(); - const nextActive: TeamMember[] = request.members.map((member) => { - const name = member.name.trim(); - if (!name) throw new Error('Member name cannot be empty'); - if (name.toLowerCase() === 'team-lead') { - throw new Error('Member name "team-lead" is reserved'); - } - const suffixInfo = parseNumericSuffixName(name); - if (suffixInfo && suffixInfo.suffix >= 2) { - throw new Error( - `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.` - ); - } - nextByName.add(name.toLowerCase()); - const prev = existingByName.get(name.toLowerCase()); - return { - name, - role: member.role?.trim() || undefined, - workflow: member.workflow?.trim() || undefined, - providerId: normalizeOptionalTeamProviderId(member.providerId), - model: member.model?.trim() || undefined, - effort: - member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' - ? member.effort - : undefined, - agentType: prev?.agentType ?? 'general-purpose', - color: prev?.color ?? getMemberColorByName(name), - joinedAt: prev?.joinedAt ?? joinedAt, - removedAt: undefined, - }; - }); + const nextActive = applyDistinctRosterColors( + request.members.map((member) => { + const name = member.name.trim(); + if (!name) throw new Error('Member name cannot be empty'); + const formatError = validateTeamMemberNameFormat(name); + if (formatError) { + throw new Error(`Member name "${name}" is invalid: ${formatError}`); + } + if (name.toLowerCase() === 'user') { + throw new Error('Member name "user" is reserved'); + } + if (name.toLowerCase() === 'team-lead') { + throw new Error('Member name "team-lead" is reserved'); + } + if (nextByName.has(name.toLowerCase())) { + throw new Error(`Member "${name}" already exists`); + } + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + throw new Error( + `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.` + ); + } + nextByName.add(name.toLowerCase()); + const prev = existingByName.get(name.toLowerCase()); + const isSameActiveMember = Boolean(prev && prev.removedAt == null); + return { + name, + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(member.providerId), + model: member.model?.trim() || undefined, + effort: + member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' + ? member.effort + : undefined, + agentType: prev?.agentType ?? 'general-purpose', + agentId: isSameActiveMember ? prev?.agentId : undefined, + color: prev?.color, + joinedAt: prev?.joinedAt ?? joinedAt, + removedAt: undefined, + }; + }) + ); // Preserve/mark removed members so stale inbox files don't resurrect them in the UI. const nextRemoved: TeamMember[] = []; @@ -1712,6 +1764,23 @@ export class TeamDataService { return result; } + async sendSystemNotificationToLead(args: { + teamName: string; + summary: string; + text: string; + taskRefs?: TaskRef[]; + }): Promise { + const leadName = await this.resolveLeadName(args.teamName); + return this.sendMessage(args.teamName, { + member: leadName, + from: 'system', + summary: args.summary, + text: args.text, + ...(args.taskRefs && args.taskRefs.length > 0 ? { taskRefs: args.taskRefs } : {}), + source: TASK_COMMENT_NOTIFICATION_SOURCE, + }); + } + private resolveLeadNameFromConfig(config: TeamConfig | null): string { if (!config) return 'team-lead'; const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); @@ -2323,12 +2392,18 @@ export class TeamDataService { createdAt: joinedAt, }); - await this.membersMetaStore.writeMembers( - request.teamName, + const membersToWrite = applyDistinctRosterColors( request.members.map((member) => ({ name: (() => { const name = member.name.trim(); if (!name) throw new Error('Member name cannot be empty'); + const formatError = validateTeamMemberNameFormat(name); + if (formatError) { + throw new Error(`Member name "${name}" is invalid: ${formatError}`); + } + if (name.toLowerCase() === 'user') { + throw new Error('Member name "user" is reserved'); + } if (name.toLowerCase() === 'team-lead') throw new Error('Member name "team-lead" is reserved'); const suffixInfo = parseNumericSuffixName(name); @@ -2347,14 +2422,14 @@ export class TeamDataService { member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' ? member.effort : undefined, - agentType: 'general-purpose', - color: getMemberColorByName(member.name.trim()), + agentType: 'general-purpose' as const, joinedAt, })), { providerBackendId: request.providerBackendId, } ); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); } async reconcileTeamArtifacts( diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 045cc007..0f99a0ce 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -22,7 +22,11 @@ interface TeamLogSourceSnapshot { logSourceGeneration: string | null; } -export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream'; +export type TeamLogSourceTrackingConsumer = + | 'change_presence' + | 'tool_activity' + | 'task_log_stream' + | 'stall_monitor'; interface TrackingState { watcher: FSWatcher | null; diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index ec65957e..62bc8f06 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -3,6 +3,7 @@ import { createCliAutoSuffixNameGuard, createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; @@ -262,6 +263,11 @@ export class TeamMemberResolver { } return aStableId.localeCompare(bStableId); }); - return members; + + const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false }); + return members.map((member) => ({ + ...member, + color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name), + })); } } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index b40d01f8..d2817917 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -1,4 +1,5 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { createHash } from 'crypto'; @@ -7,6 +8,8 @@ import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import type { InboxMessage, TeamConfig } from '@shared/types'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000; +const logger = createLogger('Service:TeamMessageFeedService'); interface TeamMessageFeedDeps { getConfig: (teamName: string) => Promise; @@ -18,6 +21,7 @@ interface TeamMessageFeedDeps { interface TeamMessageFeedCacheEntry { feedRevision: string; messages: InboxMessage[]; + cachedAt: number; } export interface TeamNormalizedMessageFeed { @@ -352,7 +356,10 @@ export class TeamMessageFeedService { async getFeed(teamName: string): Promise { const cached = this.cacheByTeam.get(teamName); - if (cached && !this.dirtyTeams.has(teamName)) { + const now = Date.now(); + const cacheDirty = this.dirtyTeams.has(teamName); + const cacheExpired = !cached || now - cached.cachedAt >= MESSAGE_FEED_CACHE_MAX_AGE_MS; + if (cached && !cacheDirty && !cacheExpired) { return { teamName, feedRevision: cached.feedRevision, @@ -362,7 +369,7 @@ export class TeamMessageFeedService { const config = await this.deps.getConfig(teamName); if (!config) { - const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] }; + const emptyEntry = { feedRevision: toFeedRevision([]), messages: [], cachedAt: now }; this.cacheByTeam.set(teamName, emptyEntry); this.dirtyTeams.delete(teamName); return { teamName, ...emptyEntry }; @@ -389,12 +396,21 @@ export class TeamMessageFeedService { }); const feedRevision = toFeedRevision(messages); + if (cached && !cacheDirty && cacheExpired && cached.feedRevision !== feedRevision) { + logger.warn( + `[${teamName}] Message feed cache expired without dirty invalidation and recovered newer durable messages` + ); + } const nextEntry = cached?.feedRevision === feedRevision - ? cached + ? { + ...cached, + cachedAt: now, + } : { feedRevision, messages, + cachedAt: now, }; this.cacheByTeam.set(teamName, nextEntry); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5ca9aec3..f2fab167 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -38,6 +38,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { @@ -225,6 +227,16 @@ const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; + +function applyDistinctProvisioningMemberColors< + T extends { name: string; color?: string; removedAt?: number }, +>(members: readonly T[]): T[] { + const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false }); + return members.map((member) => ({ + ...member, + color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name), + })); +} const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; const STALL_CHECK_INTERVAL_MS = 10_000; @@ -733,6 +745,8 @@ interface ProvisioningRun { >; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; + /** Explicit restart requests awaiting teammate rejoin or failure. */ + pendingMemberRestarts: Map; /** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */ memberSpawnLeadInboxCursorByMember: Map; /** Highest accepted deterministic bootstrap event sequence for this run. */ @@ -827,6 +841,78 @@ interface LiveTeamAgentRuntimeMetadata { tmuxPaneId?: string; } +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function commandContainsCliArgValue(command: string, argName: string, value: string): boolean { + const normalizedCommand = command.trim(); + const normalizedValue = value.trim(); + if (!normalizedCommand || !normalizedValue) { + return false; + } + const pattern = new RegExp( + `(?:^|\\s)${escapeRegexLiteral(argName)}(?:=|\\s+)${escapeRegexLiteral(normalizedValue)}(?:\\s|$)` + ); + return pattern.test(normalizedCommand); +} + +function isNeverSpawnedDuringLaunchReason(reason?: string): boolean { + return reason?.trim() === 'Teammate was never spawned during launch.'; +} + +function isLaunchGraceWindowFailureReason(reason?: string): boolean { + return reason?.trim() === 'Teammate did not join within the launch grace window.'; +} + +function isConfigRegistrationFailureReason(reason?: string): boolean { + return ( + reason?.trim() === + 'Teammate was not registered in config.json during launch. Persistent spawn failed.' + ); +} + +function isTmuxNoServerRunningError(error: unknown): boolean { + const text = error instanceof Error ? error.message : String(error ?? ''); + return /no server running on /i.test(text); +} + +function isAutoClearableLaunchFailureReason(reason?: string): boolean { + return ( + isNeverSpawnedDuringLaunchReason(reason) || + isLaunchGraceWindowFailureReason(reason) || + isConfigRegistrationFailureReason(reason) + ); +} + +function buildRestartStillRunningReason(memberName: string): string { + return ( + `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` + + `to be active. The requested settings may not have been applied.` + ); +} + +function buildRestartDuplicateUnconfirmedReason(memberName: string, rawReason?: string): string { + const suffix = rawReason?.trim() + ? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".` + : ' Agent returned duplicate_skipped without a reason.'; + return ( + `Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix + ); +} + +function buildRestartGraceTimeoutReason(memberName: string): string { + return `Teammate "${memberName}" did not rejoin within the restart grace window.`; +} + +interface PendingMemberRestartContext { + requestedAt: string; + desired: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + >; +} + function normalizeTeamAgentRuntimeBackendType( value: string | undefined, isLead: boolean @@ -1000,19 +1086,60 @@ function sleep(ms: number): Promise { async function waitForPidsToExit( pids: readonly number[], opts: { timeoutMs: number; pollMs: number } -): Promise { +): Promise { if (pids.length === 0) { - return; + return []; } const deadline = Date.now() + opts.timeoutMs; + let remainingPids = [...new Set(pids)]; while (Date.now() < deadline) { - const remaining = pids.filter((pid) => isProcessAlive(pid)); - if (remaining.length === 0) { - return; + remainingPids = remainingPids.filter((pid) => isProcessAlive(pid)); + if (remainingPids.length === 0) { + return []; } await sleep(opts.pollMs); } + + return remainingPids; +} + +async function waitForTmuxPanesToExit( + paneIds: readonly string[], + opts: { timeoutMs: number; pollMs: number } +): Promise { + const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; + if (normalizedPaneIds.length === 0) { + return []; + } + + const deadline = Date.now() + opts.timeoutMs; + let remainingPaneIds = normalizedPaneIds; + let lastError: unknown = null; + while (Date.now() < deadline) { + let livePanePidById: Map; + try { + livePanePidById = await listTmuxPanePidsForCurrentPlatform(remainingPaneIds); + lastError = null; + } catch (error) { + if (isTmuxNoServerRunningError(error)) { + return []; + } + lastError = error; + await sleep(opts.pollMs); + continue; + } + remainingPaneIds = remainingPaneIds.filter((paneId) => livePanePidById.has(paneId)); + if (remainingPaneIds.length === 0) { + return []; + } + await sleep(opts.pollMs); + } + + if (lastError) { + throw lastError instanceof Error ? lastError : new Error(getErrorMessage(lastError)); + } + return remainingPaneIds; } async function waitForChildProcessToExit( @@ -1646,7 +1773,9 @@ export function buildRestartMemberSpawnMessage( return ( `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` + - `This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` + + `This is a restart of an existing persistent teammate, not a new teammate. ` + + `If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` + + `If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` + indentMultiline(prompt, ' ') ); } @@ -3937,6 +4066,7 @@ export class TeamProvisioningService { const spawnedMemberName = run.memberSpawnToolUseIds.get(toolUseId); if (spawnedMemberName) { run.memberSpawnToolUseIds.delete(toolUseId); + const pendingRestart = run.pendingMemberRestarts.get(spawnedMemberName); if (isError) { const resultPreview = extractToolResultPreview(resultContent); this.handleMemberSpawnFailure(run, spawnedMemberName, resultPreview); @@ -3946,8 +4076,42 @@ export class TeamProvisioningService { const detail = parsedStatus.reason === 'already_running' ? 'duplicate spawn skipped - already running' - : 'duplicate spawn skipped - teammate already online'; + : parsedStatus.reason === 'bootstrap_pending' + ? 'duplicate spawn skipped - teammate bootstrap still pending' + : parsedStatus.rawReason + ? `duplicate spawn skipped - unrecognized reason: ${parsedStatus.rawReason}` + : 'duplicate spawn skipped - reason unavailable'; this.appendMemberBootstrapDiagnostic(run, spawnedMemberName, detail); + if (pendingRestart && !parsedStatus.reason) { + logger.warn( + `[${run.teamName}] Restart for teammate "${spawnedMemberName}" returned duplicate_skipped without a recognized reason` + ); + run.pendingMemberRestarts.delete(spawnedMemberName); + this.setMemberSpawnStatus( + run, + spawnedMemberName, + 'error', + buildRestartDuplicateUnconfirmedReason(spawnedMemberName, parsedStatus.rawReason) + ); + return; + } + if (parsedStatus.reason === 'already_running') { + if (pendingRestart) { + run.pendingMemberRestarts.delete(spawnedMemberName); + this.setMemberSpawnStatus( + run, + spawnedMemberName, + 'error', + buildRestartStillRunningReason(spawnedMemberName) + ); + return; + } + this.agentRuntimeSnapshotCache.delete(run.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process'); + } else { + this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); + } return; } @@ -3965,11 +4129,16 @@ export class TeamProvisioningService { memberName: string, resultPreview?: string ): void { + const pendingRestart = run.pendingMemberRestarts.get(memberName); const reason = (typeof resultPreview === 'string' && resultPreview.trim().length > 0 ? resultPreview.trim() : 'Teammate spawn failed immediately after launch.') || 'Teammate spawn failed.'; - const message = `Teammate "${memberName}" failed to start: ${reason}`; + const message = pendingRestart + ? `Failed to restart teammate "${memberName}": ${reason}` + : `Teammate "${memberName}" failed to start: ${reason}`; + + run.pendingMemberRestarts.delete(memberName); this.setMemberSpawnStatus(run, memberName, 'error', message); @@ -4022,6 +4191,23 @@ export class TeamProvisioningService { } } + private clearMemberSpawnToolTracking(run: ProvisioningRun, memberName: string): void { + let removed = false; + for (const [toolUseId, trackedMemberName] of run.memberSpawnToolUseIds.entries()) { + if (trackedMemberName !== memberName) continue; + run.memberSpawnToolUseIds.delete(toolUseId); + removed = true; + } + + if (removed) { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + 'cleared stale spawn tool tracking before manual restart' + ); + } + } + /** * Update spawn status for a specific team member and emit a change event. */ @@ -4034,6 +4220,21 @@ export class TeamProvisioningService { heartbeatAt?: string ): void { const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); + if ( + status === 'waiting' && + !prev.hardFailure && + (prev.bootstrapConfirmed || prev.runtimeAlive) + ) { + this.setMemberSpawnStatus( + run, + memberName, + 'online', + undefined, + prev.livenessSource, + prev.lastHeartbeatAt + ); + return; + } const updatedAt = nowIso(); const next: MemberSpawnStatusEntry = { ...prev, @@ -4042,13 +4243,26 @@ export class TeamProvisioningService { }; if (status === 'spawning') { - next.launchState = 'starting'; - } else if (status === 'waiting') { - next.agentToolAccepted = true; + next.agentToolAccepted = false; + next.runtimeAlive = false; + next.bootstrapConfirmed = false; next.hardFailure = false; next.error = undefined; next.hardFailureReason = undefined; + next.livenessSource = undefined; + next.firstSpawnAcceptedAt = undefined; + next.lastHeartbeatAt = undefined; + next.launchState = 'starting'; + } else if (status === 'waiting') { + next.agentToolAccepted = true; + next.runtimeAlive = false; + next.bootstrapConfirmed = false; + next.hardFailure = false; + next.error = undefined; + next.hardFailureReason = undefined; + next.livenessSource = undefined; next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; + next.lastHeartbeatAt = undefined; next.launchState = 'runtime_pending_bootstrap'; } else if (status === 'online') { next.agentToolAccepted = true; @@ -4076,6 +4290,11 @@ export class TeamProvisioningService { next.launchState = 'failed_to_start'; } else if (status === 'offline') { Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt }); + next.error = undefined; + next.hardFailureReason = undefined; + next.livenessSource = undefined; + next.firstSpawnAcceptedAt = undefined; + next.lastHeartbeatAt = undefined; } next.launchState = deriveMemberLaunchState(next); @@ -4096,6 +4315,13 @@ export class TeamProvisioningService { } run.memberSpawnStatuses.set(memberName, next); + if ( + (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || + status === 'offline' || + status === 'error' + ) { + run.pendingMemberRestarts?.delete(memberName); + } this.syncMemberLaunchGraceCheck(run, memberName, next); if (status === 'spawning') { @@ -4334,11 +4560,38 @@ export class TeamProvisioningService { throw new Error(`Team "${teamName}" is not currently running`); } - const config = await this.configReader.getConfig(teamName); - const configuredMembers = config?.members ?? []; - const configuredMember = configuredMembers.find( - (member) => member?.name?.trim() === memberName - ); + const readCurrentConfiguredMember = async (): Promise<{ + config: TeamConfig | null; + configuredMembers: TeamConfig['members']; + metaMembers: Awaited>; + configuredMember: ReturnType; + }> => { + const config = await this.configReader.getConfig(teamName); + const configuredMembers = config?.members ?? []; + let metaMembers: Awaited> = []; + try { + metaMembers = await this.membersMetaStore.getMembers(teamName); + } catch { + metaMembers = []; + } + + return { + config, + configuredMembers, + metaMembers, + configuredMember: this.resolveEffectiveConfiguredMember( + configuredMembers, + metaMembers, + memberName + ), + }; + }; + + let { config, configuredMembers, metaMembers, configuredMember } = + await readCurrentConfiguredMember(); + if (!config) { + throw new Error(`Team "${teamName}" configuration is no longer available`); + } if (!configuredMember) { throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); } @@ -4348,6 +4601,9 @@ export class TeamProvisioningService { if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { throw new Error('Lead restart is not supported from member controls'); } + if (run.pendingMemberRestarts.has(memberName)) { + throw new Error(`Restart for teammate "${memberName}" is already in progress`); + } const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; @@ -4365,6 +4621,8 @@ export class TeamProvisioningService { ); } + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const livePids = new Set(); let hasAliveRuntimeWithoutPid = false; @@ -4387,6 +4645,7 @@ export class TeamProvisioningService { ); } + const tmuxPaneIdsToVerify: string[] = []; for (const persistedRuntimeMember of persistedRuntimeMembers) { const paneId = typeof persistedRuntimeMember.tmuxPaneId === 'string' @@ -4396,6 +4655,7 @@ export class TeamProvisioningService { if (!paneId || backendType !== 'tmux') { continue; } + tmuxPaneIdsToVerify.push(paneId); try { killTmuxPaneForCurrentPlatformSync(paneId); logger.info( @@ -4423,26 +4683,94 @@ export class TeamProvisioningService { } if (livePids.size > 0) { - await waitForPidsToExit(Array.from(livePids), { + const lingeringPids = await waitForPidsToExit(Array.from(livePids), { timeoutMs: 1_500, pollMs: 100, }); + if (lingeringPids.length > 0) { + throw new Error( + `Restart for teammate "${memberName}" is still waiting for the previous process to exit (${lingeringPids.join(', ')}).` + ); + } + } + + if (tmuxPaneIdsToVerify.length > 0) { + let lingeringPaneIds: string[]; + try { + lingeringPaneIds = await waitForTmuxPanesToExit(tmuxPaneIdsToVerify, { + timeoutMs: 1_500, + pollMs: 100, + }); + } catch (error) { + throw new Error( + `Restart for teammate "${memberName}" could not verify that the previous tmux pane exited: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (lingeringPaneIds.length > 0) { + throw new Error( + `Restart for teammate "${memberName}" is still waiting for the previous tmux pane to exit (${lingeringPaneIds.join(', ')}).` + ); + } + } + + this.setMemberSpawnStatus(run, memberName, 'offline'); + + const latestRunId = this.getAliveRunId(teamName); + const currentRun = this.runs.get(runId); + if ( + latestRunId !== runId || + !currentRun || + currentRun !== run || + currentRun.processKilled || + currentRun.cancelRequested + ) { + throw new Error(`Team "${teamName}" is not currently running`); + } + + ({ config, configuredMembers, metaMembers, configuredMember } = + await readCurrentConfiguredMember()); + if (!config) { + throw new Error(`Team "${teamName}" configuration disappeared while restart was in progress`); + } + if (!configuredMember) { + throw new Error( + `Member "${memberName}" is no longer configured in team "${teamName}" after restart preparation` + ); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" was removed while restart was in progress`); + } + if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { + throw new Error('Lead restart is not supported from member controls'); } this.agentRuntimeSnapshotCache.delete(teamName); this.liveTeamAgentRuntimeMetadataCache.delete(teamName); - this.setMemberSpawnStatus(run, memberName, 'offline'); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); this.setMemberSpawnStatus(run, memberName, 'spawning'); this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); + run.pendingMemberRestarts.set(memberName, { + requestedAt: nowIso(), + desired: { + name: configuredMember.name, + role: configuredMember.role, + workflow: configuredMember.workflow, + providerId: configuredMember.providerId, + model: configuredMember.model, + effort: configuredMember.effort, + }, + }); - const leadName = - configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead'; + const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers); const restartMessage = buildRestartMemberSpawnMessage( teamName, config?.name?.trim() || teamName, leadName, { - name: memberName, + name: configuredMember.name, role: configuredMember.role, workflow: configuredMember.workflow, providerId: configuredMember.providerId, @@ -4454,6 +4782,7 @@ export class TeamProvisioningService { try { await this.sendMessageToRun(run, restartMessage); } catch (error) { + run.pendingMemberRestarts.delete(memberName); this.setMemberSpawnStatus( run, memberName, @@ -4483,6 +4812,10 @@ export class TeamProvisioningService { return; } if (!entry.firstSpawnAcceptedAt) { + if (existing) { + clearTimeout(existing); + this.pendingTimeouts.delete(key); + } return; } const remainingMs = @@ -4530,11 +4863,17 @@ export class TeamProvisioningService { ) { return; } + const restartPending = run.pendingMemberRestarts.has(memberName); + if (restartPending) { + run.pendingMemberRestarts.delete(memberName); + } this.setMemberSpawnStatus( run, memberName, 'error', - 'Teammate did not join within the launch grace window.' + restartPending + ? buildRestartGraceTimeoutReason(memberName) + : 'Teammate did not join within the launch grace window.' ); } @@ -5954,6 +6293,7 @@ export class TeamProvisioningService { request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + pendingMemberRestarts: new Map(), memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, @@ -6074,8 +6414,7 @@ export class TeamProvisioningService { limitContext: request.limitContext, createdAt: Date.now(), }); - await this.membersMetaStore.writeMembers( - request.teamName, + const membersToWrite = applyDistinctProvisioningMemberColors( effectiveMemberSpecs.map((m) => ({ name: m.name.trim(), role: m.role?.trim() || undefined, @@ -6087,13 +6426,13 @@ export class TeamProvisioningService { ? m.effort : undefined, agentType: 'general-purpose' as const, - color: getMemberColorByName(m.name.trim()), joinedAt: Date.now(), })), { providerBackendId: request.providerBackendId, } ); + await this.membersMetaStore.writeMembers(request.teamName, membersToWrite); if (request.skipPermissions === false) { await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); } @@ -6550,6 +6889,7 @@ export class TeamProvisioningService { expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + pendingMemberRestarts: new Map(), memberSpawnLeadInboxCursorByMember: new Map(), lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, @@ -8053,13 +8393,29 @@ export class TeamProvisioningService { const nextStatuses = { ...statuses }; for (const [memberName, metadata] of runtimeByMember.entries()) { const current = nextStatuses[memberName]; - if (!current || !metadata.model) { + if (!current) { continue; } - nextStatuses[memberName] = { + const nextEntry: MemberSpawnStatusEntry = { ...current, - runtimeModel: metadata.model, + ...(metadata.model ? { runtimeModel: metadata.model } : {}), }; + const failureReason = current.hardFailureReason ?? current.error; + if ( + metadata.alive && + current.launchState === 'failed_to_start' && + isAutoClearableLaunchFailureReason(failureReason) + ) { + nextEntry.status = 'online'; + nextEntry.agentToolAccepted = true; + nextEntry.runtimeAlive = true; + nextEntry.hardFailure = false; + nextEntry.hardFailureReason = undefined; + nextEntry.error = undefined; + nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; + nextEntry.launchState = deriveMemberLaunchState(nextEntry); + } + nextStatuses[memberName] = nextEntry; } return nextStatuses; } @@ -8107,6 +8463,87 @@ export class TeamProvisioningService { return undefined; } + private resolveEffectiveConfiguredMember( + configuredMembers: TeamConfig['members'] | undefined, + metaMembers: Awaited>, + memberName: string + ): { + name: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + agentType?: string; + removedAt?: number | string; + } | null { + const configuredMember = (configuredMembers ?? []).find((member) => { + const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName); + }); + const metaMember = metaMembers.find((member) => { + const candidateName = member.name?.trim() ?? ''; + return candidateName.length > 0 && matchesTeamMemberIdentity(candidateName, memberName); + }); + + if (!configuredMember && !metaMember) { + return null; + } + + const name = + metaMember?.name?.trim() || configuredMember?.name?.trim() || memberName.trim() || memberName; + const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined; + const workflow = + metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined; + const providerId = + normalizeTeamMemberProviderId(metaMember?.providerId) ?? + normalizeTeamMemberProviderId(configuredMember?.providerId); + const model = metaMember?.model?.trim() || configuredMember?.model?.trim() || undefined; + const effort = + metaMember?.effort === 'low' || + metaMember?.effort === 'medium' || + metaMember?.effort === 'high' + ? metaMember.effort + : configuredMember?.effort === 'low' || + configuredMember?.effort === 'medium' || + configuredMember?.effort === 'high' + ? configuredMember.effort + : undefined; + const agentType = + metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; + const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; + + return { + name, + ...(role ? { role } : {}), + ...(workflow ? { workflow } : {}), + ...(providerId ? { providerId } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(agentType ? { agentType } : {}), + ...(removedAt != null ? { removedAt } : {}), + }; + } + + private resolveLeadMemberName( + configuredMembers: TeamConfig['members'] | undefined, + metaMembers: Awaited> + ): string { + const configuredLead = (configuredMembers ?? []).find((member) => isLeadMember(member)); + const configuredLeadName = configuredLead?.name?.trim(); + if (configuredLeadName) { + return configuredLeadName; + } + + const metaLead = metaMembers.find((member) => isLeadMember(member)); + const metaLeadName = metaLead?.name?.trim(); + if (metaLeadName) { + return metaLeadName; + } + + return 'team-lead'; + } + private findEffectiveRunMemberModel( run: ProvisioningRun | null, memberName: string @@ -8222,6 +8659,23 @@ export class TeamProvisioningService { }); } + for (const member of metaMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName || isLeadMember({ name: memberName, agentType: member.agentType })) { + continue; + } + const runtimeModel = + member.model?.trim() || + this.findConfiguredMemberModel(configuredMembers, memberName) || + this.findEffectiveRunMemberModel(run, memberName); + upsertMetadata(memberName, { + ...(runtimeModel ? { model: runtimeModel } : {}), + ...(typeof member.agentId === 'string' && member.agentId.trim() + ? { agentId: member.agentId.trim() } + : {}), + }); + } + for (const member of run?.effectiveMembers ?? []) { const memberName = member.name?.trim() ?? ''; if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') { @@ -8248,21 +8702,38 @@ export class TeamProvisioningService { } } + const unresolvedAgentIds = [...metadataByMember.values()] + .map((metadata) => metadata.agentId?.trim() ?? '') + .filter((agentId) => agentId.length > 0); + const processPidByAgentId = + unresolvedAgentIds.length > 0 + ? this.findLiveProcessPidByAgentId(teamName, unresolvedAgentIds) + : new Map(); + for (const [memberName, metadata] of metadataByMember.entries()) { const paneId = metadata.tmuxPaneId?.trim() ?? ''; const backendType = metadata.backendType; const panePid = paneId ? panePidById.get(paneId) : undefined; - const status = this.findTrackedMemberSpawnStatus(run, memberName); - const alive = + const processPid = metadata.agentId ? processPidByAgentId.get(metadata.agentId) : undefined; + const resolvedPid = typeof panePid === 'number' && panePid > 0 + ? panePid + : typeof processPid === 'number' && processPid > 0 + ? processPid + : undefined; + const status = this.findTrackedMemberSpawnStatus(run, memberName); + const mayInferAliveFromStatusOnly = status?.launchState !== 'failed_to_start'; + const alive = + typeof resolvedPid === 'number' && resolvedPid > 0 ? true : backendType === 'tmux' ? false - : Boolean(status?.runtimeAlive || status?.bootstrapConfirmed); + : mayInferAliveFromStatusOnly && + Boolean(status?.runtimeAlive || status?.bootstrapConfirmed); metadataByMember.set(memberName, { ...metadata, alive, - ...(typeof panePid === 'number' && panePid > 0 ? { pid: panePid } : {}), + ...(typeof resolvedPid === 'number' && resolvedPid > 0 ? { pid: resolvedPid } : {}), }); } @@ -8310,6 +8781,46 @@ export class TeamProvisioningService { return rows; } + private findLiveProcessPidByAgentId( + teamName: string, + agentIds: readonly string[] + ): Map { + const normalizedAgentIds = [ + ...new Set(agentIds.map((agentId) => agentId.trim()).filter(Boolean)), + ]; + if (normalizedAgentIds.length === 0) { + return new Map(); + } + + const rows = this.readUnixProcessTableRows(); + if (rows.length === 0) { + return new Map(); + } + + const pidByAgentId = new Map(); + for (const row of rows) { + if ( + !commandContainsCliArgValue(row.command, '--team-name', teamName) || + !row.command.includes('--agent-id') + ) { + continue; + } + + for (const agentId of normalizedAgentIds) { + if (!commandContainsCliArgValue(row.command, '--agent-id', agentId)) { + continue; + } + const currentPid = pidByAgentId.get(agentId); + if (!currentPid || row.pid > currentPid) { + pidByAgentId.set(agentId, row.pid); + } + break; + } + } + + return pidByAgentId; + } + private async readProcessRssBytesByPid(pids: readonly number[]): Promise> { const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))]; if (uniquePids.length === 0) { @@ -8518,6 +9029,7 @@ export class TeamProvisioningService { const nextMembers = { ...persisted.members }; const now = nowIso(); for (const expected of persisted.expectedMembers) { + const bootstrapMember = bootstrapSnapshot?.members[expected]; const current = nextMembers[expected] ?? { name: expected, launchState: 'starting', @@ -8527,6 +9039,20 @@ export class TeamProvisioningService { hardFailure: false, lastEvaluatedAt: now, }; + if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) { + current.agentToolAccepted = true; + current.firstSpawnAcceptedAt = + current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt; + } + if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) { + current.runtimeAlive = true; + current.lastRuntimeAliveAt = + current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt; + } + if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) { + current.bootstrapConfirmed = true; + current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; + } const matchedRuntimeNames = [...configMembers].filter((name) => { if (name === expected) return true; const parsed = parseNumericSuffixName(name); @@ -8573,6 +9099,21 @@ export class TeamProvisioningService { : current.sources?.configDrift, inboxHeartbeat: heartbeatMessage != null ? true : current.sources?.inboxHeartbeat, }; + const bootstrapProvesSpawnAcceptance = + bootstrapMember?.agentToolAccepted === true || + typeof bootstrapMember?.firstSpawnAcceptedAt === 'string'; + const currentProvesSpawnAcceptance = + current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; + if ( + isNeverSpawnedDuringLaunchReason(current.hardFailureReason) && + (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) + ) { + current.hardFailure = false; + current.hardFailureReason = undefined; + if (current.sources) { + current.sources.hardFailureSignal = undefined; + } + } if (heartbeatReason) { current.hardFailure = true; current.hardFailureReason = heartbeatReason; @@ -9405,6 +9946,16 @@ export class TeamProvisioningService { } if (outcome === 'already_running') { + if (run.pendingMemberRestarts.has(memberName)) { + run.pendingMemberRestarts.delete(memberName); + this.setMemberSpawnStatus( + run, + memberName, + 'error', + buildRestartStillRunningReason(memberName) + ); + return true; + } this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process'); return true; } @@ -13117,8 +13668,7 @@ export class TeamProvisioningService { const joinedAt = Date.now(); try { - await this.membersMetaStore.writeMembers( - teamName, + const membersToWrite = applyDistinctProvisioningMemberColors( teammateMembers.map((member) => ({ name: member.name.trim(), role: member.role?.trim() || undefined, @@ -13129,14 +13679,14 @@ export class TeamProvisioningService { member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' ? member.effort : undefined, - agentType: 'general-purpose', - color: getMemberColorByName(member.name.trim()), + agentType: 'general-purpose' as const, joinedAt, })), { providerBackendId: request.providerBackendId, } ); + await this.membersMetaStore.writeMembers(teamName, membersToWrite); } catch (error) { logger.warn( `[${teamName}] Failed to persist members.meta.json: ${ diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index f6290a2f..fda988d3 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -39,3 +39,12 @@ export { TeamSentMessagesStore } from './TeamSentMessagesStore'; export { TeamTaskReader } from './TeamTaskReader'; export { TeamTaskWriter } from './TeamTaskWriter'; export { countLineChanges } from './UnifiedLineCounter'; +export { ActiveTeamRegistry } from './stallMonitor/ActiveTeamRegistry'; +export { BoardTaskActivityBatchIndexer } from './stallMonitor/BoardTaskActivityBatchIndexer'; +export { TeamTaskLogFreshnessReader } from './stallMonitor/TeamTaskLogFreshnessReader'; +export { TeamTaskStallExactRowReader } from './stallMonitor/TeamTaskStallExactRowReader'; +export { TeamTaskStallJournal } from './stallMonitor/TeamTaskStallJournal'; +export { TeamTaskStallMonitor } from './stallMonitor/TeamTaskStallMonitor'; +export { TeamTaskStallNotifier } from './stallMonitor/TeamTaskStallNotifier'; +export { TeamTaskStallPolicy } from './stallMonitor/TeamTaskStallPolicy'; +export { TeamTaskStallSnapshotSource } from './stallMonitor/TeamTaskStallSnapshotSource'; diff --git a/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts new file mode 100644 index 00000000..8e838772 --- /dev/null +++ b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts @@ -0,0 +1,101 @@ +import type { TeamLogSourceTracker } from '../TeamLogSourceTracker'; +import type { TeamChangeEvent } from '@shared/types'; + +interface TeamAliveProcessesReader { + listAliveProcessTeams(): Promise; +} + +interface TeamLogSourceTrackingHandle { + enableTracking( + teamName: string, + consumer: 'stall_monitor' + ): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>; + disableTracking( + teamName: string, + consumer: 'stall_monitor' + ): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>; +} + +export class ActiveTeamRegistry { + private readonly activeTeams = new Set(); + private reconcileTimer: ReturnType | null = null; + + constructor( + private readonly teamDataService: TeamAliveProcessesReader, + private readonly teamLogSourceTracker: Pick< + TeamLogSourceTracker, + 'enableTracking' | 'disableTracking' + > & + TeamLogSourceTrackingHandle, + private readonly reconcileIntervalMs: number = 5 * 60_000 + ) {} + + noteTeamChange(event: TeamChangeEvent): void { + if ( + event.type === 'member-spawn' || + (event.type === 'lead-activity' && event.detail !== 'offline') + ) { + if (!this.activeTeams.has(event.teamName)) { + this.activeTeams.add(event.teamName); + void this.teamLogSourceTracker.enableTracking(event.teamName, 'stall_monitor'); + } + return; + } + + if (event.type === 'task-log-change' || event.type === 'log-source-change') { + if (!this.activeTeams.has(event.teamName)) { + return; + } + } + } + + async listActiveTeams(): Promise { + return [...this.activeTeams].sort((left, right) => left.localeCompare(right)); + } + + start(): void { + if (this.reconcileTimer) { + return; + } + void this.reconcile(); + this.reconcileTimer = setInterval(() => { + void this.reconcile(); + }, this.reconcileIntervalMs); + } + + async stop(): Promise { + if (this.reconcileTimer) { + clearInterval(this.reconcileTimer); + this.reconcileTimer = null; + } + + const teamNames = [...this.activeTeams]; + this.activeTeams.clear(); + await Promise.all( + teamNames.map((teamName) => + this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor') + ) + ); + } + + async reconcile(): Promise { + const aliveTeams = await this.teamDataService.listAliveProcessTeams(); + const aliveSet = new Set(aliveTeams); + + for (const teamName of aliveTeams) { + if (this.activeTeams.has(teamName)) { + continue; + } + this.activeTeams.add(teamName); + await this.teamLogSourceTracker.enableTracking(teamName, 'stall_monitor'); + } + + for (const teamName of [...this.activeTeams]) { + if (aliveSet.has(teamName)) { + continue; + } + this.activeTeams.delete(teamName); + await this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor'); + } + } +} diff --git a/src/main/services/team/stallMonitor/BoardTaskActivityBatchIndexer.ts b/src/main/services/team/stallMonitor/BoardTaskActivityBatchIndexer.ts new file mode 100644 index 00000000..548effb5 --- /dev/null +++ b/src/main/services/team/stallMonitor/BoardTaskActivityBatchIndexer.ts @@ -0,0 +1,30 @@ +import { BoardTaskActivityRecordBuilder } from '../taskLogs/activity/BoardTaskActivityRecordBuilder'; + +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { RawTaskActivityMessage } from '../taskLogs/activity/BoardTaskActivityTranscriptReader'; +import type { TeamTask } from '@shared/types'; + +export class BoardTaskActivityBatchIndexer { + constructor( + private readonly recordBuilder: Pick< + BoardTaskActivityRecordBuilder, + 'buildForTasks' + > = new BoardTaskActivityRecordBuilder() + ) {} + + buildIndex(args: { + teamName: string; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): Map { + if (args.tasks.length === 0 || args.messages.length === 0) { + return new Map(); + } + + return this.recordBuilder.buildForTasks({ + teamName: args.teamName, + tasks: args.tasks, + messages: args.messages, + }); + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts new file mode 100644 index 00000000..326af24e --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache'; + +import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes'; + +const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; +const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; + +interface ParsedFreshnessSignal { + taskId: string; + updatedAt: string; + transcriptFileBasename?: string; +} + +function encodeTaskId(taskId: string): string { + return encodeURIComponent(taskId); +} + +function isValidTimestamp(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +export class TeamTaskLogFreshnessReader { + private readonly cache = new BoardTaskActivityParseCache(); + + async readSignals( + projectDir: string, + taskIds: string[] + ): Promise> { + const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort(); + const signalFilePaths = uniqueTaskIds.map((taskId) => + path.join( + projectDir, + BOARD_TASK_LOG_FRESHNESS_DIRNAME, + `${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}` + ) + ); + this.cache.retainOnly(new Set(signalFilePaths)); + + const rows = await Promise.all( + uniqueTaskIds.map(async (taskId, index) => { + const filePath = signalFilePaths[index]; + const parsed = await this.readSignal(filePath); + if (!parsed || parsed.taskId !== taskId) { + return null; + } + return [ + taskId, + { + taskId, + updatedAt: parsed.updatedAt, + filePath, + ...(parsed.transcriptFileBasename + ? { transcriptFileBasename: parsed.transcriptFileBasename } + : {}), + } satisfies TaskLogFreshnessSignal, + ] as const; + }) + ); + + return new Map(rows.filter((row): row is NonNullable => row !== null)); + } + + private async readSignal(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + this.cache.clearForPath(filePath); + return false; + } + + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached !== null) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.parseSignal(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch { + this.cache.clearForPath(filePath); + return false; + } + } + + private async parseSignal(filePath: string): Promise { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') { + return false; + } + + const record = parsed as Record; + const taskId = + typeof record.taskId === 'string' && record.taskId.trim().length > 0 + ? record.taskId.trim() + : null; + const updatedAt = isValidTimestamp(record.updatedAt) ? record.updatedAt : null; + if (!taskId || !updatedAt) { + return false; + } + + return { + taskId, + updatedAt, + ...(typeof record.transcriptFile === 'string' && record.transcriptFile.trim().length > 0 + ? { transcriptFileBasename: path.basename(record.transcriptFile.trim()) } + : {}), + }; + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallExactRowReader.ts b/src/main/services/team/stallMonitor/TeamTaskStallExactRowReader.ts new file mode 100644 index 00000000..b515eb3a --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallExactRowReader.ts @@ -0,0 +1,127 @@ +import { yieldToEventLoop } from '@main/utils/asyncYield'; +import { parseJsonlLine } from '@main/utils/jsonl'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache'; + +import type { TeamTaskStallExactRow } from './TeamTaskStallTypes'; + +const logger = createLogger('Service:TeamTaskStallExactRowReader'); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function hasStrictTimestamp(record: Record): boolean { + return typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp)); +} + +function parseSystemSubtype(record: Record): 'turn_duration' | 'init' | undefined { + return record.subtype === 'turn_duration' || record.subtype === 'init' + ? record.subtype + : undefined; +} + +export class TeamTaskStallExactRowReader { + private readonly cache = new BoardTaskActivityParseCache(); + + async parseFiles(filePaths: string[]): Promise> { + const uniquePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniquePaths)); + + const rows = await Promise.all( + uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const) + ); + return new Map(rows); + } + + private async parseFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached !== null) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.readFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable stall exact-log transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async readFile(filePath: string): Promise { + const rows: TeamTaskStallExactRow[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let lineCount = 0; + let sourceOrder = 0; + + for await (const line of rl) { + if (!line.trim()) { + continue; + } + lineCount += 1; + + try { + const raw = JSON.parse(line) as unknown; + const record = asRecord(raw); + if (!record || !hasStrictTimestamp(record)) { + continue; + } + + const parsed = parseJsonlLine(line); + if (!parsed) { + continue; + } + + sourceOrder += 1; + const systemSubtype = parseSystemSubtype(record); + rows.push({ + filePath, + sourceOrder, + messageUuid: parsed.uuid, + timestamp: record.timestamp as string, + parsedMessage: parsed, + ...(parsed.requestId ? { requestId: parsed.requestId } : {}), + ...(parsed.sourceToolUseID ? { sourceToolUseId: parsed.sourceToolUseID } : {}), + ...(parsed.sourceToolAssistantUUID + ? { sourceToolAssistantUuid: parsed.sourceToolAssistantUUID } + : {}), + ...(systemSubtype ? { systemSubtype } : {}), + toolUseIds: parsed.toolCalls.map((toolCall) => toolCall.id), + toolResultIds: parsed.toolResults.map((toolResult) => toolResult.toolUseId), + }); + } catch (error) { + logger.debug(`Skipping malformed stall exact-log line in ${filePath}: ${String(error)}`); + } + + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } + } + + return rows; + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts new file mode 100644 index 00000000..316796e6 --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts @@ -0,0 +1,145 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { atomicWriteAsync } from '../atomicWrite'; +import { withFileLock } from '../fileLock'; + +import type { + TaskStallEvaluation, + TaskStallJournalEntry, + TaskStallJournalState, +} from './TeamTaskStallTypes'; + +function isValidState(value: unknown): value is TaskStallJournalState { + return value === 'suspected' || value === 'alert_ready' || value === 'alerted'; +} + +export class TeamTaskStallJournal { + private getFilePath(teamName: string): string { + return path.join(getTeamsBasePath(), teamName, 'stall-monitor-journal.json'); + } + + async reconcileScan(args: { + teamName: string; + evaluations: TaskStallEvaluation[]; + activeTaskIds: string[]; + now: string; + }): Promise { + const filePath = this.getFilePath(args.teamName); + let readyEvaluations: TaskStallEvaluation[] = []; + + await withFileLock(filePath, async () => { + const entries = await this.readUnlocked(filePath); + const candidateByEpoch = new Map( + args.evaluations + .filter( + ( + evaluation + ): evaluation is TaskStallEvaluation & + Required> => + evaluation.status === 'alert' && + typeof evaluation.taskId === 'string' && + typeof evaluation.branch === 'string' && + typeof evaluation.signal === 'string' && + typeof evaluation.epochKey === 'string' + ) + .map((evaluation) => [evaluation.epochKey, evaluation] as const) + ); + + const activeTaskIdSet = new Set(args.activeTaskIds); + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) { + entries.splice(i, 1); + } + } + + for (const [epochKey, evaluation] of candidateByEpoch) { + const existing = entries.find((entry) => entry.epochKey === epochKey); + if (!existing) { + entries.push({ + epochKey, + teamName: args.teamName, + taskId: evaluation.taskId, + branch: evaluation.branch, + signal: evaluation.signal, + state: 'suspected', + consecutiveScans: 1, + createdAt: args.now, + updatedAt: args.now, + }); + continue; + } + + existing.updatedAt = args.now; + if (existing.state === 'alerted') { + continue; + } + + existing.consecutiveScans += 1; + if (existing.consecutiveScans >= 2) { + existing.state = 'alert_ready'; + readyEvaluations.push(evaluation); + } + } + + await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2)); + }); + + return readyEvaluations; + } + + async markAlerted(teamName: string, epochKey: string, now: string): Promise { + const filePath = this.getFilePath(teamName); + await withFileLock(filePath, async () => { + const entries = await this.readUnlocked(filePath); + const target = entries.find((entry) => entry.epochKey === epochKey); + if (!target) { + return; + } + target.state = 'alerted'; + target.updatedAt = now; + target.alertedAt = now; + await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2)); + }); + } + + private async readUnlocked(filePath: string): Promise { + try { + const raw = await fs.promises.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter( + (item): item is TaskStallJournalEntry => + item != null && + typeof item === 'object' && + typeof (item as TaskStallJournalEntry).epochKey === 'string' && + typeof (item as TaskStallJournalEntry).teamName === 'string' && + typeof (item as TaskStallJournalEntry).taskId === 'string' && + ((item as TaskStallJournalEntry).branch === 'work' || + (item as TaskStallJournalEntry).branch === 'review') && + ((item as TaskStallJournalEntry).signal === 'turn_ended_after_touch' || + (item as TaskStallJournalEntry).signal === 'mid_turn_after_touch' || + (item as TaskStallJournalEntry).signal === 'touch_then_other_turns') && + isValidState((item as TaskStallJournalEntry).state) && + typeof (item as TaskStallJournalEntry).consecutiveScans === 'number' && + typeof (item as TaskStallJournalEntry).createdAt === 'string' && + typeof (item as TaskStallJournalEntry).updatedAt === 'string' + ) + .map((entry) => ({ + ...entry, + ...(entry.alertedAt ? { alertedAt: entry.alertedAt } : {}), + })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts new file mode 100644 index 00000000..c5cfbe66 --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts @@ -0,0 +1,246 @@ +import { createLogger } from '@shared/utils/logger'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import { ActiveTeamRegistry } from './ActiveTeamRegistry'; +import { + getTeamTaskStallActivationGraceMs, + getTeamTaskStallScanIntervalMs, + getTeamTaskStallStartupGraceMs, + isTeamTaskStallAlertsEnabled, + isTeamTaskStallMonitorEnabled, +} from './featureGates'; + +import type { TeamTaskStallSnapshotSource } from './TeamTaskStallSnapshotSource'; +import type { TeamTaskStallPolicy } from './TeamTaskStallPolicy'; +import type { TeamTaskStallJournal } from './TeamTaskStallJournal'; +import type { TeamTaskStallNotifier } from './TeamTaskStallNotifier'; +import type { TaskStallAlert, TaskStallEvaluation } from './TeamTaskStallTypes'; +import type { TeamChangeEvent } from '@shared/types'; + +const logger = createLogger('Service:TeamTaskStallMonitor'); + +interface TeamObservationState { + firstSeenAtMs: number; + lastActivationAtMs: number; +} + +export class TeamTaskStallMonitor { + private scanTimer: ReturnType | null = null; + private nudgeTimer: ReturnType | null = null; + private scanInFlight = false; + private started = false; + private readonly observationByTeam = new Map(); + + constructor( + private readonly registry: ActiveTeamRegistry, + private readonly snapshotSource: TeamTaskStallSnapshotSource, + private readonly policy: TeamTaskStallPolicy, + private readonly journal: TeamTaskStallJournal, + private readonly notifier: TeamTaskStallNotifier + ) {} + + start(): void { + if (!isTeamTaskStallMonitorEnabled()) { + logger.debug('Task stall monitor disabled by feature gate'); + return; + } + if (this.started) { + return; + } + this.started = true; + this.registry.start(); + this.scheduleNextScan(2_000); + } + + async stop(): Promise { + this.started = false; + if (this.scanTimer) { + clearTimeout(this.scanTimer); + this.scanTimer = null; + } + if (this.nudgeTimer) { + clearTimeout(this.nudgeTimer); + this.nudgeTimer = null; + } + await this.registry.stop(); + } + + noteTeamChange(event: TeamChangeEvent): void { + this.registry.noteTeamChange(event); + if (!isTeamTaskStallMonitorEnabled()) { + return; + } + + if ( + event.type === 'member-spawn' || + (event.type === 'lead-activity' && event.detail !== 'offline') + ) { + const now = Date.now(); + const existing = this.observationByTeam.get(event.teamName); + this.observationByTeam.set(event.teamName, { + firstSeenAtMs: existing?.firstSeenAtMs ?? now, + lastActivationAtMs: now, + }); + this.scheduleNudgedScan(); + return; + } + + if (event.type === 'task-log-change' || event.type === 'log-source-change') { + this.scheduleNudgedScan(); + } + } + + private scheduleNextScan(delayMs: number): void { + if (!this.started) { + return; + } + if (this.scanTimer) { + clearTimeout(this.scanTimer); + } + this.scanTimer = setTimeout(() => { + this.scanTimer = null; + void this.runScan(); + }, delayMs); + } + + private scheduleNudgedScan(): void { + if (!this.started || this.nudgeTimer) { + return; + } + this.nudgeTimer = setTimeout(() => { + this.nudgeTimer = null; + void this.runScan(); + }, 5_000); + } + + private async runScan(): Promise { + if (!this.started || this.scanInFlight) { + return; + } + this.scanInFlight = true; + try { + const activeTeams = await this.registry.listActiveTeams(); + const activeSet = new Set(activeTeams); + for (const teamName of [...this.observationByTeam.keys()]) { + if (!activeSet.has(teamName)) { + this.observationByTeam.delete(teamName); + } + } + + const now = new Date(); + for (const teamName of activeTeams) { + const observation = this.getOrCreateObservation(teamName, now.getTime()); + const startupAgeMs = now.getTime() - observation.firstSeenAtMs; + if (startupAgeMs < getTeamTaskStallStartupGraceMs()) { + continue; + } + + const activationAgeMs = now.getTime() - observation.lastActivationAtMs; + if (activationAgeMs < getTeamTaskStallActivationGraceMs()) { + continue; + } + + await this.scanTeam(teamName, now); + } + } catch (error) { + logger.warn(`Task stall monitor scan failed: ${String(error)}`); + } finally { + this.scanInFlight = false; + this.scheduleNextScan(getTeamTaskStallScanIntervalMs()); + } + } + + private getOrCreateObservation(teamName: string, nowMs: number): TeamObservationState { + const existing = this.observationByTeam.get(teamName); + if (existing) { + return existing; + } + const created = { + firstSeenAtMs: nowMs, + lastActivationAtMs: nowMs, + }; + this.observationByTeam.set(teamName, created); + return created; + } + + private async scanTeam(teamName: string, now: Date): Promise { + const snapshot = await this.snapshotSource.getSnapshot(teamName); + if (!snapshot) { + return; + } + + const evaluations: TaskStallEvaluation[] = []; + for (const task of snapshot.inProgressTasks) { + evaluations.push(this.policy.evaluateWork({ now, task, snapshot })); + } + for (const task of snapshot.reviewOpenTasks) { + evaluations.push(this.policy.evaluateReview({ now, task, snapshot })); + } + + const activeTaskIds = [ + ...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)), + ]; + const readyEvaluations = await this.journal.reconcileScan({ + teamName, + evaluations, + activeTaskIds, + now: now.toISOString(), + }); + + const alerts = readyEvaluations + .map((evaluation) => this.buildAlert(snapshot, evaluation)) + .filter((alert): alert is TaskStallAlert => alert !== null); + + if (alerts.length === 0) { + return; + } + + if (!isTeamTaskStallAlertsEnabled()) { + logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`); + return; + } + + await this.notifier.notifyLead(teamName, alerts); + await Promise.all( + alerts.map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString())) + ); + } + + private buildAlert( + snapshot: Awaited>, + evaluation: TaskStallEvaluation + ): TaskStallAlert | null { + if ( + !snapshot || + evaluation.status !== 'alert' || + !evaluation.taskId || + !evaluation.branch || + !evaluation.signal || + !evaluation.epochKey + ) { + return null; + } + + const task = snapshot.allTasksById.get(evaluation.taskId); + if (!task) { + return null; + } + + const displayId = getTaskDisplayId(task); + return { + teamName: snapshot.teamName, + taskId: task.id, + displayId, + subject: task.subject, + branch: evaluation.branch, + signal: evaluation.signal, + reason: evaluation.reason, + epochKey: evaluation.epochKey, + taskRef: { + taskId: task.id, + displayId, + teamName: snapshot.teamName, + }, + }; + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts new file mode 100644 index 00000000..0f00b766 --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -0,0 +1,32 @@ +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; + +import type { TaskStallAlert } from './TeamTaskStallTypes'; +import type { TeamDataService } from '../TeamDataService'; + +function buildLeadAlertText(alerts: TaskStallAlert[]): string { + return alerts + .map( + (alert) => + `- ${formatTaskDisplayLabel({ id: alert.taskId, displayId: alert.displayId })} [${alert.branch}] ${alert.subject} - ${alert.reason}` + ) + .join('\n'); +} + +export class TeamTaskStallNotifier { + constructor( + private readonly teamDataService: Pick + ) {} + + async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise { + if (alerts.length === 0) { + return; + } + + await this.teamDataService.sendSystemNotificationToLead({ + teamName, + summary: 'Potential stalled tasks detected', + text: buildLeadAlertText(alerts), + taskRefs: alerts.map((alert) => alert.taskRef), + }); + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts new file mode 100644 index 00000000..1d339dec --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -0,0 +1,508 @@ +import type { + ReviewTaskContext, + TaskStallBranch, + TaskStallEvaluation, + TaskStallSignal, + TeamTaskStallExactRow, + TeamTaskStallSnapshot, + WorkTaskContext, +} from './TeamTaskStallTypes'; +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TeamTask, TaskWorkInterval, TaskHistoryEvent } from '@shared/types'; + +const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']); +const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']); + +const ONE_MINUTE_MS = 60_000; +const WORK_THRESHOLDS_MS: Record = { + turn_ended_after_touch: 8 * ONE_MINUTE_MS, + touch_then_other_turns: 10 * ONE_MINUTE_MS, + mid_turn_after_touch: 20 * ONE_MINUTE_MS, +}; +const REVIEW_THRESHOLDS_MS: Record = { + turn_ended_after_touch: 10 * ONE_MINUTE_MS, + touch_then_other_turns: 10 * ONE_MINUTE_MS, + mid_turn_after_touch: 25 * ONE_MINUTE_MS, +}; + +function skip( + taskId: string, + reason: string, + skipReason: TaskStallEvaluation['skipReason'] +): TaskStallEvaluation { + return { + status: 'skip', + taskId, + reason, + skipReason, + }; +} + +function isAfterOrEqual(timestamp: string, lowerBound: string): boolean { + return Date.parse(timestamp) >= Date.parse(lowerBound); +} + +function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null { + const intervals = task.workIntervals ?? []; + for (let i = intervals.length - 1; i >= 0; i -= 1) { + const interval = intervals[i]; + if (!interval.completedAt) { + return interval; + } + } + return null; +} + +function getOpenReviewWindowStart(task: TeamTask): string | null { + if (task.reviewState !== 'review' || !task.historyEvents?.length) { + return null; + } + + for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) { + const event = task.historyEvents[i]; + if (event.type === 'review_started') { + return event.timestamp; + } + if ( + event.type === 'review_approved' || + event.type === 'review_changes_requested' || + (event.type === 'status_changed' && event.to === 'in_progress') + ) { + return null; + } + } + return null; +} + +function hasReviewStartedByReviewer( + historyEvents: TaskHistoryEvent[] | undefined, + reviewer: string, + windowStartedAt: string +): boolean { + if (!historyEvents?.length) { + return false; + } + + return historyEvents.some( + (event) => + event.type === 'review_started' && + event.actor === reviewer && + isAfterOrEqual(event.timestamp, windowStartedAt) + ); +} + +function isStrongReviewTouch( + record: BoardTaskActivityRecord, + reviewer: string, + hasExplicitStartedReview: boolean, + windowStartedAt: string +): boolean { + if ( + record.actor.memberName !== reviewer || + !record.action?.canonicalToolName || + !REVIEW_TOUCH_TOOLS.has(record.action.canonicalToolName) || + !isAfterOrEqual(record.timestamp, windowStartedAt) + ) { + return false; + } + + if (record.action.canonicalToolName === 'review_start') { + return true; + } + + if ( + record.actorContext.relation === 'same_task' && + record.actorContext.activePhase === 'review' + ) { + return true; + } + + return hasExplicitStartedReview; +} + +function findLastMeaningfulWorkTouch( + records: BoardTaskActivityRecord[], + owner: string, + intervalStartedAt: string +): BoardTaskActivityRecord | null { + return ( + [...records] + .filter((record) => record.actor.memberName === owner) + .filter((record) => isAfterOrEqual(record.timestamp, intervalStartedAt)) + .filter((record) => WORK_TOUCH_TOOLS.has(record.action?.canonicalToolName ?? '')) + .at(-1) ?? null + ); +} + +function findLastMeaningfulReviewTouch( + records: BoardTaskActivityRecord[], + reviewer: string, + windowStartedAt: string, + hasExplicitStartedReview: boolean +): BoardTaskActivityRecord | null { + return ( + [...records] + .filter((record) => + isStrongReviewTouch(record, reviewer, hasExplicitStartedReview, windowStartedAt) + ) + .at(-1) ?? null + ); +} + +function anchorEvidenceRank(row: TeamTaskStallExactRow, toolUseId: string | undefined): number { + if (!toolUseId || row.parsedMessage.type !== 'assistant') { + return 0; + } + if (row.toolUseIds.includes(toolUseId)) { + return 2; + } + if (row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)) { + return 1; + } + return 0; +} + +function deduplicateAssistantRowsByRequestId( + rows: TeamTaskStallExactRow[], + toolUseId: string | undefined +): TeamTaskStallExactRow[] { + const preferredIndexByRequestId = new Map(); + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (row.parsedMessage.type !== 'assistant' || !row.requestId) { + continue; + } + const existingIndex = preferredIndexByRequestId.get(row.requestId); + if (existingIndex === undefined) { + preferredIndexByRequestId.set(row.requestId, i); + continue; + } + const existingRank = anchorEvidenceRank(rows[existingIndex], toolUseId); + const nextRank = anchorEvidenceRank(row, toolUseId); + if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) { + preferredIndexByRequestId.set(row.requestId, i); + } + } + + if (preferredIndexByRequestId.size === 0) { + return rows; + } + + return rows.filter((row, index) => { + if (row.parsedMessage.type !== 'assistant' || !row.requestId) { + return true; + } + return preferredIndexByRequestId.get(row.requestId) === index; + }); +} + +function findAnchorRowIndex( + rows: TeamTaskStallExactRow[], + messageUuid: string, + toolUseId?: string +): number { + const candidates = rows + .map((row, index) => ({ row, index })) + .filter(({ row }) => row.messageUuid === messageUuid); + if (candidates.length === 0) { + return -1; + } + + if (toolUseId) { + const explicitToolUse = candidates.filter(({ row }) => row.toolUseIds.includes(toolUseId)); + if (explicitToolUse.length > 0) { + return explicitToolUse.at(-1)!.index; + } + + const linkedRows = candidates.filter( + ({ row }) => row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId) + ); + if (linkedRows.length > 0) { + return linkedRows.at(-1)!.index; + } + } + + return candidates.at(-1)!.index; +} + +function classifyPostTouchState(args: { + rows: TeamTaskStallExactRow[]; + anchorMessageUuid: string; + anchorToolUseId?: string; +}): TaskStallSignal | 'ambiguous' { + const normalizedRows = deduplicateAssistantRowsByRequestId(args.rows, args.anchorToolUseId); + const anchorIndex = findAnchorRowIndex( + normalizedRows, + args.anchorMessageUuid, + args.anchorToolUseId + ); + if (anchorIndex < 0) { + return 'ambiguous'; + } + + let sawTurnEnd = false; + let sawLaterRows = false; + + for (let i = anchorIndex + 1; i < normalizedRows.length; i += 1) { + const row = normalizedRows[i]; + if (row.systemSubtype === 'turn_duration') { + sawTurnEnd = true; + continue; + } + + sawLaterRows = true; + if (sawTurnEnd) { + return 'touch_then_other_turns'; + } + } + + if (sawTurnEnd) { + return 'turn_ended_after_touch'; + } + if (sawLaterRows) { + return 'mid_turn_after_touch'; + } + return 'mid_turn_after_touch'; +} + +function buildEpochKey( + task: TeamTask, + branch: TaskStallBranch, + signal: TaskStallSignal, + touch: BoardTaskActivityRecord +): string { + return [ + task.id, + branch, + signal, + touch.timestamp, + touch.source.filePath, + touch.source.messageUuid, + touch.source.toolUseId ?? 'ambient', + ].join(':'); +} + +function buildAlertEvaluation(args: { + task: TeamTask; + branch: TaskStallBranch; + signal: TaskStallSignal; + touch: BoardTaskActivityRecord; + reason: string; +}): TaskStallEvaluation { + return { + status: 'alert', + taskId: args.task.id, + branch: args.branch, + signal: args.signal, + epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch), + reason: args.reason, + }; +} + +export class TeamTaskStallPolicy { + evaluateWork(args: { + now: Date; + task: TeamTask; + snapshot: TeamTaskStallSnapshot; + }): TaskStallEvaluation { + const { task, snapshot } = args; + + if (!snapshot.activityReadsEnabled) { + return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled'); + } + if (!snapshot.exactReadsEnabled) { + return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled'); + } + if (task.status !== 'in_progress') { + return skip(task.id, 'Task is not in progress', 'task_not_in_progress'); + } + if (!task.owner) { + return skip(task.id, 'Task has no owner', 'owner_missing'); + } + if (task.owner === snapshot.leadName) { + return skip(task.id, 'Task owner is the lead', 'owner_is_lead'); + } + if (task.reviewState === 'review') { + return skip(task.id, 'Task is currently under review', 'review_active'); + } + if (task.blockedBy?.length) { + return skip(task.id, 'Task is blocked', 'task_blocked'); + } + if (task.needsClarification) { + return skip(task.id, 'Task is waiting for clarification', 'needs_clarification'); + } + + const openWorkInterval = getOpenWorkInterval(task); + if (!openWorkInterval?.startedAt) { + return skip(task.id, 'Task has no open work interval', 'no_open_work_interval'); + } + + const records = snapshot.recordsByTaskId.get(task.id) ?? []; + if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) { + return skip( + task.id, + 'Task run is not instrumented enough for stall evaluation', + 'non_instrumented_run' + ); + } + + const workContext: WorkTaskContext | null = (() => { + const touch = findLastMeaningfulWorkTouch(records, task.owner!, openWorkInterval.startedAt); + if (!touch) { + return null; + } + return { + owner: task.owner!, + intervalStartedAt: openWorkInterval.startedAt, + lastMeaningfulTouch: touch, + lastMeaningfulTouchAt: touch.timestamp, + }; + })(); + + if (!workContext) { + return skip( + task.id, + 'No positive work touch found in current work interval', + 'no_positive_touch' + ); + } + + const exactRows = snapshot.exactRowsByFilePath.get( + workContext.lastMeaningfulTouch.source.filePath + ); + if (!exactRows?.length) { + return skip(task.id, 'Post-touch exact rows are unavailable', 'ambiguous_state'); + } + + const signal = classifyPostTouchState({ + rows: exactRows, + anchorMessageUuid: workContext.lastMeaningfulTouch.source.messageUuid, + anchorToolUseId: workContext.lastMeaningfulTouch.source.toolUseId, + }); + if (signal === 'ambiguous') { + return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state'); + } + + const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt); + const thresholdMs = WORK_THRESHOLDS_MS[signal]; + if (elapsedMs < thresholdMs) { + return skip( + task.id, + 'Work touch is still below the configured stall threshold', + 'below_threshold' + ); + } + + return buildAlertEvaluation({ + task, + branch: 'work', + signal, + touch: workContext.lastMeaningfulTouch, + reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`, + }); + } + + evaluateReview(args: { + now: Date; + task: TeamTask; + snapshot: TeamTaskStallSnapshot; + }): TaskStallEvaluation { + const { task, snapshot } = args; + + if (!snapshot.activityReadsEnabled) { + return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled'); + } + if (!snapshot.exactReadsEnabled) { + return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled'); + } + if (task.reviewState !== 'review') { + return skip(task.id, 'Task is not in an open review window', 'review_terminal'); + } + if (task.needsClarification) { + return skip(task.id, 'Task is waiting for clarification', 'needs_clarification'); + } + + const reviewWindowStartedAt = getOpenReviewWindowStart(task); + if (!reviewWindowStartedAt) { + return skip(task.id, 'Task has no open review window', 'no_open_review_window'); + } + + const resolvedReviewer = snapshot.resolvedReviewersByTaskId.get(task.id) ?? { + reviewer: null, + source: 'none', + }; + if (!resolvedReviewer.reviewer) { + return skip(task.id, 'Reviewer could not be resolved safely', 'reviewer_unresolved'); + } + + const records = snapshot.recordsByTaskId.get(task.id) ?? []; + if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) { + return skip( + task.id, + 'Review run is not instrumented enough for stall evaluation', + 'non_instrumented_run' + ); + } + + const explicitReviewStarted = hasReviewStartedByReviewer( + task.historyEvents, + resolvedReviewer.reviewer, + reviewWindowStartedAt + ); + const reviewContext: ReviewTaskContext | null = (() => { + const touch = findLastMeaningfulReviewTouch( + records, + resolvedReviewer.reviewer!, + reviewWindowStartedAt, + explicitReviewStarted + ); + if (!touch) { + return null; + } + return { + resolvedReviewer, + reviewWindowStartedAt, + lastMeaningfulTouch: touch, + lastMeaningfulTouchAt: touch.timestamp, + }; + })(); + + if (!reviewContext) { + return skip(task.id, 'No explicit started-review evidence was found', 'no_positive_touch'); + } + + const exactRows = snapshot.exactRowsByFilePath.get( + reviewContext.lastMeaningfulTouch.source.filePath + ); + if (!exactRows?.length) { + return skip(task.id, 'Post-review exact rows are unavailable', 'ambiguous_state'); + } + + const signal = classifyPostTouchState({ + rows: exactRows, + anchorMessageUuid: reviewContext.lastMeaningfulTouch.source.messageUuid, + anchorToolUseId: reviewContext.lastMeaningfulTouch.source.toolUseId, + }); + if (signal === 'ambiguous') { + return skip(task.id, 'Post-review state is ambiguous', 'ambiguous_state'); + } + + const elapsedMs = args.now.getTime() - Date.parse(reviewContext.lastMeaningfulTouchAt); + const thresholdMs = REVIEW_THRESHOLDS_MS[signal]; + if (elapsedMs < thresholdMs) { + return skip( + task.id, + 'Review touch is still below the configured stall threshold', + 'below_threshold' + ); + } + + return buildAlertEvaluation({ + task, + branch: 'review', + signal, + touch: reviewContext.lastMeaningfulTouch, + reason: `Potential started-review stall after ${signal.replaceAll('_', ' ')}.`, + }); + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts new file mode 100644 index 00000000..b6118f28 --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -0,0 +1,119 @@ +import { TeamTaskReader } from '../TeamTaskReader'; +import { TeamKanbanManager } from '../TeamKanbanManager'; +import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator'; +import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader'; +import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates'; +import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'; + +import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; +import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader'; +import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader'; +import { buildResolvedReviewerIndex } from './reviewerResolution'; + +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes'; +import type { TeamConfig, TeamTask } from '@shared/types'; + +function resolveLeadNameFromConfig(config: TeamConfig): string { + const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead')); + return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; +} + +export class TeamTaskStallSnapshotSource { + constructor( + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), + private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), + private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(), + private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(), + private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader() + ) {} + + async getSnapshot(teamName: string): Promise { + const transcriptContext = await this.transcriptSourceLocator.getContext(teamName); + if (!transcriptContext) { + return null; + } + + const [activeTasks, deletedTasks, kanbanState] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.kanbanManager.getState(teamName), + ]); + const allTasks = [...activeTasks, ...deletedTasks]; + const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); + const inProgressTasks = activeTasks.filter( + (task) => task.status === 'in_progress' && task.reviewState !== 'review' + ); + const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review'); + const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); + const activityReadsEnabled = isBoardTaskActivityReadEnabled(); + const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); + + let recordsByTaskId = new Map(); + if ( + activityReadsEnabled && + allTasks.length > 0 && + transcriptContext.transcriptFiles.length > 0 + ) { + const messages = await this.transcriptReader.readFiles(transcriptContext.transcriptFiles); + recordsByTaskId = this.activityBatchIndexer.buildIndex({ + teamName, + tasks: allTasks, + messages, + }); + } + + const relevantMonitorTasks = [...inProgressTasks, ...reviewOpenTasks]; + const relevantExactFiles = this.collectRelevantExactFiles( + relevantMonitorTasks, + recordsByTaskId + ); + const [freshnessByTaskId, exactRowsByFilePath] = await Promise.all([ + this.freshnessReader.readSignals( + transcriptContext.projectDir, + relevantMonitorTasks.map((task) => task.id) + ), + exactReadsEnabled + ? this.exactRowReader.parseFiles(relevantExactFiles) + : Promise.resolve(new Map()), + ]); + + return { + teamName, + scannedAt: new Date().toISOString(), + projectDir: transcriptContext.projectDir, + projectId: transcriptContext.projectId, + leadName: resolveLeadNameFromConfig(transcriptContext.config), + transcriptFiles: transcriptContext.transcriptFiles, + activityReadsEnabled, + exactReadsEnabled, + activeTasks, + deletedTasks, + allTasksById, + inProgressTasks, + reviewOpenTasks, + resolvedReviewersByTaskId, + recordsByTaskId, + freshnessByTaskId, + exactRowsByFilePath, + }; + } + + private collectRelevantExactFiles( + inProgressTasks: TeamTask[], + recordsByTaskId: Map + ): string[] { + const filePaths = new Set(); + + for (const task of inProgressTasks) { + const records = recordsByTaskId.get(task.id) ?? []; + for (const record of records) { + filePaths.add(record.source.filePath); + } + } + + return [...filePaths].sort((left, right) => left.localeCompare(right)); + } +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts new file mode 100644 index 00000000..46550e05 --- /dev/null +++ b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts @@ -0,0 +1,139 @@ +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { ParsedMessage } from '@main/types'; +import type { TeamTask } from '@shared/types'; + +export type TaskStallBranch = 'work' | 'review'; + +export type TaskStallSignal = + | 'turn_ended_after_touch' + | 'mid_turn_after_touch' + | 'touch_then_other_turns'; + +export type TaskStallEvaluationStatus = 'skip' | 'suspected' | 'alert'; + +export type TaskStallSkipReason = + | 'task_not_in_progress' + | 'owner_missing' + | 'owner_is_lead' + | 'task_blocked' + | 'needs_clarification' + | 'review_active' + | 'review_terminal' + | 'reviewer_unresolved' + | 'non_instrumented_run' + | 'activity_reads_disabled' + | 'exact_reads_disabled' + | 'no_positive_touch' + | 'no_open_work_interval' + | 'no_open_review_window' + | 'ambiguous_state' + | 'below_threshold' + | 'first_scan_only'; + +export type ResolvedReviewerSource = + | 'kanban_state' + | 'history_review_approved_actor' + | 'history_review_started_actor' + | 'history_review_requested_reviewer' + | 'none'; + +export interface ResolvedReviewer { + reviewer: string | null; + source: ResolvedReviewerSource; +} + +export interface TaskStallEvaluation { + status: TaskStallEvaluationStatus; + taskId?: string; + branch?: TaskStallBranch; + signal?: TaskStallSignal; + epochKey?: string; + reason: string; + skipReason?: TaskStallSkipReason; +} + +export interface TaskLogFreshnessSignal { + taskId: string; + updatedAt: string; + filePath: string; + transcriptFileBasename?: string; +} + +export interface TeamTaskStallExactRow { + filePath: string; + sourceOrder: number; + messageUuid: string; + timestamp: string; + parsedMessage: ParsedMessage; + requestId?: string; + sourceToolUseId?: string; + sourceToolAssistantUuid?: string; + systemSubtype?: 'turn_duration' | 'init'; + toolUseIds: string[]; + toolResultIds: string[]; +} + +export interface TeamTaskStallSnapshot { + teamName: string; + scannedAt: string; + projectDir: string; + projectId: string; + leadName: string; + transcriptFiles: string[]; + activityReadsEnabled: boolean; + exactReadsEnabled: boolean; + activeTasks: TeamTask[]; + deletedTasks: TeamTask[]; + allTasksById: Map; + inProgressTasks: TeamTask[]; + reviewOpenTasks: TeamTask[]; + resolvedReviewersByTaskId: Map; + recordsByTaskId: Map; + freshnessByTaskId: Map; + exactRowsByFilePath: Map; +} + +export interface WorkTaskContext { + owner: string; + intervalStartedAt: string; + lastMeaningfulTouch: BoardTaskActivityRecord; + lastMeaningfulTouchAt: string; +} + +export interface ReviewTaskContext { + resolvedReviewer: ResolvedReviewer; + reviewWindowStartedAt: string; + lastMeaningfulTouch: BoardTaskActivityRecord; + lastMeaningfulTouchAt: string; +} + +export interface TaskStallAlert { + teamName: string; + taskId: string; + displayId: string; + subject: string; + branch: TaskStallBranch; + signal: TaskStallSignal; + reason: string; + epochKey: string; + taskRef: { + taskId: string; + displayId: string; + teamName: string; + }; +} + +export type TaskStallJournalState = 'suspected' | 'alert_ready' | 'alerted'; + +export interface TaskStallJournalEntry { + epochKey: string; + teamName: string; + taskId: string; + branch: TaskStallBranch; + signal: TaskStallSignal; + state: TaskStallJournalState; + consecutiveScans: number; + createdAt: string; + updatedAt: string; + alertedAt?: string; +} diff --git a/src/main/services/team/stallMonitor/featureGates.ts b/src/main/services/team/stallMonitor/featureGates.ts new file mode 100644 index 00000000..f9c24682 --- /dev/null +++ b/src/main/services/team/stallMonitor/featureGates.ts @@ -0,0 +1,42 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +function readInt(value: string | undefined, defaultValue: number): number { + if (value == null) { + return defaultValue; + } + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue; +} + +export function isTeamTaskStallMonitorEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false); +} + +export function isTeamTaskStallAlertsEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false); +} + +export function getTeamTaskStallScanIntervalMs(): number { + return readInt(process.env.CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS, 60_000); +} + +export function getTeamTaskStallStartupGraceMs(): number { + return readInt(process.env.CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS, 180_000); +} + +export function getTeamTaskStallActivationGraceMs(): number { + return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000); +} diff --git a/src/main/services/team/stallMonitor/reviewerResolution.ts b/src/main/services/team/stallMonitor/reviewerResolution.ts new file mode 100644 index 00000000..962f4f84 --- /dev/null +++ b/src/main/services/team/stallMonitor/reviewerResolution.ts @@ -0,0 +1,47 @@ +import { TeamKanbanManager } from '../TeamKanbanManager'; + +import type { ResolvedReviewer } from './TeamTaskStallTypes'; +import type { TeamTask } from '@shared/types'; + +export function resolveReviewerFromHistory(task: TeamTask): ResolvedReviewer { + if (!task.historyEvents?.length) { + return { reviewer: null, source: 'none' }; + } + + for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) { + const event = task.historyEvents[i]; + if (event.type === 'review_approved' && event.actor) { + return { reviewer: event.actor, source: 'history_review_approved_actor' }; + } + if (event.type === 'review_started' && event.actor) { + return { reviewer: event.actor, source: 'history_review_started_actor' }; + } + if (event.type === 'review_requested' && event.reviewer) { + return { reviewer: event.reviewer, source: 'history_review_requested_reviewer' }; + } + } + + return { reviewer: null, source: 'none' }; +} + +export function buildResolvedReviewerIndex( + tasks: TeamTask[], + kanbanState: Awaited> +): Map { + const resolved = new Map(); + + for (const task of tasks) { + const kanbanReviewer = kanbanState.tasks[task.id]?.reviewer; + if (typeof kanbanReviewer === 'string' && kanbanReviewer.trim().length > 0) { + resolved.set(task.id, { + reviewer: kanbanReviewer.trim(), + source: 'kanban_state', + }); + continue; + } + + resolved.set(task.id, resolveReviewerFromHistory(task)); + } + + return resolved; +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts index fc58f657..01d780a3 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -312,6 +312,21 @@ function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityR return left.id.localeCompare(right.id); } +function resolveCandidateTaskIds(locator: BoardTaskLocator, lookup: TaskLookup): string[] { + const canonicalTask = + (locator.canonicalId && lookup.byId.get(locator.canonicalId)) || + (locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) || + (locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref) + ? lookup.byId.get(locator.ref) + : undefined); + if (canonicalTask) { + return [canonicalTask.id]; + } + + const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? []; + return [...new Set(displayCandidates.map((task) => task.id))]; +} + export class BoardTaskActivityRecordBuilder { buildForTask(args: { teamName: string; @@ -319,64 +334,98 @@ export class BoardTaskActivityRecordBuilder { tasks: TeamTask[]; messages: RawTaskActivityMessage[]; }): BoardTaskActivityRecord[] { + return ( + this.buildForTasks({ + teamName: args.teamName, + tasks: args.tasks, + messages: args.messages, + }).get(args.targetTask.id) ?? [] + ); + } + + buildForTasks(args: { + teamName: string; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): Map { const lookup = buildTaskLookup(args.tasks); - const records: BoardTaskActivityRecord[] = []; - const seenIds = new Set(); + const recordsByTaskId = new Map(); + const seenIdsByTaskId = new Map>(); for (const message of args.messages) { const actionMap = buildActionMap(message.boardTaskToolActions); for (const link of message.boardTaskLinks) { const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup); - if ( - resolvedTask.taskRef?.taskId !== args.targetTask.id && - !locatorCouldMatchTask(link.task, args.targetTask, lookup) - ) { + const candidateTaskIds = resolveCandidateTaskIds(link.task, lookup); + if (candidateTaskIds.length === 0) { continue; } - const action = link.linkKind === 'execution' || !link.toolUseId ? undefined : actionMap.get(link.toolUseId); - const peerTask = resolvePeerTask( - args.teamName, - link, - message.boardTaskLinks, - args.targetTask, - lookup - ); - const record: BoardTaskActivityRecord = { - id: [ - message.uuid, - link.toolUseId ?? 'ambient', - link.task.ref, - link.targetRole, - link.linkKind, - ].join(':'), - timestamp: message.timestamp, - task: resolvedTask, - linkKind: link.linkKind, - targetRole: link.targetRole, - actor: resolveActivityActor(message), - actorContext: buildActorContext(args.teamName, link.actorContext, lookup), - ...(action ? { action: buildAction({ action, link, peerTask }) } : {}), - source: { - messageUuid: message.uuid, - filePath: message.filePath, - ...(link.toolUseId ? { toolUseId: link.toolUseId } : {}), - sourceOrder: message.sourceOrder, - }, - }; - if (seenIds.has(record.id)) { - continue; + for (const taskId of candidateTaskIds) { + const targetTask = lookup.byId.get(taskId); + if (!targetTask) { + continue; + } + if ( + resolvedTask.taskRef?.taskId !== targetTask.id && + !locatorCouldMatchTask(link.task, targetTask, lookup) + ) { + continue; + } + + const peerTask = resolvePeerTask( + args.teamName, + link, + message.boardTaskLinks, + targetTask, + lookup + ); + const record: BoardTaskActivityRecord = { + id: [ + message.uuid, + link.toolUseId ?? 'ambient', + link.task.ref, + link.targetRole, + link.linkKind, + ].join(':'), + timestamp: message.timestamp, + task: resolvedTask, + linkKind: link.linkKind, + targetRole: link.targetRole, + actor: resolveActivityActor(message), + actorContext: buildActorContext(args.teamName, link.actorContext, lookup), + ...(action ? { action: buildAction({ action, link, peerTask }) } : {}), + source: { + messageUuid: message.uuid, + filePath: message.filePath, + ...(link.toolUseId ? { toolUseId: link.toolUseId } : {}), + sourceOrder: message.sourceOrder, + }, + }; + + const seenIds = seenIdsByTaskId.get(taskId) ?? new Set(); + if (seenIds.has(record.id)) { + continue; + } + seenIds.add(record.id); + seenIdsByTaskId.set(taskId, seenIds); + + const taskRecords = recordsByTaskId.get(taskId) ?? []; + taskRecords.push(record); + recordsByTaskId.set(taskId, taskRecords); } - seenIds.add(record.id); - records.push(record); } } - return records.sort(compareRecords); + for (const [taskId, records] of recordsByTaskId) { + recordsByTaskId.set(taskId, records.sort(compareRecords)); + } + + return recordsByTaskId; } } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 89c83b83..6ad828ef 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -4,6 +4,7 @@ import { parentPort } from 'node:worker_threads'; import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; interface ListTeamsPayload { teamsDir: string; @@ -593,6 +594,11 @@ async function listTeams( dropCliProvisionerMembers(memberMap); const members = Array.from(memberMap.values()); + const memberColors = buildTeamMemberColorMap(members, { preferProvidedColors: false }); + const coloredMembers = members.map((member) => ({ + ...member, + color: memberColors.get(member.name) ?? member.color, + })); const launchStateSummary = (await readLaunchState(payload.teamsDir, teamName)) ?? (() => { @@ -623,7 +629,7 @@ async function listTeams( memberCount: memberMap.size, taskCount: 0, lastActivity: null, - ...(members.length > 0 ? { members } : {}), + ...(coloredMembers.length > 0 ? { members: coloredMembers } : {}), ...(color ? { color } : {}), ...(projectPath ? { projectPath } : {}), ...(leadSessionId ? { leadSessionId } : {}), diff --git a/src/renderer/assets/participant-avatars/01.png b/src/renderer/assets/participant-avatars/01.png new file mode 100644 index 00000000..4128d3b0 Binary files /dev/null and b/src/renderer/assets/participant-avatars/01.png differ diff --git a/src/renderer/assets/participant-avatars/02.png b/src/renderer/assets/participant-avatars/02.png new file mode 100644 index 00000000..15575859 Binary files /dev/null and b/src/renderer/assets/participant-avatars/02.png differ diff --git a/src/renderer/assets/participant-avatars/03.png b/src/renderer/assets/participant-avatars/03.png new file mode 100644 index 00000000..a5e00bcd Binary files /dev/null and b/src/renderer/assets/participant-avatars/03.png differ diff --git a/src/renderer/assets/participant-avatars/04.png b/src/renderer/assets/participant-avatars/04.png new file mode 100644 index 00000000..f984db69 Binary files /dev/null and b/src/renderer/assets/participant-avatars/04.png differ diff --git a/src/renderer/assets/participant-avatars/05.png b/src/renderer/assets/participant-avatars/05.png new file mode 100644 index 00000000..a9795962 Binary files /dev/null and b/src/renderer/assets/participant-avatars/05.png differ diff --git a/src/renderer/assets/participant-avatars/06.png b/src/renderer/assets/participant-avatars/06.png new file mode 100644 index 00000000..71950d32 Binary files /dev/null and b/src/renderer/assets/participant-avatars/06.png differ diff --git a/src/renderer/assets/participant-avatars/07.png b/src/renderer/assets/participant-avatars/07.png new file mode 100644 index 00000000..8f23fb86 Binary files /dev/null and b/src/renderer/assets/participant-avatars/07.png differ diff --git a/src/renderer/assets/participant-avatars/08.png b/src/renderer/assets/participant-avatars/08.png new file mode 100644 index 00000000..c7ada81e Binary files /dev/null and b/src/renderer/assets/participant-avatars/08.png differ diff --git a/src/renderer/assets/participant-avatars/09.png b/src/renderer/assets/participant-avatars/09.png new file mode 100644 index 00000000..8f4abe98 Binary files /dev/null and b/src/renderer/assets/participant-avatars/09.png differ diff --git a/src/renderer/assets/participant-avatars/10.png b/src/renderer/assets/participant-avatars/10.png new file mode 100644 index 00000000..bee2490e Binary files /dev/null and b/src/renderer/assets/participant-avatars/10.png differ diff --git a/src/renderer/assets/participant-avatars/11.png b/src/renderer/assets/participant-avatars/11.png new file mode 100644 index 00000000..e77da7e4 Binary files /dev/null and b/src/renderer/assets/participant-avatars/11.png differ diff --git a/src/renderer/assets/participant-avatars/12.png b/src/renderer/assets/participant-avatars/12.png new file mode 100644 index 00000000..32ee4912 Binary files /dev/null and b/src/renderer/assets/participant-avatars/12.png differ diff --git a/src/renderer/assets/participant-avatars/13.png b/src/renderer/assets/participant-avatars/13.png new file mode 100644 index 00000000..9b774e24 Binary files /dev/null and b/src/renderer/assets/participant-avatars/13.png differ diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 6ac4efda..7f29c0ff 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { getTeamColorSet, getThemedBadge, @@ -5,7 +7,13 @@ import { getThemedText, } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; -import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers'; +import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; +import { + agentAvatarUrl, + buildMemberAvatarMap, + displayMemberName, +} from '@renderer/utils/memberHelpers'; import { MemberHoverCard } from './members/MemberHoverCard'; @@ -40,6 +48,12 @@ export const MemberBadge = ({ }: MemberBadgeProps): React.JSX.Element => { const colors = getTeamColorSet(color ?? ''); const { isLight } = useTheme(); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const effectiveTeamName = teamName ?? selectedTeamName; + const teamMembers = useStore((s) => + effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; @@ -53,7 +67,7 @@ export const MemberBadge = ({ const avatar = ( (null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => + getTeamPendingRepliesState(teamName) + ); const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', @@ -923,7 +933,13 @@ export const TeamDetailView = ({ const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogOpen, setLaunchDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); @@ -1155,6 +1171,7 @@ export const TeamDetailView = ({ const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< { teamName: string; displayName: string; projectPath: string }[] >([]); + const launchDialogOpen = launchDialogState.open; // Session loading and filtering state const [sessions, setSessions] = useState([]); @@ -1200,6 +1217,8 @@ export const TeamDetailView = ({ clearProvisioningError, isTeamProvisioning, refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, syncTeamPendingReplyRefresh, kanbanFilterQuery, clearKanbanFilter, @@ -1251,6 +1270,8 @@ export const TeamDetailView = ({ loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -1274,6 +1295,7 @@ export const TeamDetailView = ({ const tabId = useTabIdOptional(); const activeTabId = useStore((s) => s.activeTabId); const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); useEffect(() => { const now = Date.now(); @@ -1337,6 +1359,14 @@ export const TeamDetailView = ({ } }, [tabId, initTabUIState]); + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + useEffect(() => { const wasProvisioning = wasProvisioningRef.current; wasProvisioningRef.current = isTeamProvisioning; @@ -1375,6 +1405,32 @@ export const TeamDetailView = ({ } }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { if (!launchDialogOpen) return; @@ -1537,6 +1593,10 @@ export const TeamDetailView = ({ return nextMember; }); }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] + ); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessionIds = useMemo(() => { @@ -1661,10 +1721,49 @@ export const TeamDetailView = ({ setSendDialogOpen(true); }, []); - const handleRestartTeam = useCallback(() => { - setLaunchDialogOpen(true); + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); }, []); + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ + teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, + }); + }, + [data?.isAlive, launchTeam, teamName] + ); + + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { setSelectedMember(member); setSelectedMemberView(null); @@ -1912,6 +2011,7 @@ export const TeamDetailView = ({ onReplyToMessage: handleReplyToMessage, onRestartTeam: handleRestartTeam, onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, }), [ activeMembers, @@ -2010,7 +2110,7 @@ export const TeamDetailView = ({
@@ -2027,17 +2127,16 @@ export const TeamDetailView = ({
setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} + onClose={closeLaunchDialog} + onLaunch={handleLaunchDialogSubmit} + onRelaunch={handleRelaunchDialogSubmit} /> ); @@ -2168,12 +2267,17 @@ export const TeamDetailView = ({ variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]" + disabled={isTeamProvisioning} onClick={() => setEditDialogOpen(true)} > - Edit team + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + @@ -2294,7 +2398,7 @@ export const TeamDetailView = ({ {!data.isAlive && !isTeamProvisioning ? ( setLaunchDialogOpen(true)} + onLaunch={() => openLaunchDialog('launch')} /> ) : null} @@ -2708,9 +2812,13 @@ export const TeamDetailView = ({ currentDescription={data.config.description ?? ''} currentColor={data.config.color ?? ''} currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} onSaved={() => void selectTeam(teamName)} /> @@ -2801,7 +2909,7 @@ export const TeamDetailView = ({ setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} + onClose={closeLaunchDialog} + onLaunch={handleLaunchDialogSubmit} + onRelaunch={handleRelaunchDialogSubmit} /> [t.id, t])); const entries: ActivityEntry[] = []; @@ -115,7 +117,7 @@ export const ActiveTasksBlock = ({
; + /** + * Root element for IntersectionObserver-based visibility tracking. + * Typically the same node as `scrollElementRef`, but left separate so + * future code can observe a more specific inner container when needed. + */ + observerRoot?: RefObject; + /** + * Distance from the scroll container's scroll origin to the timeline root, + * measured from the DOM. Zero in this release; used by the virtualizer in a + * follow-up change. + */ + scrollMargin?: number; + /** Enable virtualization (wired in a follow-up; ignored for now). */ + virtualizationEnabled?: boolean; +} + interface ActivityTimelineProps { messages: InboxMessage[]; teamName: string; @@ -66,6 +126,14 @@ interface ActivityTimelineProps { onExpandItem?: (key: string) => void; /** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */ onExpandContent?: () => void; + /** + * Optional viewport contract. When provided, IntersectionObserver uses the + * passed `observerRoot` instead of the document viewport, which is required + * for correctness inside scrollable layouts (sidebar, bottom-sheet) where + * the row may be clipped by its scroll parent while still intersecting the + * page viewport. + */ + viewport?: TimelineViewport; } const VIEWPORT_THRESHOLD = 0.15; @@ -74,6 +142,59 @@ const COMPACT_MESSAGES_WIDTH_PX = 400; const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; +const VIRTUALIZER_OVERSCAN = 8; +const VIRTUALIZATION_ROW_GAP_PX = 4; + +/** + * Row count above which virtualization is worth its complexity cost. Below + * this, the direct render path is both simpler and faster (no wrapper div, + * no position: absolute, no measurement churn). Chosen so conversations under + * roughly one session of activity stay on the direct path and the virtualized + * path only activates when scrolling behavior actually starts to matter. + */ +const VIRTUALIZATION_ROW_THRESHOLD = 60; + +/** + * Per-kind height estimates for `estimateSize`. These are rough initial guesses + * only; the virtualizer re-measures rows as they mount via `measureElement` + * (wired in a follow-up PR), so small inaccuracies here are self-correcting. + * Sizes come from visually averaged steady-state heights in production layouts. + */ +const ROW_SIZE_ESTIMATES: Record = { + 'session-separator': 135, + 'compaction-divider': 50, + 'lead-thought-group': 220, + 'message-row': 140, +}; + +function collectScrollMarginObserverTargets( + rootElement: HTMLElement, + scrollElement: HTMLElement +): HTMLElement[] { + const targets = new Set([rootElement, scrollElement]); + + let current: HTMLElement | null = rootElement; + while (current && current !== scrollElement) { + const parentElement: HTMLElement | null = current.parentElement; + if (!parentElement) { + break; + } + + targets.add(parentElement); + + let previousSibling: Element | null = current.previousElementSibling; + while (previousSibling) { + if (previousSibling instanceof HTMLElement) { + targets.add(previousSibling); + } + previousSibling = previousSibling.previousElementSibling; + } + + current = parentElement; + } + + return [...targets]; +} function getItemSessionAnchorId(item: TimelineItem): string | undefined { if (item.type === 'lead-thoughts') { @@ -141,6 +262,7 @@ const MessageRowWithObserver = ({ onExpand, expandItemKey, onExpandContent, + observerRoot, }: { message: InboxMessage; teamName: string; @@ -170,6 +292,7 @@ const MessageRowWithObserver = ({ onExpand?: (key: string) => void; expandItemKey?: string; onExpandContent?: () => void; + observerRoot?: RefObject; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -185,6 +308,10 @@ const MessageRowWithObserver = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve the observer root at effect-time. Falls back to the document + // viewport (null) when no root is provided — preserves pre-contract + // behavior for layouts without a known scroll owner. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -195,11 +322,11 @@ const MessageRowWithObserver = ({ reportedRef.current = true; cb(msg); }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible]); + }, [onVisible, observerRoot]); return ( @@ -265,6 +392,7 @@ const MemoizedMessageRowWithObserver = React.memo( prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && prev.onExpandContent === next.onExpandContent && + prev.observerRoot === next.observerRoot && areInboxMessagesEquivalentForRender(prev.message, next.message) ); @@ -291,7 +419,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ onTeamClick, onExpandItem, onExpandContent, + viewport, }: ActivityTimelineProps): React.JSX.Element { + const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef; const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); const [compactHeader, setCompactHeader] = useState(false); @@ -444,6 +574,129 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; const startIndex = pinnedThoughtGroup ? 1 : 0; + // Flatten timelineItems into atomic render rows. Each row maps to exactly + // one visual element — no Fragment bundles session separators with their + // owning item, because a windowing layer (landing in a follow-up PR) needs + // each row to be measurable and addressable independently. + const renderRows = useMemo(() => { + const rows: TimelineRow[] = []; + if (pinnedThoughtGroup) { + rows.push({ + kind: 'lead-thought-group', + key: getThoughtGroupKey(pinnedThoughtGroup.group), + itemIndex: 0, + group: pinnedThoughtGroup.group, + isPinned: true, + }); + } + for (let i = startIndex; i < timelineItems.length; i += 1) { + const item = timelineItems[i]; + if (i > 0) { + const currSessionId = getItemSessionAnchorId(item); + const prevSessionId = previousSessionAnchorByIndex[i]; + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + // Include itemIndex in the key so a repeated transition (e.g. lead + // sessions A→B→A→B) does not collide on key `A->B` twice — React + // treats duplicate keys as the same element and reuses state + // across unrelated separators. + rows.push({ + kind: 'session-separator', + key: `session-separator-${i}-${prevSessionId}->${currSessionId}`, + }); + } + } + if (item.type === 'lead-thoughts') { + rows.push({ + kind: 'lead-thought-group', + key: getThoughtGroupKey(item.group), + itemIndex: i, + group: item.group, + isPinned: false, + }); + continue; + } + const message = item.message; + if (isCompactionMessage(message)) { + rows.push({ + kind: 'compaction-divider', + key: `compaction-${toMessageKey(message)}`, + message, + }); + continue; + } + rows.push({ + kind: 'message-row', + key: toMessageKey(message), + itemIndex: i, + message, + }); + } + return rows; + }, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]); + + // Virtualizer gate — activates only when the parent opts in via + // `viewport.virtualizationEnabled`, the scroll element ref is present, and + // the row count is large enough for virtualization to pay for itself. Below + // the threshold the direct render path is both simpler and faster, so we + // keep it for short lists. + const shouldVirtualize = + viewport?.virtualizationEnabled === true && + viewport.scrollElementRef != null && + renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD; + + // DOM-measured distance from the scroll container's scroll origin to the + // timeline root. We avoid re-measuring on every scroll: the offset only + // changes when layout above the timeline changes, so observe the timeline, + // its ancestor chain, and all previous siblings that can push it down. + const [measuredScrollMargin, setMeasuredScrollMargin] = useState(0); + + useLayoutEffect(() => { + if (!shouldVirtualize) return; + const scrollEl = viewport?.scrollElementRef?.current ?? null; + const rootEl = rootRef.current; + if (!scrollEl || !rootEl) return; + + let pending = false; + let rafId: number | null = null; + const measure = (): void => { + if (pending) return; + pending = true; + rafId = requestAnimationFrame(() => { + rafId = null; + pending = false; + const scrollRect = scrollEl.getBoundingClientRect(); + const rootRect = rootEl.getBoundingClientRect(); + // Distance from top of scroll content to top of timeline root. Adding + // `scrollTop` compensates for the fact that both rects are relative + // to the viewport at measurement time, not the scrollable content. + const next = Math.max(0, rootRect.top - scrollRect.top + scrollEl.scrollTop); + setMeasuredScrollMargin((prev) => (Math.abs(prev - next) < 0.5 ? prev : next)); + }); + }; + + measure(); + const resizeObserver = new ResizeObserver(measure); + const observedTargets = collectScrollMarginObserverTargets(rootEl, scrollEl); + observedTargets.forEach((target) => resizeObserver.observe(target)); + window.addEventListener('resize', measure); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + window.removeEventListener('resize', measure); + }; + }, [shouldVirtualize, viewport?.scrollElementRef]); + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? renderRows.length : 0, + getScrollElement: () => viewport?.scrollElementRef?.current ?? null, + estimateSize: (index) => ROW_SIZE_ESTIMATES[renderRows[index]?.kind ?? 'message-row'], + getItemKey: (index) => renderRows[index]?.key ?? `row-${index}`, + overscan: VIRTUALIZER_OVERSCAN, + gap: VIRTUALIZATION_ROW_GAP_PX, + scrollMargin: measuredScrollMargin, + }); + // Determine the index of the "newest" non-thought timeline item (for auto-expand). const newestMessageIndex = useMemo(() => { return findNewestMessageIndex(timelineItems); @@ -485,6 +738,124 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); + // Render a single atomic row. Logic per kind mirrors the previous inline + // render path; separators and dividers are their own rows rather than + // being bundled into Fragments, which is the contract the virtualizer will + // consume in a follow-up PR. + // + // `suppressEntryAnimation` is set when the caller is the virtualized path: + // the virtualizer mounts and unmounts rows as they enter and leave the + // viewport, so relying on mount as a signal of "this item is new" would + // replay the entry animation every time the user scrolls back to an old + // row. In the direct render path the flag stays false and animation still + // runs on real data-set additions. + const renderTimelineRow = ( + row: TimelineRow, + options?: { suppressEntryAnimation?: boolean } + ): React.JSX.Element | null => { + const suppressEntry = options?.suppressEntryAnimation === true; + switch (row.kind) { + case 'session-separator': + return ( +
+
+ + New session + +
+
+ ); + case 'compaction-divider': + return ; + case 'lead-thought-group': { + const { group, itemIndex, isPinned, key } = row; + const firstThought = group.thoughts[0]; + const info = memberInfo.get(firstThought.from); + const collapseProps = getItemCollapseProps(key, itemIndex); + const pinnedCanBeLive = isPinned + ? currentLeadSessionId + ? firstThought.leadSessionId === currentLeadSessionId + : true + : false; + return ( + + ); + } + case 'message-row': { + const { message, itemIndex, key } = row; + const renderProps = resolveMessageRenderProps(message, ctx); + const collapseProps = getItemCollapseProps(key, itemIndex); + const isUnread = readState + ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) + : !message.read; + return ( + + ); + } + } + }; + if (messages.length === 0) { return (
@@ -496,165 +867,49 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ return (
- {/* Pinned (newest) thought group — always at top */} - {pinnedThoughtGroup && - (() => { - const { group } = pinnedThoughtGroup; - const firstThought = group.thoughts[0]; - const pinnedCanBeLive = currentLeadSessionId - ? firstThought.leadSessionId === currentLeadSessionId - : true; - const info = memberInfo.get(firstThought.from); - const itemKey = getThoughtGroupKey(group); - const stableKey = itemKey; - const collapseProps = getItemCollapseProps(stableKey, 0); - return ( - - ); - })()} - - {/* Remaining items */} - {timelineItems.slice(startIndex).map((item, index) => { - const realIndex = index + startIndex; - - // Session boundary separator (messages sorted desc — new on top) - let sessionSeparator: React.JSX.Element | null = null; - if (realIndex > 0) { - const currSessionId = getItemSessionAnchorId(item); - const prevSessionId = previousSessionAnchorByIndex[realIndex]; - if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { - sessionSeparator = ( + {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = renderRows[virtualRow.index]; + if (!row) return null; + return (
-
- - New session - -
+ {renderTimelineRow(row, { suppressEntryAnimation: true })}
); - } - } - - if (item.type === 'lead-thoughts') { - const { group } = item; - const firstThought = group.thoughts[0]; - const info = memberInfo.get(firstThought.from); - const itemKey = getThoughtGroupKey(group); - const stableKey = itemKey; - const collapseProps = getItemCollapseProps(stableKey, realIndex); - return ( - - {sessionSeparator} - - - ); - } - - const { message } = item; - - // Compaction boundary — render as a divider instead of a regular message card - if (isCompactionMessage(message)) { - const messageKey = toMessageKey(message); - return ( - - {sessionSeparator} - - - ); - } - - const renderProps = resolveMessageRenderProps(message, ctx); - const messageKey = toMessageKey(message); - const stableKey = messageKey; - const collapseProps = getItemCollapseProps(stableKey, realIndex); - const isUnread = readState - ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) - : !message.read; - return ( - - {sessionSeparator} - - - ); - })} + })} +
+ ) : ( + renderRows.map((row) => renderTimelineRow(row)) + )} {hiddenCount > 0 && (
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9ee1adc1..bcb1626f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,6 +1,7 @@ import { type JSX, memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps { memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; + /** + * Root element for IntersectionObserver-based visibility tracking. When + * omitted, the observer falls back to the document viewport — correct for + * top-level renders, incorrect when the row is inside a scroll container + * (sidebar, bottom-sheet) that can clip the row while the document + * viewport still contains it. + */ + observerRoot?: RefObject; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; /** Whether the owning team is currently alive. */ @@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({ memberColor, isNew, onVisible, + observerRoot, canBeLive, isTeamAlive, leadActivity, @@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({ if (!onVisible) return; const el = ref.current; if (!el) return; + // Resolve observer root at effect-time. Falls back to the document + // viewport when no root is provided — preserves pre-contract behavior. + const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; @@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({ } reportedCountRef.current = thoughts.length; }, - { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); - }, [onVisible, thoughts]); + }, [onVisible, observerRoot, thoughts]); const clearPendingScrollSync = useCallback(() => { if (scrollSyncFrameRef.current !== null) { @@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo( prev.compactHeader === next.compactHeader && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && + prev.observerRoot === next.observerRoot && areThoughtGroupsEquivalent(prev.group, next.group) ); diff --git a/src/renderer/components/team/activity/MessageExpandDialog.tsx b/src/renderer/components/team/activity/MessageExpandDialog.tsx index bed2dcff..e0986355 100644 --- a/src/renderer/components/team/activity/MessageExpandDialog.tsx +++ b/src/renderer/components/team/activity/MessageExpandDialog.tsx @@ -9,7 +9,7 @@ import { } from '@renderer/components/ui/dialog'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers'; import { MemberBadge } from '../MemberBadge'; @@ -28,6 +28,7 @@ function formatTime(timestamp: string): string { interface DialogThoughtsContentProps { group: LeadThoughtGroup; + members?: ResolvedTeamMember[]; memberColor?: string; onTaskIdClick?: (taskId: string) => void; onReply?: (message: InboxMessage) => void; @@ -39,6 +40,7 @@ interface DialogThoughtsContentProps { const DialogThoughtsContent = ({ group, + members, memberColor, onTaskIdClick, onReply, @@ -51,6 +53,7 @@ const DialogThoughtsContent = ({ const newest = thoughts[0]; const oldest = thoughts[thoughts.length - 1]; const colors = getTeamColorSet(memberColor ?? ''); + const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]); const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]); return ( @@ -58,7 +61,7 @@ const DialogThoughtsContent = ({ {/* Header */}
s.pendingApprovals)); const colorMap = buildMemberColorMap(members); + const avatarMap = buildMemberAvatarMap(members); const memberPending = Object.entries(pendingRepliesByMember) .map(([name, sentAtMs]) => ({ kind: 'member' as const, @@ -111,7 +113,7 @@ export const PendingRepliesBlock = ({
soloTeam - ? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }] + ? [ + { + id: 'team-lead', + name: 'team-lead', + subtitle: 'Team Lead', + color: resolveTeamLeadColorName(), + }, + ] : buildMemberDraftSuggestions(members, memberColorMap), [memberColorMap, members, soloTeam] ); @@ -1219,7 +1227,7 @@ export const CreateTeamDialog = ({ } }} > - + {initialData ? 'Copy Team' : 'Create Team'} diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index a19e19b4..9eaa1c4c 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -1,13 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { buildMembersFromDrafts, createMemberDraftsFromInputs, filterEditableMemberInputs, + createMemberDraft, MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; +import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -21,8 +23,21 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { + agentAvatarUrl, + buildMemberColorMap, + displayMemberName, +} from '@renderer/utils/memberHelpers'; +import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { Loader2 } from 'lucide-react'; +import { + buildEditTeamSourceSnapshot, + getMemberRuntimeContractKey, + getLiveRosterIdentityChanges, + getMembersRequiringRuntimeRestart, +} from './editTeamRuntimeChanges'; + import type { ResolvedTeamMember } from '@shared/types'; const TEAM_COLOR_NAMES = [ @@ -43,16 +58,73 @@ interface EditTeamDialogProps { currentDescription: string; currentColor: string; currentMembers: ResolvedTeamMember[]; + leadMember?: ResolvedTeamMember | null; + resolvedMemberColorMap?: ReadonlyMap; isTeamAlive?: boolean; + isTeamProvisioning?: boolean; projectPath?: string | null; onClose: () => void; - onSaved: () => void; + onChangeLeadRuntime: () => void; + onSaved: () => Promise | void; } function membersToDrafts(members: ResolvedTeamMember[]) { return createMemberDraftsFromInputs(filterEditableMemberInputs(members)); } +function useEditTeamErrorReset( + setError: (value: string | null) => void, + setSaveOutcomeError: (value: string | null) => void +): () => void { + return () => { + setError(null); + setSaveOutcomeError(null); + }; +} + +function getInvalidMemberNamesError( + members: readonly { + name: string; + removedAt?: number | string | null; + }[] +): string | null { + for (const member of members) { + if (member.removedAt) { + continue; + } + const name = member.name.trim(); + if (!name) { + return 'Member name cannot be empty'; + } + if (validateMemberNameInline(name) !== null) { + return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars'; + } + const lower = name.toLowerCase(); + if (lower === 'user' || lower === 'team-lead') { + return `Member name "${name}" is reserved`; + } + const suffixInfo = parseNumericSuffixName(name); + if (suffixInfo && suffixInfo.suffix >= 2) { + return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`; + } + } + return null; +} + +function applyRemovedMembersToSnapshot( + members: readonly ResolvedTeamMember[], + removedMemberNames: readonly string[] +): ResolvedTeamMember[] { + if (removedMemberNames.length === 0) { + return [...members]; + } + const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase())); + const removedAt = Date.now(); + return members.map((member) => + removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member + ); +} + export const EditTeamDialog = ({ open, teamName, @@ -60,9 +132,13 @@ export const EditTeamDialog = ({ currentDescription, currentColor, currentMembers, + leadMember = null, + resolvedMemberColorMap, isTeamAlive = false, + isTeamProvisioning = false, projectPath, onClose, + onChangeLeadRuntime, onSaved, }: EditTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); @@ -72,39 +148,305 @@ export const EditTeamDialog = ({ const [members, setMembers] = useState(() => membersToDrafts(currentMembers)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [saveOutcomeError, setSaveOutcomeError] = useState(null); + const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState< + Record + >({}); + const wasOpenRef = useRef(false); + const initializedTeamNameRef = useRef(null); + const baselineSourceSnapshotRef = useRef(null); + const pendingCommittedSourceSnapshotRef = useRef(null); useFileListCacheWarmer(projectPath ?? null); + const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError); + const effectiveResolvedMemberColorMap = useMemo( + () => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers), + [currentMembers, resolvedMemberColorMap] + ); + const leadDraft = useMemo(() => { + if (!leadMember) return null; + return createMemberDraft({ + id: `lead:${leadMember.name}`, + name: displayMemberName(leadMember.name), + originalName: leadMember.name, + roleSelection: '', + customRole: 'Team Lead', + workflow: leadMember.workflow, + providerId: leadMember.providerId, + model: leadMember.model ?? '', + effort: leadMember.effort, + }); + }, [leadMember]); useEffect(() => { + const wasOpen = wasOpenRef.current; if (open) { - setName(currentName); - setDescription(currentDescription); - setColor(currentColor); - setMembers(membersToDrafts(currentMembers)); - setError(null); + const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName; + if (shouldInitialize) { + setName(currentName); + setDescription(currentDescription); + setColor(currentColor); + setMembers(membersToDrafts(currentMembers)); + setError(null); + setSaveOutcomeError(null); + setMembersPendingRestartRetry({}); + initializedTeamNameRef.current = teamName; + baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({ + name: currentName, + description: currentDescription, + color: currentColor, + members: currentMembers, + }); + pendingCommittedSourceSnapshotRef.current = null; + } else if (pendingCommittedSourceSnapshotRef.current !== null) { + const latestSourceSnapshot = buildEditTeamSourceSnapshot({ + name: currentName, + description: currentDescription, + color: currentColor, + members: currentMembers, + }); + if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) { + baselineSourceSnapshotRef.current = latestSourceSnapshot; + pendingCommittedSourceSnapshotRef.current = null; + } + } + } else if (wasOpen) { + initializedTeamNameRef.current = null; + baselineSourceSnapshotRef.current = null; + pendingCommittedSourceSnapshotRef.current = null; } - }, [open, currentName, currentDescription, currentColor, currentMembers]); + wasOpenRef.current = open; + }, [open, teamName, currentName, currentDescription, currentColor, currentMembers]); + + const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]); + const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]); + const hasDuplicateMembers = useMemo(() => { + const names = members + .filter((member) => !member.removedAt) + .map((member) => member.name.trim().toLowerCase()) + .filter(Boolean); + return new Set(names).size !== names.length; + }, [members]); + const membersToRestart = useMemo( + () => + isTeamAlive + ? getMembersRequiringRuntimeRestart({ + previousMembers: currentMembers, + nextMembers: builtMembers, + }) + : [], + [builtMembers, currentMembers, isTeamAlive] + ); + const builtMembersByName = useMemo( + () => + new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)), + [builtMembers] + ); + const effectiveMembersToRestart = useMemo(() => { + const retryMembers = Object.entries(membersPendingRestartRetry).flatMap( + ([normalizedName, expectedRuntimeContractKey]) => { + const nextMember = builtMembersByName.get(normalizedName); + if (!nextMember) { + return []; + } + return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey + ? [nextMember.name.trim()] + : []; + } + ); + return Array.from( + new Set( + [...membersToRestart, ...retryMembers] + .map((memberName) => memberName.trim()) + .filter(Boolean) + ) + ); + }, [builtMembersByName, membersPendingRestartRetry, membersToRestart]); + const liveIdentityChanges = useMemo( + () => + isTeamAlive + ? getLiveRosterIdentityChanges({ + previousMembers: currentMembers, + nextDrafts: members, + }) + : { renamed: [], removed: [] }, + [currentMembers, isTeamAlive, members] + ); + const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0; + const liveRemovedExistingMembers = useMemo( + () => (isTeamAlive ? liveIdentityChanges.removed : []), + [isTeamAlive, liveIdentityChanges.removed] + ); + const hasNewLiveTeammates = useMemo( + () => + isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()), + [isTeamAlive, members] + ); + const memberWarningById = useMemo(() => { + const restartNames = new Set( + effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase()) + ); + if (restartNames.size === 0) { + return undefined; + } + return Object.fromEntries( + members.map((member) => [ + member.id, + restartNames.has(member.name.trim().toLowerCase()) + ? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.' + : null, + ]) + ); + }, [effectiveMembersToRestart, members]); const handleSave = (): void => { if (!name.trim()) { setError('Team name cannot be empty'); return; } - const builtMembers = buildMembersFromDrafts(members); + if (invalidMemberNamesError) { + setError(invalidMemberNamesError); + return; + } + if (hasDuplicateMembers) { + setError('Member names must be unique before saving'); + return; + } + const latestSourceSnapshot = buildEditTeamSourceSnapshot({ + name: currentName, + description: currentDescription, + color: currentColor, + members: currentMembers, + }); + const allowedSourceSnapshots = new Set( + [baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter( + (value): value is string => value !== null + ) + ); + if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) { + setError( + 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.' + ); + return; + } + if (hasBlockedLiveIdentityChanges) { + setError( + `Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}` + ); + return; + } + if (isTeamProvisioning) { + setError( + 'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.' + ); + return; + } + if (hasNewLiveTeammates) { + setError( + 'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.' + ); + return; + } setSaving(true); setError(null); + setSaveOutcomeError(null); void (async () => { + let configSaved = false; + let membersSaved = false; + let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers; try { await api.teams.updateConfig(teamName, { name: name.trim(), description: description.trim(), color, }); + configSaved = true; + for (const removedMemberName of liveRemovedExistingMembers) { + await api.teams.removeMember(teamName, removedMemberName); + committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [ + removedMemberName, + ]); + } await api.teams.replaceMembers(teamName, { members: builtMembers }); - onSaved(); - onClose(); + membersSaved = true; + pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({ + name: name.trim(), + description: description.trim(), + color: color.trim(), + members: builtMembers.map((member) => ({ + name: member.name, + role: member.role, + workflow: member.workflow, + providerId: member.providerId, + model: member.model, + effort: member.effort, + })) as ResolvedTeamMember[], + }); + + const restartFailures: string[] = []; + const failedRestartMembers: string[] = []; + for (const memberName of effectiveMembersToRestart) { + try { + await api.teams.restartMember(teamName, memberName); + } catch (restartError) { + const detail = + restartError instanceof Error ? restartError.message : String(restartError); + failedRestartMembers.push(memberName); + restartFailures.push(`${memberName} (${detail})`); + } + } + + await Promise.resolve(onSaved()); + if (restartFailures.length === 0) { + setMembersPendingRestartRetry({}); + onClose(); + return; + } + + setMembersPendingRestartRetry( + Object.fromEntries( + failedRestartMembers.flatMap((memberName) => { + const nextMember = builtMembersByName.get(memberName.trim().toLowerCase()); + if (!nextMember) { + return []; + } + return [ + [memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const, + ]; + }) + ) + ); + setSaveOutcomeError( + `Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}` + ); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to save'); + const message = e instanceof Error ? e.message : 'Failed to save'; + if (membersSaved) { + setSaveOutcomeError( + `Team changes were saved, but failed to refresh the latest view: ${message}` + ); + } else if (configSaved) { + pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({ + name: name.trim(), + description: description.trim(), + color: color.trim(), + members: committedMembersForSnapshot, + }); + let refreshErrorDetail: string | null = null; + try { + await Promise.resolve(onSaved()); + } catch (refreshError) { + refreshErrorDetail = + refreshError instanceof Error ? refreshError.message : String(refreshError); + } + setSaveOutcomeError( + refreshErrorDetail + ? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}` + : `Team settings were saved, but member changes failed: ${message}` + ); + } else { + setError(message); + } } finally { setSaving(false); } @@ -113,7 +455,7 @@ export const EditTeamDialog = ({ return ( !nextOpen && onClose()}> - + Edit Team Change team name, description and color @@ -131,7 +473,10 @@ export const EditTeamDialog = ({ id="edit-team-name" type="text" value={name} - onChange={(e) => setName(e.target.value)} + onChange={(e) => { + clearTransientErrors(); + setName(e.target.value); + }} onKeyDown={(e) => { if (e.key === 'Enter' && !saving && name.trim()) handleSave(); }} @@ -149,7 +494,10 @@ export const EditTeamDialog = ({