fix(team): retain launch status and clarify notifications

This commit is contained in:
777genius 2026-05-03 13:18:53 +03:00
parent 69a47fda07
commit d0341e58af
13 changed files with 682 additions and 81 deletions

View file

@ -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<string>();
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<typeof planRateLimitAutoResume>): 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,

View file

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

View file

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

View file

@ -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<string, ProvisioningRun>();
private readonly provisioningRunByTeam = new Map<string, string>();
private readonly aliveRunByTeam = new Map<string, string>();
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
private retainedProvisioningProgressByRunId: Map<string, TeamProvisioningProgress> | undefined =
new Map<string, TeamProvisioningProgress>();
private retainedProvisioningProgressTimersByRunId:
| Map<string, ReturnType<typeof setTimeout>>
| undefined = new Map<string, ReturnType<typeof setTimeout>>();
private readonly runtimeAdapterTraceLinesByRunId = new Map<string, string[]>();
private readonly runtimeAdapterTraceKeyByRunId = new Map<string, string>();
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<string, TeamProvisioningProgress> {
this.retainedProvisioningProgressByRunId ??= new Map<string, TeamProvisioningProgress>();
return this.retainedProvisioningProgressByRunId;
}
private getRetainedProvisioningProgressTimersMap(): Map<string, ReturnType<typeof setTimeout>> {
this.retainedProvisioningProgressTimersByRunId ??= new Map<
string,
ReturnType<typeof setTimeout>
>();
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<void> {
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<void> {
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);
}

View file

@ -61,17 +61,21 @@ interface TeamNotificationConfig {
const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> = {
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' },
};
// =============================================================================

View file

@ -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<HTMLElement>(`[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(() => {

View file

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

View file

@ -43,6 +43,7 @@ import type {
MemberActivityMetaEntry,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
NotificationTarget,
PersistedTeamLaunchSummary,
ResolvedTeamMember,
SendMessageRequest,
@ -1063,6 +1064,7 @@ const notifiedStatusChangeKeys = new Set<string>();
const notifiedCommentKeys = new Set<string>();
const notifiedCreatedTaskKeys = new Set<string>();
const notifiedAllCompletedTeams = new Set<string>();
const notifiedBlockedTaskKeys = new Set<string>();
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<TaskComment, 'author' | 'text' | 'id'>,
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<TaskComment, 'author' | 'text' | 'id'>,
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<Extract<NotificationTarget, { kind: 'team' }>['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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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`);

View file

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

View file

@ -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<string, unknown> {
const mock = ElectronNotification as unknown as {
mock: { calls: [Record<string, unknown>][] };
};
return mock.mock.calls.at(-1)?.[0] ?? {};
}
function makeTeamPayload(
overrides: Partial<TeamNotificationPayload> = {}
@ -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');
});
});

View file

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

View file

@ -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<TeamEventType, { triggerName: string; triggerColor: string }> = {
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)) {

View file

@ -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', () => {