fix(team): retain launch status and clarify notifications
This commit is contained in:
parent
69a47fda07
commit
d0341e58af
13 changed files with 682 additions and 81 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue