From d0341e58afe59bf90416d12e8786174ff7b1d7cd Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 13:18:53 +0300 Subject: [PATCH] fix(team): retain launch status and clarify notifications --- src/main/ipc/teams.ts | 48 ++++-- .../infrastructure/NotificationManager.ts | 39 ++++- src/main/services/team/AutoResumeService.ts | 128 ++++++++++----- .../services/team/TeamProvisioningService.ts | 152 ++++++++++++++++-- src/main/utils/teamNotificationBuilder.ts | 4 + .../components/team/TeamDetailView.tsx | 24 +++ .../store/slices/notificationSlice.ts | 4 +- src/renderer/store/slices/teamSlice.ts | 124 +++++++++++++- src/shared/types/notifications.ts | 6 +- .../NotificationManager.team.test.ts | 98 +++++++++++ .../team/TeamProvisioningService.test.ts | 60 +++++++ .../utils/teamNotificationBuilder.test.ts | 8 +- test/renderer/store/notificationSlice.test.ts | 68 ++++++++ 13 files changed, 682 insertions(+), 81 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 32132aaf..98f7ac49 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -123,6 +123,7 @@ import { import { getAutoResumeService, initializeAutoResumeService, + planRateLimitAutoResume, } from '../services/team/AutoResumeService'; import { buildReplaceMembersDiff, @@ -364,6 +365,21 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string | const seenApiErrorKeys = new Set(); const SEEN_API_ERROR_KEYS_MAX = 500; +function formatNotificationClockTime(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +function buildRateLimitNotificationBody(plan: ReturnType): string { + if (plan.kind === 'scheduled') { + return `Auto-resume scheduled at ${formatNotificationClockTime(plan.resetTime)}`; + } + return 'Manual restart needed'; +} + /** * Check messages for rate limit indicators and fire notifications for new ones. * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion) @@ -395,6 +411,18 @@ function checkRateLimitMessages( const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `rate-limit:${teamName}:${rawKey}`; + const isLeadAutoResumeCandidate = + !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); + const autoResumeSessionMatches = + msg.source !== 'lead_session' || + (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId); + const autoResumePlan = planRateLimitAutoResume({ + enabled: autoResumeEnabled, + canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches, + messageText: msg.text, + observedAt, + messageTimestamp: new Date(msg.timestamp), + }); // In-memory guard: prevents resurrection after user deletes the notification. if (!seenRateLimitKeys.has(dedupeKey)) { @@ -412,8 +440,8 @@ function checkRateLimitMessages( teamName, teamDisplayName, from: msg.from, - summary: `Rate limit: ${msg.from}`, - body: msg.text.slice(0, 200), + summary: 'Rate limit', + body: buildRateLimitNotificationBody(autoResumePlan), dedupeKey, target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, @@ -425,18 +453,10 @@ function checkRateLimitMessages( // Persisted history for an offline/stopped team may still contain the old // rate-limit message, but arming a new timer from that stale history would // resurrect the nudge into a later manual restart. - const isLeadAutoResumeCandidate = - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - - if (autoResumeEnabled && teamIsAlive && isLeadAutoResumeCandidate) { + if (autoResumePlan.kind === 'scheduled') { // Only let persisted lead_session history rebuild auto-resume when it // clearly belongs to the currently running lead session. Otherwise an old // rate-limit from a previous manual run can resurrect into a newer restart. - if (msg.source === 'lead_session') { - if (!currentLeadSessionId) continue; - if (msg.leadSessionId !== currentLeadSessionId) continue; - } - // Pass the original message timestamp so relative reset windows survive restarts // and old history does not rebuild a fresh auto-resume timer from "now". getAutoResumeService().handleRateLimitMessage( @@ -483,12 +503,12 @@ function checkApiErrorMessages( void NotificationManager.getInstance() .addTeamNotification({ - teamEventType: 'rate_limit', // reuse rate_limit type — closest fit + teamEventType: 'api_error', teamName, teamDisplayName, from: msg.from, - summary: `API Error ${statusCode}: ${msg.from}`, - body: msg.text.slice(0, 400), + summary: `API Error ${statusCode}`, + body: 'Manual restart needed', dedupeKey, target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 0491e5dc..99737b09 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -287,8 +287,12 @@ function extractTaskSubject(summary: string): string { return summary .replace(/^Comment on\s+#[^:]+:\s*/i, '') .replace(/^Comment on\s+#[^\s]+/i, '') - .replace(/^Clarification needed\s+-\s+Task\s+#[^:]+:\s*/i, '') - .replace(/^Clarification needed\s+-\s+Task\s+#[^\s]+/i, '') + .replace(/^Clarification needed\s+[-–—]\s+Task\s+#[^:]+:\s*/i, '') + .replace(/^Clarification needed\s+[-–—]\s+Task\s+#[^\s]+/i, '') + .replace(/^Review requested\s+#[^:]+:\s*/i, '') + .replace(/^Review requested\s+#[^\s]+/i, '') + .replace(/^Blocked\s+#[^:]+:\s*/i, '') + .replace(/^Blocked\s+#[^\s]+/i, '') .replace(/^New task\s+#[^:]+:\s*/i, '') .replace(/^New task\s+#[^\s]+/i, '') .replace(/^Task\s+#[^:]+:\s*/i, '') @@ -303,7 +307,14 @@ function getTeamNotificationAction( case 'task_comment': return taskRef ? `commented on ${taskRef}` : 'commented on a task'; case 'task_clarification': - return taskRef ? `needs clarification on ${taskRef}` : 'needs clarification'; + return taskRef ? `needs your reply on ${taskRef}` : 'needs your reply'; + case 'task_review_requested': + return taskRef ? `requested review on ${taskRef}` : 'requested review'; + case 'task_blocked': { + const sender = payload.from.trim().toLowerCase(); + if (sender === 'system') return taskRef ? `Task is blocked on ${taskRef}` : 'Task is blocked'; + return taskRef ? `is blocked on ${taskRef}` : 'is blocked'; + } case 'task_status_change': return taskRef ? `changed ${taskRef}` : 'changed task status'; case 'task_created': @@ -316,9 +327,9 @@ function getTeamNotificationAction( case 'cross_team_message': return 'sent a cross-team message'; case 'rate_limit': - return /api error/i.test(`${payload.summary} ${payload.body}`) - ? 'hit an API error' - : 'hit rate limit'; + return 'paused: rate limit'; + case 'api_error': + return 'paused: API error'; case 'schedule_completed': return 'completed a schedule'; case 'schedule_failed': @@ -357,6 +368,22 @@ function buildTeamNotificationPresentation( const where = getTeamNotificationWhere(payload, taskRef); const normalizedBody = cleanNotificationText(body); + if (payload.teamEventType === 'team_launch_incomplete') { + return { + title: 'Team launch incomplete', + where: truncateNotificationText(where, 120), + body: truncateNotificationText(normalizedBody || summary, 300), + }; + } + + if (payload.teamEventType === 'task_blocked' && payload.from.trim().toLowerCase() === 'system') { + return { + title: truncateNotificationText(action, 96), + where: truncateNotificationText(where, 120), + body: truncateNotificationText(normalizedBody || summary, 300), + }; + } + return { title: truncateNotificationText(`${who} ${action}`.trim(), 96), where: truncateNotificationText(where, 120), diff --git a/src/main/services/team/AutoResumeService.ts b/src/main/services/team/AutoResumeService.ts index 0ec6f237..604b74d3 100644 --- a/src/main/services/team/AutoResumeService.ts +++ b/src/main/services/team/AutoResumeService.ts @@ -20,6 +20,64 @@ interface PendingAutoResumeEntry { sourceRunId: string | null; } +export type RateLimitAutoResumePlan = + | { + kind: 'scheduled'; + resetTime: Date; + delayMs: number; + fireAtMs: number; + rawDelayMs: number; + } + | { + kind: 'manual'; + reason: 'disabled' | 'not_resumable' | 'reset_unparseable' | 'stale' | 'too_far'; + }; + +export function planRateLimitAutoResume(input: { + enabled: boolean; + canAutoResume: boolean; + messageText: string; + observedAt: Date; + messageTimestamp?: Date; +}): RateLimitAutoResumePlan { + if (!input.enabled) return { kind: 'manual', reason: 'disabled' }; + if (!input.canAutoResume) return { kind: 'manual', reason: 'not_resumable' }; + + const observedAtMs = input.observedAt.getTime(); + const messageTimestamp = input.messageTimestamp ?? input.observedAt; + const messageAtMs = Number.isFinite(messageTimestamp.getTime()) + ? messageTimestamp.getTime() + : observedAtMs; + const parseReferenceTime = Number.isFinite(messageTimestamp.getTime()) + ? messageTimestamp + : input.observedAt; + + const resetTime = parseRateLimitResetTime(input.messageText, parseReferenceTime); + if (!resetTime) return { kind: 'manual', reason: 'reset_unparseable' }; + + const resetAtMs = resetTime.getTime(); + const rawDelayMs = resetAtMs - observedAtMs; + const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS; + const messageAgeMs = Math.max(0, observedAtMs - messageAtMs); + + if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) { + return { kind: 'manual', reason: 'stale' }; + } + + const delayMs = Math.max(0, targetFireAtMs - observedAtMs); + if (delayMs > AUTO_RESUME_MAX_DELAY_MS) { + return { kind: 'manual', reason: 'too_far' }; + } + + return { + kind: 'scheduled', + resetTime, + delayMs, + fireAtMs: observedAtMs + delayMs, + rawDelayMs, + }; +} + type AutoResumeProvisioning = Pick< TeamProvisioningService, 'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam' @@ -40,31 +98,13 @@ export class AutoResumeService { observedAt: Date = new Date(), messageTimestamp: Date = observedAt ): void { - const cfg = this.configManager.getConfig(); - if (!cfg.notifications.autoResumeOnRateLimit) return; - const observedAtMs = observedAt.getTime(); const messageAtMs = Number.isFinite(messageTimestamp.getTime()) ? messageTimestamp.getTime() : observedAtMs; - const parseReferenceTime = Number.isFinite(messageTimestamp.getTime()) - ? messageTimestamp - : observedAt; - - const resetTime = parseRateLimitResetTime(messageText, parseReferenceTime); - if (!resetTime) { - logger.info( - `[auto-resume] Rate limit detected for "${teamName}" but reset time was not parseable - skipping auto-resume` - ); - return; - } - - const resetAtMs = resetTime.getTime(); - const rawDelayMs = resetAtMs - observedAtMs; - const targetFireAtMs = resetAtMs + AUTO_RESUME_BUFFER_MS; - const messageAgeMs = Math.max(0, observedAtMs - messageAtMs); const existing = this.pendingTimers.get(teamName); const sourceRunId = this.provisioningService.getCurrentRunId(teamName); + const cfg = this.configManager.getConfig(); if (existing && messageAtMs < existing.sourceMessageAtMs) { logger.info( @@ -73,35 +113,37 @@ export class AutoResumeService { return; } - if (targetFireAtMs <= observedAtMs && messageAgeMs > AUTO_RESUME_HISTORY_FRESH_MS) { - logger.info( - `[auto-resume] Parsed reset time for "${teamName}" passed its buffered fire deadline ${Math.round((observedAtMs - targetFireAtMs) / 1000)}s ago - skipping stale history replay` - ); - return; - } + const plan = planRateLimitAutoResume({ + enabled: cfg.notifications.autoResumeOnRateLimit, + canAutoResume: true, + messageText, + observedAt, + messageTimestamp, + }); - if (rawDelayMs < 0) { - logger.warn( - `[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-rawDelayMs / 1000)}s in the past - using remaining buffered delay` - ); - } - - const delayMs = Math.max(0, targetFireAtMs - observedAtMs); - const fireAtMs = observedAtMs + delayMs; - - if (delayMs > AUTO_RESUME_MAX_DELAY_MS) { - if (existing) { + if (plan.kind === 'manual') { + if (plan.reason === 'too_far' && existing) { clearTimeout(existing.timer); this.pendingTimers.delete(teamName); } - logger.warn( - `[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(delayMs / 60000)}m away - exceeds ceiling, skipping` + if (plan.reason === 'too_far') { + logger.warn(`[auto-resume] Parsed reset time for "${teamName}" exceeds ceiling - skipping`); + return; + } + logger.info( + `[auto-resume] Rate limit detected for "${teamName}" but auto-resume is manual (${plan.reason})` ); return; } + if (plan.rawDelayMs < 0) { + logger.warn( + `[auto-resume] Parsed reset time for "${teamName}" is ${Math.round(-plan.rawDelayMs / 1000)}s in the past - using remaining buffered delay` + ); + } + if ( - existing?.fireAtMs === fireAtMs && + existing?.fireAtMs === plan.fireAtMs && existing.sourceMessageAtMs === messageAtMs && existing.sourceRunId === sourceRunId ) { @@ -112,22 +154,22 @@ export class AutoResumeService { clearTimeout(existing.timer); this.pendingTimers.delete(teamName); logger.info( - `[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}` + `[auto-resume] Rescheduling resume for "${teamName}" to ${plan.resetTime.toISOString()}` ); } else { logger.info( - `[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)` + `[auto-resume] Scheduling resume for "${teamName}" at ${plan.resetTime.toISOString()} (in ${Math.round(plan.delayMs / 1000)}s)` ); } const timer = setTimeout(() => { this.pendingTimers.delete(teamName); void this.fireResumeNudge(teamName, sourceRunId); - }, delayMs); + }, plan.delayMs); this.pendingTimers.set(teamName, { timer, - fireAtMs, + fireAtMs: plan.fireAtMs, sourceMessageAtMs: messageAtMs, sourceRunId, }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 29307f3c..3c4c920f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4693,11 +4693,17 @@ export class TeamProvisioningService { private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; + private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); private readonly runtimeAdapterProgressByRunId = new Map(); + private retainedProvisioningProgressByRunId: Map | undefined = + new Map(); + private retainedProvisioningProgressTimersByRunId: + | Map> + | undefined = new Map>(); private readonly runtimeAdapterTraceLinesByRunId = new Map(); private readonly runtimeAdapterTraceKeyByRunId = new Map(); private readonly runtimeAdapterRunByTeam = new Map< @@ -15908,9 +15914,48 @@ export class TeamProvisioningService { if (runtimeProgress) { return runtimeProgress; } + const retainedProgress = this.getRetainedProvisioningProgressMap().get(runId); + if (retainedProgress) { + return retainedProgress; + } throw new Error('Unknown runId'); } + private getRetainedProvisioningProgressMap(): Map { + this.retainedProvisioningProgressByRunId ??= new Map(); + return this.retainedProvisioningProgressByRunId; + } + + private getRetainedProvisioningProgressTimersMap(): Map> { + this.retainedProvisioningProgressTimersByRunId ??= new Map< + string, + ReturnType + >(); + return this.retainedProvisioningProgressTimersByRunId; + } + + private retainProvisioningProgress(runId: string, progress: TeamProvisioningProgress): void { + const retainedProgress = this.getRetainedProvisioningProgressMap(); + const retainedTimers = this.getRetainedProvisioningProgressTimersMap(); + const previousTimer = retainedTimers.get(runId); + if (previousTimer) { + clearTimeout(previousTimer); + } + + retainedProgress.set(runId, { + ...progress, + warnings: progress.warnings ? [...progress.warnings] : undefined, + launchDiagnostics: progress.launchDiagnostics ? [...progress.launchDiagnostics] : undefined, + }); + + const timer = setTimeout(() => { + retainedProgress.delete(runId); + retainedTimers.delete(runId); + }, TeamProvisioningService.RETAINED_PROVISIONING_PROGRESS_TTL_MS); + timer.unref?.(); + retainedTimers.set(runId, timer); + } + async cancelProvisioning(runId: string): Promise { const run = this.runs.get(runId); if (!run) { @@ -22578,15 +22623,7 @@ export class TeamProvisioningService { void this.injectGeminiPostLaunchHydration(run); } - if (!run.provisioningComplete && !run.cancelRequested) { - void this.handleProvisioningTurnComplete(run).catch((err: unknown) => { - logger.error( - `[${run.teamName}] handleProvisioningTurnComplete threw unexpectedly: ${ - err instanceof Error ? err.message : String(err) - }` - ); - }); - } + this.completeProvisioningFromSuccessfulResult(run); } else if (subtype === 'error') { const errorMsg = typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown'); @@ -22783,6 +22820,20 @@ export class TeamProvisioningService { } } + private completeProvisioningFromSuccessfulResult(run: ProvisioningRun): void { + if (run.provisioningComplete || run.cancelRequested) { + return; + } + + void this.handleProvisioningTurnComplete(run).catch((err: unknown) => { + logger.error( + `[${run.teamName}] handleProvisioningTurnComplete threw unexpectedly: ${ + err instanceof Error ? err.message : String(err) + }` + ); + }); + } + /** * Injects a post-compact context reminder into the lead process via stdin. * Reinjects durable lead rules (constraints, communication protocol, board MCP ops) @@ -24041,6 +24092,13 @@ export class TeamProvisioningService { if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); + } else { + void this.fireTeamLaunchIncompleteNotification( + run, + failedSpawnMembers, + launchSummary, + persistedLaunchSnapshot + ); } if (hasSpawnFailures) { @@ -24219,6 +24277,13 @@ export class TeamProvisioningService { if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); + } else { + void this.fireTeamLaunchIncompleteNotification( + run, + failedSpawnMembers, + launchSummary, + persistedLaunchSnapshot + ); } if (hasSpawnFailures) { @@ -24287,6 +24352,74 @@ export class TeamProvisioningService { } } + private async fireTeamLaunchIncompleteNotification( + run: ProvisioningRun, + failedMembers: readonly { name: string }[], + launchSummary: { + confirmedCount: number; + pendingCount: number; + failedCount: number; + runtimeAlivePendingCount: number; + runtimeProcessPendingCount?: number; + }, + snapshot?: PersistedTeamLaunchSnapshot | null + ): Promise { + try { + const config = ConfigManager.getInstance().getConfig(); + const suppressToast = !config.notifications.notifyOnTeamLaunched; + const displayName = run.request.displayName || run.teamName; + const expectedMembers = + snapshot?.expectedMembers ?? + run.expectedMembers ?? + run.allEffectiveMembers.map((member) => member.name).filter(Boolean); + const expectedCount = expectedMembers.length; + if (expectedCount === 0) return; + + const failedNames = failedMembers.map((member) => member.name).filter(Boolean); + const pendingNames = + snapshot?.expectedMembers.filter((memberName) => { + if (failedNames.includes(memberName)) return false; + const member = snapshot.members[memberName]; + if (!member) return false; + return ( + member.launchState !== 'confirmed_alive' && member.launchState !== 'skipped_for_launch' + ); + }) ?? []; + const missingNames = failedNames.length > 0 ? failedNames : pendingNames; + const missingCount = + missingNames.length > 0 + ? missingNames.length + : Math.max(0, launchSummary.pendingCount + launchSummary.failedCount); + const joinedCount = Math.max( + 0, + Math.min(expectedCount, launchSummary.confirmedCount || expectedCount - missingCount) + ); + const missingLabel = + missingNames.length > 0 + ? `${missingNames.map((name) => `@${name}`).join(', ')} did not join` + : `${missingCount} teammate${missingCount === 1 ? '' : 's'} did not join`; + + await NotificationManager.getInstance().addTeamNotification({ + teamEventType: 'team_launch_incomplete', + teamName: run.teamName, + teamDisplayName: displayName, + from: 'system', + summary: 'Team launch incomplete', + body: `${joinedCount}/${expectedCount} joined · ${missingLabel}`, + dedupeKey: `team_launch_incomplete:${run.teamName}:${run.runId}`, + target: { kind: 'team', teamName: run.teamName, section: 'members' }, + projectPath: run.request.cwd, + suppressToast, + }); + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to fire team_launch_incomplete notification: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + // --------------------------------------------------------------------------- // Same-team native delivery dedup (Layer 2) // --------------------------------------------------------------------------- @@ -24675,6 +24808,7 @@ export class TeamProvisioningService { } } // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) + this.retainProvisioningProgress(run.runId, run.progress); this.runs.delete(run.runId); } diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index c6649e4e..ba44f842 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -61,17 +61,21 @@ interface TeamNotificationConfig { const TEAM_NOTIFICATION_CONFIG: Record = { rate_limit: { triggerName: 'Rate Limit', triggerColor: 'red' }, + api_error: { triggerName: 'API Error', triggerColor: 'red' }, lead_inbox: { triggerName: 'Team Inbox', triggerColor: 'blue' }, user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' }, task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' }, task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' }, + task_review_requested: { triggerName: 'Review Requested', triggerColor: 'orange' }, + task_blocked: { triggerName: 'Task Blocked', triggerColor: 'red' }, task_created: { triggerName: 'Task Created', triggerColor: 'green' }, all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' }, cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, + team_launch_incomplete: { triggerName: 'Launch Incomplete', triggerColor: 'orange' }, }; // ============================================================================= diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e2331837..38b61dba 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1844,6 +1844,30 @@ export const TeamDetailView = memo(function TeamDetailView({ setPendingReviewRequest(null); }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + const pendingTeamSectionFocus = useStore((s) => s.pendingTeamSectionFocus); + const clearTeamSectionFocus = useStore((s) => s.clearTeamSectionFocus); + useEffect(() => { + if (!pendingTeamSectionFocus || pendingTeamSectionFocus.teamName !== teamName) return; + + const sectionId = + pendingTeamSectionFocus.section === 'members' + ? 'team' + : pendingTeamSectionFocus.section === 'tasks' + ? 'kanban' + : pendingTeamSectionFocus.section; + + if (sectionId === 'overview') { + contentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + clearTeamSectionFocus(); + return; + } + + const section = document.querySelector(`[data-section-id="${sectionId}"]`); + if (!section) return; + section.dispatchEvent(new CustomEvent('team-section-navigate')); + clearTeamSectionFocus(); + }, [pendingTeamSectionFocus, clearTeamSectionFocus, teamName, data]); + // Pick up pending member profile request from MemberHoverCard const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); useEffect(() => { diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts index 741d5f61..44b1f082 100644 --- a/src/renderer/store/slices/notificationSlice.ts +++ b/src/renderer/store/slices/notificationSlice.ts @@ -90,7 +90,9 @@ function navigateToTeamNotification(state: AppState, error: DetectedError): void state.openTeamTab(teamName, error.context.cwd); - if (target?.kind === 'task') { + if (target?.kind === 'team' && target.section) { + state.focusTeamSection(target.teamName, target.section); + } else if (target?.kind === 'task') { state.openGlobalTaskDetail(target.teamName, target.taskId, target.commentId); } else if (target?.kind === 'member') { state.openMemberProfile(target.memberName, target.teamName, target.focus); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index eba09fdc..306f76d0 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -43,6 +43,7 @@ import type { MemberActivityMetaEntry, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + NotificationTarget, PersistedTeamLaunchSummary, ResolvedTeamMember, SendMessageRequest, @@ -1063,6 +1064,7 @@ const notifiedStatusChangeKeys = new Set(); const notifiedCommentKeys = new Set(); const notifiedCreatedTaskKeys = new Set(); const notifiedAllCompletedTeams = new Set(); +const notifiedBlockedTaskKeys = new Set(); let isFirstFetchAllTasks = true; @@ -1236,13 +1238,17 @@ function detectTaskCommentNotifications( for (const comment of newComments) { // Don't notify about user's own comments if (comment.author === 'user') continue; - // Skip review-related comment types (already covered by status change notifications) - if (comment.type === 'review_request' || comment.type === 'review_approved') continue; const key = `${task.teamName}:${task.id}:${comment.id}`; if (notifiedCommentKeys.has(key)) continue; notifiedCommentKeys.add(key); + if (comment.type === 'review_request') { + fireTaskReviewRequestedNotification(task, comment, !notifyEnabled); + continue; + } + if (comment.type === 'review_approved') continue; + fireTaskCommentNotification(task, comment, !notifyEnabled); } } @@ -1250,7 +1256,7 @@ function detectTaskCommentNotifications( function fireTaskCommentNotification( task: GlobalTask, - comment: { author: string; text: string; id: string }, + comment: Pick, suppressToast: boolean ): void { // Double-check: never notify about user's own comments @@ -1281,6 +1287,91 @@ function fireTaskCommentNotification( .catch(() => undefined); } +function fireTaskReviewRequestedNotification( + task: GlobalTask, + comment: Pick, + suppressToast: boolean +): void { + const stripped = stripAgentBlocks(comment.text).trim(); + const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped; + + void api.teams + ?.showMessageNotification({ + teamName: task.teamName, + teamDisplayName: task.teamDisplayName, + from: comment.author, + to: 'user', + summary: `Review requested ${formatTaskDisplayLabel(task)}: ${task.subject}`, + body: preview || task.subject, + teamEventType: 'task_review_requested', + dedupeKey: `review-request:${task.teamName}:${task.id}:${comment.id}`, + target: { + kind: 'task', + teamName: task.teamName, + taskId: task.id, + commentId: comment.id, + focus: 'review', + }, + suppressToast, + }) + .catch(() => undefined); +} + +function detectBlockedTaskNotifications( + oldTasks: GlobalTask[], + newTasks: GlobalTask[], + notifyEnabled: boolean +): void { + const oldTaskMap = new Map(oldTasks.map((task) => [`${task.teamName}:${task.id}`, task])); + + for (const task of newTasks) { + const oldTask = oldTaskMap.get(`${task.teamName}:${task.id}`); + const oldBlockedBy = oldTask?.blockedBy?.filter(Boolean) ?? []; + const newBlockedBy = task.blockedBy?.filter(Boolean) ?? []; + const key = `${task.teamName}:${task.id}:${newBlockedBy.join(',')}`; + + if (newBlockedBy.length > 0 && oldBlockedBy.length === 0) { + if (notifiedBlockedTaskKeys.has(key)) continue; + notifiedBlockedTaskKeys.add(key); + fireTaskBlockedNotification(task, newBlockedBy, !notifyEnabled); + } else if (newBlockedBy.length === 0) { + for (const existingKey of Array.from(notifiedBlockedTaskKeys)) { + if (existingKey.startsWith(`${task.teamName}:${task.id}:`)) { + notifiedBlockedTaskKeys.delete(existingKey); + } + } + } + } +} + +function fireTaskBlockedNotification( + task: GlobalTask, + blockedBy: readonly string[], + suppressToast: boolean +): void { + const blockerRefs = blockedBy.map((id) => formatTaskDisplayLabel({ id })).join(', '); + + void api.teams + ?.showMessageNotification({ + teamName: task.teamName, + teamDisplayName: task.teamDisplayName, + from: task.owner ?? 'system', + to: 'user', + summary: `Blocked ${formatTaskDisplayLabel(task)}: ${task.subject}`, + body: blockerRefs ? `Blocked by ${blockerRefs}` : task.subject, + teamEventType: 'task_blocked', + dedupeKey: `blocked:${task.teamName}:${task.id}:${blockedBy.join(',')}`, + target: { + kind: 'task', + teamName: task.teamName, + taskId: task.id, + focus: 'detail', + }, + suppressToast, + }) + .catch(() => undefined); +} + function detectTaskCreatedNotifications( oldTasks: GlobalTask[], newTasks: GlobalTask[], @@ -1488,6 +1579,13 @@ export interface PendingMemberProfileState { focus?: 'profile' | 'messages' | 'logs'; } +type TeamSectionTarget = NonNullable['section']>; + +export interface PendingTeamSectionFocusState { + teamName: string; + section: TeamSectionTarget; +} + /** Per-team launch parameters shown in the header badge. */ export interface TeamLaunchParams { providerId?: TeamProviderId; @@ -1982,6 +2080,9 @@ export interface TeamSlice { focus?: PendingMemberProfileState['focus'] ) => void; closeMemberProfile: () => void; + pendingTeamSectionFocus: PendingTeamSectionFocusState | null; + focusTeamSection: (teamName: string, section: TeamSectionTarget) => void; + clearTeamSectionFocus: () => void; /** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */ pendingReviewRequest: { taskId: string; @@ -2502,9 +2603,16 @@ export const createTeamSlice: StateCreator = (set, kanbanFilterQuery: null, globalTaskDetail: null, pendingMemberProfile: null, - openMemberProfile: (memberName: string, teamName?: string, focus?: PendingMemberProfileState['focus']) => - set({ pendingMemberProfile: { memberName, teamName, focus } }), + pendingTeamSectionFocus: null, + openMemberProfile: ( + memberName: string, + teamName?: string, + focus?: PendingMemberProfileState['focus'] + ) => set({ pendingMemberProfile: { memberName, teamName, focus } }), closeMemberProfile: () => set({ pendingMemberProfile: null }), + focusTeamSection: (teamName: string, section: TeamSectionTarget) => + set({ pendingTeamSectionFocus: { teamName, section } }), + clearTeamSectionFocus: () => set({ pendingTeamSectionFocus: null }), pendingReviewRequest: null, setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }), openGlobalTaskDetail: (teamName: string, taskId: string, commentId?: string) => { @@ -2649,6 +2757,7 @@ export const createTeamSlice: StateCreator = (set, const notifyOnClarifications = get().appConfig?.notifications?.notifyOnClarifications ?? true; detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications); + detectBlockedTaskNotifications(oldTasks, tasks, notifyOnClarifications); detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName); const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true; @@ -2664,6 +2773,11 @@ export const createTeamSlice: StateCreator = (set, if (task.needsClarification === 'user') { notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`); } + if ((task.blockedBy?.length ?? 0) > 0) { + notifiedBlockedTaskKeys.add( + `${task.teamName}:${task.id}:${(task.blockedBy ?? []).join(',')}` + ); + } notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); if (task.reviewState === 'needsFix') { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`); diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index dcc10095..8de0313f 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -21,17 +21,21 @@ import type { TriggerColor } from '@shared/constants/triggerColors'; */ export type TeamEventType = | 'rate_limit' + | 'api_error' | 'lead_inbox' | 'user_inbox' | 'task_clarification' | 'task_status_change' | 'task_comment' + | 'task_review_requested' + | 'task_blocked' | 'task_created' | 'all_tasks_completed' | 'cross_team_message' | 'schedule_completed' | 'schedule_failed' - | 'team_launched'; + | 'team_launched' + | 'team_launch_incomplete'; export type NotificationTarget = | { diff --git a/test/main/services/infrastructure/NotificationManager.team.test.ts b/test/main/services/infrastructure/NotificationManager.team.test.ts index 2bddcc92..4631faa0 100644 --- a/test/main/services/infrastructure/NotificationManager.team.test.ts +++ b/test/main/services/infrastructure/NotificationManager.team.test.ts @@ -67,6 +67,14 @@ vi.mock('@main/utils/textFormatting', () => ({ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { Notification as ElectronNotification } from 'electron'; + +function getLastNotificationOptions(): Record { + const mock = ElectronNotification as unknown as { + mock: { calls: [Record][] }; + }; + return mock.mock.calls.at(-1)?.[0] ?? {}; +} function makeTeamPayload( overrides: Partial = {} @@ -258,4 +266,94 @@ describe('NotificationManager.addTeamNotification', () => { const result = await manager.getNotifications({ limit: 10 }); expect(result.notifications).toHaveLength(0); }); + + it('formats clarification as a reply-needed notification', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'task_clarification', + from: 'jack', + summary: 'Clarification needed - Task #55c51f15', + body: 'Can you confirm the reviewer?', + dedupeKey: 'presentation-reply', + }) + ); + + expect(getLastNotificationOptions().title).toBe('@jack needs your reply on #55c51f15'); + }); + + it('formats review requests as action-needed notifications', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'task_review_requested', + from: 'alice', + summary: 'Review requested #46cceca0: Landing page', + body: 'Please review the implementation.', + dedupeKey: 'presentation-review', + }) + ); + + expect(getLastNotificationOptions().title).toBe('@alice requested review on #46cceca0'); + }); + + it('formats blocked tasks as action-needed notifications', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'task_blocked', + from: 'bob', + summary: 'Blocked #6002830d: API contract', + body: 'Blocked by #11111111', + dedupeKey: 'presentation-blocked', + }) + ); + + expect(getLastNotificationOptions().title).toBe('@bob is blocked on #6002830d'); + }); + + it('formats rate limits with human restart guidance', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'rate_limit', + from: 'tom', + summary: 'Rate limit', + body: 'Auto-resume scheduled at 14:30', + dedupeKey: 'presentation-rate', + }) + ); + + const options = getLastNotificationOptions(); + expect(options.title).toBe('@tom paused: rate limit'); + expect(options.body).toContain('Auto-resume scheduled at 14:30'); + }); + + it('formats API errors with manual restart guidance', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'api_error', + from: 'tom', + summary: 'API Error 500', + body: 'Manual restart needed', + dedupeKey: 'presentation-api', + }) + ); + + const options = getLastNotificationOptions(); + expect(options.title).toBe('@tom paused: API error'); + expect(options.body).toContain('Manual restart needed'); + }); + + it('formats incomplete launches without a System prefix', async () => { + await manager.addTeamNotification( + makeTeamPayload({ + teamEventType: 'team_launch_incomplete', + from: 'system', + summary: 'Team launch incomplete', + body: '3/4 joined · @tom did not join', + dedupeKey: 'presentation-launch-incomplete', + }) + ); + + const options = getLastNotificationOptions(); + expect(options.title).toBe('Team launch incomplete'); + expect(options.body).toContain('3/4 joined · @tom did not join'); + }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index cdd7852a..9d763590 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -651,6 +651,66 @@ describe('TeamProvisioningService', () => { }); }); + describe('provisioning status', () => { + it('retains final progress after cleanupRun removes the live run', async () => { + const svc = new TeamProvisioningService(); + const run = createClaudeLogsRun({ + runId: 'run-retained-progress', + teamName: 'retained-progress-team', + provisioningComplete: false, + progress: { + runId: 'run-retained-progress', + teamName: 'retained-progress-team', + state: 'failed', + message: 'CLI exited quickly', + startedAt: '2026-04-19T10:00:00.000Z', + updatedAt: '2026-04-19T10:00:01.000Z', + error: 'bootstrap failed', + warnings: ['retry is safe'], + }, + }); + + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + (svc as any).cleanupRun(run); + + expect((svc as any).runs.has(run.runId)).toBe(false); + await expect(svc.getProvisioningStatus(run.runId)).resolves.toMatchObject({ + runId: run.runId, + teamName: run.teamName, + state: 'failed', + message: 'CLI exited quickly', + error: 'bootstrap failed', + warnings: ['retry is safe'], + }); + }); + + it('treats result.success as a fallback provisioning completion signal', () => { + const svc = new TeamProvisioningService(); + const run = createClaudeLogsRun({ + runId: 'run-success-fallback', + teamName: 'success-fallback-team', + provisioningComplete: false, + progress: { + runId: 'run-success-fallback', + teamName: 'success-fallback-team', + state: 'configuring', + message: 'Waiting for CLI result', + startedAt: '2026-04-19T10:00:00.000Z', + updatedAt: '2026-04-19T10:00:01.000Z', + }, + }); + const complete = vi + .spyOn(svc as any, 'handleProvisioningTurnComplete') + .mockResolvedValue(undefined); + + (svc as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + expect(complete).toHaveBeenCalledWith(run); + }); + }); + describe('member spawn status launch reads', () => { it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => { const svc = new TeamProvisioningService(); diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts index 0dee4cee..78210d06 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -52,11 +52,11 @@ describe('buildDetectedErrorFromTeam', () => { expect(result.teamEventType).toBe('rate_limit'); }); - it('constructs message from "from" and body', () => { + it('constructs message from "from", summary, and body', () => { const result = buildDetectedErrorFromTeam( makePayload({ from: 'bob', body: 'Something happened' }) ); - expect(result.message).toBe('[bob] Something happened'); + expect(result.message).toBe('[bob] Hello from Alice: Something happened'); }); it('truncates body to 300 chars in message', () => { @@ -88,17 +88,21 @@ describe('buildDetectedErrorFromTeam', () => { const EXPECTED_CONFIG: Record = { rate_limit: { triggerName: 'Rate Limit', triggerColor: 'red' }, + api_error: { triggerName: 'API Error', triggerColor: 'red' }, lead_inbox: { triggerName: 'Team Inbox', triggerColor: 'blue' }, user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' }, task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' }, task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' }, + task_review_requested: { triggerName: 'Review Requested', triggerColor: 'orange' }, + task_blocked: { triggerName: 'Task Blocked', triggerColor: 'red' }, task_created: { triggerName: 'Task Created', triggerColor: 'green' }, all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' }, cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, team_launched: { triggerName: 'Team Launched', triggerColor: 'green' }, + team_launch_incomplete: { triggerName: 'Launch Incomplete', triggerColor: 'orange' }, }; for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) { diff --git a/test/renderer/store/notificationSlice.test.ts b/test/renderer/store/notificationSlice.test.ts index 8d2557e7..2aade950 100644 --- a/test/renderer/store/notificationSlice.test.ts +++ b/test/renderer/store/notificationSlice.test.ts @@ -502,6 +502,74 @@ describe('notificationSlice', () => { expect(tabs).toHaveLength(1); expect(tabs[0].type).toBe('team'); }); + + it('should open global task detail for task notification targets', () => { + const teamError = createMockError({ + sessionId: 'team:delta-team', + source: 'task_comment', + category: 'team' as never, + teamEventType: 'task_comment' as never, + target: { + kind: 'task', + teamName: 'delta-team', + taskId: 'task-123', + commentId: 'comment-456', + focus: 'comments', + }, + }); + + store.getState().navigateToError(teamError); + + expect(store.getState().globalTaskDetail).toEqual({ + teamName: 'delta-team', + taskId: 'task-123', + commentId: 'comment-456', + }); + }); + + it('should open team-scoped member profile for member notification targets', () => { + const teamError = createMockError({ + sessionId: 'team:epsilon-team', + source: 'rate_limit', + category: 'team' as never, + teamEventType: 'rate_limit' as never, + target: { + kind: 'member', + teamName: 'epsilon-team', + memberName: 'tom', + focus: 'logs', + }, + }); + + store.getState().navigateToError(teamError); + + expect(store.getState().pendingMemberProfile).toEqual({ + teamName: 'epsilon-team', + memberName: 'tom', + focus: 'logs', + }); + }); + + it('should focus a team section for team notification targets', () => { + const teamError = createMockError({ + sessionId: 'team:zeta-team', + source: 'team_launch_incomplete', + category: 'team' as never, + teamEventType: 'team_launch_incomplete' as never, + target: { + kind: 'team', + teamName: 'zeta-team', + section: 'members', + }, + }); + + store.getState().navigateToError(teamError); + + expect(store.getState().pendingTeamSectionFocus).toEqual({ + teamName: 'zeta-team', + section: 'members', + }); + }); }); describe('existing tab behavior', () => {