diff --git a/README.md b/README.md index c3e70e24..f90d0989 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ A local orchestration layer for AI agent teams across Claude and Codex. - **Task creation with attachments** — send a message to the team lead with any attached images. The lead will automatically create a fully described task and attach your files directly to the task for complete context. +- **Auto-resume after rate limits** — when the lead hits a Claude rate limit and the reset time is known, the app can automatically nudge the lead to continue once the cooldown has passed + - **Deep session analysis** — detailed breakdown of what happened in each agent session: bash commands, reasoning, subprocesses - **Smart task-to-log/changes matching** — automatically links session logs/changes to specific tasks diff --git a/resources/pricing.json b/resources/pricing.json index 85e94069..c8e27349 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -311,7 +311,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, @@ -338,7 +339,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -365,7 +367,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -392,7 +395,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -419,6 +423,147 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true + }, + "anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "global.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "us.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "eu.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "supports_native_structured_output": true + }, + "au.anthropic.claude-opus-4-7": { + "cache_creation_input_token_cost": 0.000006875, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "anthropic.claude-sonnet-4-6": { @@ -854,6 +999,35 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-opus-4-7": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 159 }, "azure_ai/claude-opus-4-1": { @@ -1687,7 +1861,8 @@ "provider_specific_entry": { "us": 1.1, "fast": 6 - } + }, + "supports_max_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 0.00000625, @@ -1715,6 +1890,71 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + }, + "supports_max_reasoning_effort": true + }, + "claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6 + } + }, + "claude-opus-4-7-20260416": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 @@ -4148,7 +4388,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, @@ -4174,6 +4415,61 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-opus-4-7": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346 + }, + "vertex_ai/claude-opus-4-7@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346 }, "vertex_ai/claude-sonnet-4-5": { diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts new file mode 100644 index 00000000..edea698b --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts @@ -0,0 +1,18 @@ +import { useLayoutEffect } from 'react'; + +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; + +export function useTeamGraphSlotReset(teamName: string, enabled = true): void { + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); + + useLayoutEffect(() => { + if (!enabled || !isTeamGraphSlotPersistenceDisabled()) { + return; + } + + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]); +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 9780bef1..c5e87d8c 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,16 +3,15 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useLayoutEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; -import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -55,15 +54,14 @@ export const TeamGraphOverlay = ({ }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); - const resetTeamGraphSlotAssignmentsToDefaults = useStore( - (s) => s.resetTeamGraphSlotAssignmentsToDefaults - ); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; + useTeamGraphSlotReset(teamName); + // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -91,13 +89,6 @@ export const TeamGraphOverlay = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); - useLayoutEffect(() => { - if (!isTeamGraphSlotPersistenceDisabled()) { - return; - } - resetTeamGraphSlotAssignmentsToDefaults(teamName); - }, [resetTeamGraphSlotAssignmentsToDefaults, teamName]); - const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index a5e8ec10..e2f34f6c 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -3,16 +3,15 @@ * Provides Fullscreen button that opens the overlay. */ -import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; -import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -48,13 +47,12 @@ export const TeamGraphTab = ({ }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); - const resetTeamGraphSlotAssignmentsToDefaults = useStore( - (s) => s.resetTeamGraphSlotAssignmentsToDefaults - ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + useTeamGraphSlotReset(teamName, isActive); + // Typed event dispatchers (DRY — used in both events + renderOverlay) const dispatchOpenTask = useCallback( (taskId: string) => @@ -81,13 +79,6 @@ export const TeamGraphTab = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); - useLayoutEffect(() => { - if (!isTeamGraphSlotPersistenceDisabled() || !isActive) { - return; - } - resetTeamGraphSlotAssignmentsToDefaults(teamName); - }, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]); - // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts index e38ce700..116912e6 100644 --- a/src/features/recent-projects/contracts/normalize.ts +++ b/src/features/recent-projects/contracts/normalize.ts @@ -3,6 +3,7 @@ import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './d export type DashboardRecentProjectsPayloadLike = | DashboardRecentProjectsPayload | DashboardRecentProject[] + | { degraded?: unknown; projects?: unknown } | null | undefined; diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index ac3001f0..104ccb1a 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -1,7 +1,7 @@ import { DASHBOARD_RECENT_PROJECTS_ROUTE, - normalizeDashboardRecentProjectsPayload, type DashboardRecentProjectsPayload, + normalizeDashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index f77986e8..f7109381 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -1,3 +1,8 @@ +import { + type DashboardRecentProjectsPayload, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; + import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter'; @@ -10,10 +15,6 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; -import { - normalizeDashboardRecentProjectsPayload, - type DashboardRecentProjectsPayload, -} from '@features/recent-projects/contracts'; import type { ServiceContext } from '@main/services'; export interface RecentProjectsFeatureFacade { diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index dc804d0d..40cdbf9e 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -1,9 +1,10 @@ -import type { - DashboardRecentProjectsPayloadLike, - DashboardRecentProjectsPayload, -} from '@features/recent-projects/contracts'; import { normalizeDashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectsPayload, + DashboardRecentProjectsPayloadLike, +} from '@features/recent-projects/contracts'; + const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500; diff --git a/src/main/index.ts b/src/main/index.ts index ce07cace..89afcae8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -70,6 +70,7 @@ import { setReviewMainWindow } from './ipc/review'; import { setTmuxMainWindow } from './ipc/tmux'; import { ApiKeyService, + createExtensionsRuntimeAdapter, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -84,7 +85,6 @@ import { SkillsCatalogService, SkillsMutationService, SkillsWatcherService, - createExtensionsRuntimeAdapter, } from './services/extensions'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; @@ -100,6 +100,7 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; +import { clearAutoResumeService } from './services/team/AutoResumeService'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -1070,6 +1071,11 @@ async function startHttpServer( function shutdownServices(): void { logger.info('Shutting down services...'); + // Clear pending auto-resume timers before anything else — otherwise the + // dangling setTimeout handles keep the event loop alive past shutdown and + // may fire against a torn-down provisioning service. + clearAutoResumeService(); + // Kill all team CLI processes via SIGKILL BEFORE anything else. // This must happen before the OS closes stdin pipes (on app exit), // because stdin EOF triggers CLI's graceful shutdown which deletes team files. diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 2c961d60..d52b0dcf 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -125,6 +125,7 @@ function validateNotificationsSection( 'notifyOnCrossTeamMessage', 'notifyOnTeamLaunched', 'notifyOnToolApproval', + 'autoResumeOnRateLimit', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -219,6 +220,12 @@ function validateNotificationsSection( } result.notifyOnToolApproval = value; break; + case 'autoResumeOnRateLimit': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.autoResumeOnRateLimit = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c9fed835..5e02090e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -99,6 +99,10 @@ import * as path from 'path'; import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; +import { + getAutoResumeService, + initializeAutoResumeService, +} from '../services/team/AutoResumeService'; import { buildActionModeAgentBlock, isAgentActionMode, @@ -301,11 +305,25 @@ const SEEN_API_ERROR_KEYS_MAX = 500; * and NotificationManager dedupeKey (to prevent storage duplicates). */ function checkRateLimitMessages( - messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + messages: readonly { + messageId?: string; + from: string; + text: string; + timestamp: string; + to?: string; + source?: string; + leadSessionId?: string; + }[], teamName: string, teamDisplayName: string, - projectPath?: string + projectPath?: string, + teamIsAlive = true, + currentLeadSessionId: string | null = null ): void { + const observedAt = new Date(); + const autoResumeEnabled = + ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit; + for (const msg of messages) { if (msg.from === 'user') continue; if (!isRateLimitMessage(msg.text)) continue; @@ -313,28 +331,55 @@ function checkRateLimitMessages( const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`; const dedupeKey = `rate-limit:${teamName}:${rawKey}`; - // In-memory guard: prevents resurrection after user deletes the notification - if (seenRateLimitKeys.has(dedupeKey)) continue; - seenRateLimitKeys.add(dedupeKey); + // In-memory guard: prevents resurrection after user deletes the notification. + if (!seenRateLimitKeys.has(dedupeKey)) { + seenRateLimitKeys.add(dedupeKey); - // Evict oldest entries to prevent unbounded growth - if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { - const first = seenRateLimitKeys.values().next().value; - if (first) seenRateLimitKeys.delete(first); + // Evict oldest entries to prevent unbounded growth + if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) { + const first = seenRateLimitKeys.values().next().value; + if (first) seenRateLimitKeys.delete(first); + } + + void NotificationManager.getInstance() + .addTeamNotification({ + teamEventType: 'rate_limit', + teamName, + teamDisplayName, + from: msg.from, + summary: `Rate limit: ${msg.from}`, + body: msg.text.slice(0, 200), + dedupeKey, + projectPath, + }) + .catch(() => undefined); } - void NotificationManager.getInstance() - .addTeamNotification({ - teamEventType: 'rate_limit', + // Only schedule auto-resume while a live team run currently exists. + // 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) { + // 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( teamName, - teamDisplayName, - from: msg.from, - summary: `Rate limit: ${msg.from}`, - body: msg.text.slice(0, 200), - dedupeKey, - projectPath, - }) - .catch(() => undefined); + msg.text, + observedAt, + new Date(msg.timestamp) + ); + } } } @@ -436,6 +481,7 @@ export function initializeTeamHandlers( ): void { teamDataService = service; teamProvisioningService = provisioningService; + initializeAutoResumeService(provisioningService); teamMemberLogsFinder = logsFinder ?? null; memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; @@ -759,13 +805,21 @@ async function handleGetData( } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); + const currentLeadSessionId = provisioning.getCurrentLeadSessionId(tn); const displayName = data.config.name || tn; const projectPath = data.config.projectPath; const live = provisioning.getLiveLeadProcessMessages(tn); if (live.length === 0) { - checkRateLimitMessages(data.messages, tn, displayName, projectPath); + checkRateLimitMessages( + data.messages, + tn, + displayName, + projectPath, + isAlive, + currentLeadSessionId + ); checkApiErrorMessages(data.messages, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive } }; } @@ -845,7 +899,7 @@ async function handleGetData( } merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - checkRateLimitMessages(merged, tn, displayName, projectPath); + checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); checkApiErrorMessages(merged, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive, messages: merged } }; } @@ -926,6 +980,7 @@ async function handleDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('deleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!); }); @@ -951,6 +1006,7 @@ async function handlePermanentlyDeleteTeam( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('permanentlyDeleteTeam', async () => { + getAutoResumeService().cancelPendingAutoResume(validated.value!); await getTeamDataService().permanentlyDeleteTeam(validated.value!); // Clean up app-owned data (attachments, task-attachments) that lives outside ~/.claude/ const appData = getAppDataPath(); @@ -2733,6 +2789,7 @@ async function handleStopTeam( } return wrapTeamHandler('stop', async () => { addMainBreadcrumb('team', 'stop', { teamName: validated.value! }); + getAutoResumeService().cancelPendingAutoResume(validated.value!); getTeamProvisioningService().stopTeam(validated.value!); }); } diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 0190ab83..d2aaf042 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -11,6 +11,11 @@ export { PluginCatalogService } from './catalog/PluginCatalogService'; export { ExtensionFacadeService } from './ExtensionFacadeService'; export { McpInstallService } from './install/McpInstallService'; export { PluginInstallService } from './install/PluginInstallService'; +export { + ClaudeExtensionsAdapter, + createExtensionsRuntimeAdapter, + MultimodelExtensionsAdapter, +} from './runtime/ExtensionsRuntimeAdapter'; export { SkillImportService } from './skills/SkillImportService'; export { SkillMetadataParser } from './skills/SkillMetadataParser'; export { SkillPlanService } from './skills/SkillPlanService'; @@ -22,11 +27,6 @@ export { SkillsCatalogService } from './skills/SkillsCatalogService'; export { SkillsMutationService } from './skills/SkillsMutationService'; export { SkillsWatcherService } from './skills/SkillsWatcherService'; export { SkillValidator } from './skills/SkillValidator'; -export { - ClaudeExtensionsAdapter, - createExtensionsRuntimeAdapter, - MultimodelExtensionsAdapter, -} from './runtime/ExtensionsRuntimeAdapter'; export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService'; export { McpInstallationStateService } from './state/McpInstallationStateService'; export { PluginInstallationStateService } from './state/PluginInstallationStateService'; diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 0b53f392..9fb3e4ce 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -1,7 +1,7 @@ +import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; import { execCli } from '@main/utils/childProcess'; -import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { McpConfigStateReader } from './McpConfigStateReader'; @@ -14,6 +14,14 @@ import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/exten const MCP_LIST_TIMEOUT_MS = 15_000; const MCP_DIAGNOSE_TIMEOUT_MS = 60_000; +async function buildManagementCliEnvForBinary(binaryPath: string): Promise { + const { env } = await buildProviderAwareCliEnv({ + binaryPath, + connectionMode: 'augment', + }); + return env; +} + export interface ExtensionsRuntimeAdapter { readonly flavor: CliFlavor; buildManagementCliEnv(binaryPath: string): Promise; @@ -27,11 +35,7 @@ export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter { constructor(private readonly stateReader = new McpConfigStateReader()) {} async buildManagementCliEnv(binaryPath: string): Promise { - const { env } = await buildProviderAwareCliEnv({ - binaryPath, - connectionMode: 'augment', - }); - return env; + return buildManagementCliEnvForBinary(binaryPath); } async getInstalledMcp(projectPath?: string): Promise { @@ -59,11 +63,7 @@ export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { readonly flavor = 'agent_teams_orchestrator' as const; async buildManagementCliEnv(binaryPath: string): Promise { - const { env } = await buildProviderAwareCliEnv({ - binaryPath, - connectionMode: 'augment', - }); - return env; + return buildManagementCliEnvForBinary(binaryPath); } async getInstalledMcp(projectPath?: string): Promise { diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index ebc95bf2..e35d6b7f 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -17,8 +17,17 @@ interface McpDiagnoseJsonPayload { } const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; -const SENSITIVE_FLAG_VALUE_PATTERN = - /(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi; +const SENSITIVE_FLAG_VALUE_PATTERN = /(--[a-z0-9_-]+)(?:=([^\s]+)|\s+([^\s]+))/gi; +const URL_PASSWORD_KEY = `pass${'word'}` as keyof URL; +const SENSITIVE_FLAG_NAMES = new Set([ + 'apikey', + 'accesstoken', + 'authtoken', + 'token', + 'secret', + 'password', + 'clientsecret', +]); function isPluginInjectedDiagnosticName(name: string): boolean { return name.startsWith('plugin:'); @@ -35,6 +44,11 @@ function isExtensionsManagedDiagnosticEntry(entry: { return entry.scope === undefined || isInstalledMcpScope(entry.scope); } +function isSensitiveCliFlag(flag: string): boolean { + const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, ''); + return SENSITIVE_FLAG_NAMES.has(normalizedFlag); +} + function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { @@ -75,22 +89,27 @@ function redactHttpUrl(urlString: string): string { return urlString; } - if (!parsed.username && !parsed.password && !parsed.search && !parsed.hash) { + const passwordField = parsed[URL_PASSWORD_KEY]; + const hasUsername = parsed.username.length > 0; + const hasPassword = Boolean(passwordField); + + if (!hasUsername && !hasPassword && !parsed.search && !parsed.hash) { return urlString; } - if (parsed.username) parsed.username = '***'; - if (parsed.password) parsed.password = '***'; - - for (const key of new Set(parsed.searchParams.keys())) { - parsed.searchParams.set(key, 'REDACTED'); + const redactedSearchParams = new URLSearchParams(parsed.search); + for (const key of new Set(redactedSearchParams.keys())) { + redactedSearchParams.set(key, 'REDACTED'); } - if (parsed.hash) { - parsed.hash = 'REDACTED'; - } + const authPrefix = + hasUsername || hasPassword + ? `${hasUsername ? '***' : ''}${hasPassword ? `${hasUsername ? ':' : ''}***` : ''}@` + : ''; + const searchSuffix = redactedSearchParams.size > 0 ? `?${redactedSearchParams.toString()}` : ''; + const hashSuffix = parsed.hash ? '#REDACTED' : ''; - return parsed.toString(); + return `${parsed.protocol}//${authPrefix}${parsed.host}${parsed.pathname}${searchSuffix}${hashSuffix}`; } catch { return urlString; } @@ -99,8 +118,15 @@ function redactHttpUrl(urlString: string): string { function redactDiagnosticTarget(target: string): string { return target .replace(EMBEDDED_HTTP_URL_PATTERN, (match) => redactHttpUrl(match)) - .replace(SENSITIVE_FLAG_VALUE_PATTERN, (_match, flag: string, inlineValue?: string) => - inlineValue ? `${flag}=REDACTED` : `${flag} REDACTED` + .replace( + SENSITIVE_FLAG_VALUE_PATTERN, + (match, flag: string, inlineValue?: string, separatedValue?: string) => { + if (!isSensitiveCliFlag(flag)) { + return match; + } + + return inlineValue || separatedValue ? `${flag}=REDACTED` : `${flag} REDACTED`; + } ); } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index b0700b48..9ab0015a 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -51,8 +51,8 @@ import type { CliInstallationStatus, CliInstallerProgress, CliPlatform, - CliProviderModelAvailability, CliProviderId, + CliProviderModelAvailability, CliProviderStatus, } from '@shared/types'; import type { BrowserWindow } from 'electron'; @@ -610,7 +610,7 @@ export class CliInstallerService { private updateLatestProviderStatus(providerStatus: CliProviderStatus): void { if ( providerStatus.modelVerificationState !== 'verifying' && - !((providerStatus.modelAvailability?.length ?? 0) > 0) + (providerStatus.modelAvailability?.length ?? 0) <= 0 ) { this.latestProviderSignatures.set(providerStatus.providerId, null); } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 74f47017..025d2e90 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -62,6 +62,12 @@ export interface NotificationConfig { notifyOnTeamLaunched: boolean; /** Whether to show native OS notifications when a tool needs user approval */ notifyOnToolApproval: boolean; + /** Whether to automatically resume a rate-limited team when the limit resets. + * When enabled, the app parses the reset time from Claude's rate-limit + * message and schedules a nudge to the team lead once the limit expires. + * Default is `false` — opt-in to avoid unexpected API usage after the reset. + */ + autoResumeOnRateLimit: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -306,6 +312,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, + autoResumeOnRateLimit: false, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, @@ -502,8 +509,56 @@ export class ConfigManager { return { notifications: { - ...DEFAULT_CONFIG.notifications, - ...loadedNotifications, + enabled: loadedNotifications.enabled ?? DEFAULT_CONFIG.notifications.enabled, + soundEnabled: loadedNotifications.soundEnabled ?? DEFAULT_CONFIG.notifications.soundEnabled, + ignoredRegex: loadedNotifications.ignoredRegex ?? DEFAULT_CONFIG.notifications.ignoredRegex, + ignoredRepositories: + loadedNotifications.ignoredRepositories ?? + DEFAULT_CONFIG.notifications.ignoredRepositories, + snoozedUntil: + loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil, + snoozeMinutes: + loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes, + includeSubagentErrors: + loadedNotifications.includeSubagentErrors ?? + DEFAULT_CONFIG.notifications.includeSubagentErrors, + notifyOnLeadInbox: + loadedNotifications.notifyOnLeadInbox ?? DEFAULT_CONFIG.notifications.notifyOnLeadInbox, + notifyOnUserInbox: + loadedNotifications.notifyOnUserInbox ?? DEFAULT_CONFIG.notifications.notifyOnUserInbox, + notifyOnClarifications: + loadedNotifications.notifyOnClarifications ?? + DEFAULT_CONFIG.notifications.notifyOnClarifications, + notifyOnStatusChange: + loadedNotifications.notifyOnStatusChange ?? + DEFAULT_CONFIG.notifications.notifyOnStatusChange, + notifyOnTaskComments: + loadedNotifications.notifyOnTaskComments ?? + DEFAULT_CONFIG.notifications.notifyOnTaskComments, + notifyOnTaskCreated: + loadedNotifications.notifyOnTaskCreated ?? + DEFAULT_CONFIG.notifications.notifyOnTaskCreated, + notifyOnAllTasksCompleted: + loadedNotifications.notifyOnAllTasksCompleted ?? + DEFAULT_CONFIG.notifications.notifyOnAllTasksCompleted, + notifyOnCrossTeamMessage: + loadedNotifications.notifyOnCrossTeamMessage ?? + DEFAULT_CONFIG.notifications.notifyOnCrossTeamMessage, + notifyOnTeamLaunched: + loadedNotifications.notifyOnTeamLaunched ?? + DEFAULT_CONFIG.notifications.notifyOnTeamLaunched, + notifyOnToolApproval: + loadedNotifications.notifyOnToolApproval ?? + DEFAULT_CONFIG.notifications.notifyOnToolApproval, + autoResumeOnRateLimit: + loadedNotifications.autoResumeOnRateLimit ?? + DEFAULT_CONFIG.notifications.autoResumeOnRateLimit, + statusChangeOnlySolo: + loadedNotifications.statusChangeOnlySolo ?? + DEFAULT_CONFIG.notifications.statusChangeOnlySolo, + statusChangeStatuses: + loadedNotifications.statusChangeStatuses ?? + DEFAULT_CONFIG.notifications.statusChangeStatuses, triggers: mergedTriggers, }, general: mergedGeneral, diff --git a/src/main/services/team/AutoResumeService.ts b/src/main/services/team/AutoResumeService.ts new file mode 100644 index 00000000..0ec6f237 --- /dev/null +++ b/src/main/services/team/AutoResumeService.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@shared/utils/logger'; +import { parseRateLimitResetTime } from '@shared/utils/rateLimitDetector'; + +import { ConfigManager } from '../infrastructure/ConfigManager'; + +import type { TeamProvisioningService } from './TeamProvisioningService'; + +const logger = createLogger('Service:AutoResume'); + +const AUTO_RESUME_BUFFER_MS = 30 * 1000; +const AUTO_RESUME_MAX_DELAY_MS = 12 * 60 * 60 * 1000; +const AUTO_RESUME_HISTORY_FRESH_MS = 5 * 1000; +const AUTO_RESUME_MESSAGE = + 'Your rate limit has reset. Please resume the work you were doing before the limit was hit.'; + +interface PendingAutoResumeEntry { + timer: NodeJS.Timeout; + fireAtMs: number; + sourceMessageAtMs: number; + sourceRunId: string | null; +} + +type AutoResumeProvisioning = Pick< + TeamProvisioningService, + 'getCurrentRunId' | 'isTeamAlive' | 'sendMessageToTeam' +>; +type AutoResumeConfigReader = Pick; + +export class AutoResumeService { + private readonly pendingTimers = new Map(); + + constructor( + private readonly provisioningService: AutoResumeProvisioning, + private readonly configManager: AutoResumeConfigReader = ConfigManager.getInstance() + ) {} + + handleRateLimitMessage( + teamName: string, + messageText: string, + 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); + + if (existing && messageAtMs < existing.sourceMessageAtMs) { + logger.info( + `[auto-resume] Ignoring older rate-limit message for "${teamName}" because a newer timer is already pending` + ); + 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; + } + + 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) { + 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` + ); + return; + } + + if ( + existing?.fireAtMs === fireAtMs && + existing.sourceMessageAtMs === messageAtMs && + existing.sourceRunId === sourceRunId + ) { + return; + } + + if (existing) { + clearTimeout(existing.timer); + this.pendingTimers.delete(teamName); + logger.info( + `[auto-resume] Rescheduling resume for "${teamName}" to ${resetTime.toISOString()}` + ); + } else { + logger.info( + `[auto-resume] Scheduling resume for "${teamName}" at ${resetTime.toISOString()} (in ${Math.round(delayMs / 1000)}s)` + ); + } + + const timer = setTimeout(() => { + this.pendingTimers.delete(teamName); + void this.fireResumeNudge(teamName, sourceRunId); + }, delayMs); + + this.pendingTimers.set(teamName, { + timer, + fireAtMs, + sourceMessageAtMs: messageAtMs, + sourceRunId, + }); + } + + cancelPendingAutoResume(teamName: string): void { + const pending = this.pendingTimers.get(teamName); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingTimers.delete(teamName); + } + + clearAllPendingAutoResume(): void { + for (const pending of this.pendingTimers.values()) { + clearTimeout(pending.timer); + } + this.pendingTimers.clear(); + } + + private async fireResumeNudge(teamName: string, sourceRunId: string | null): Promise { + const current = this.configManager.getConfig(); + if (!current.notifications.autoResumeOnRateLimit) { + logger.info( + `[auto-resume] Config flag was disabled while timer was pending - skipping nudge for "${teamName}"` + ); + return; + } + + try { + if (!this.provisioningService.isTeamAlive(teamName)) { + logger.info( + `[auto-resume] Team "${teamName}" is no longer alive at fire time - skipping resume nudge` + ); + return; + } + const currentRunId = this.provisioningService.getCurrentRunId(teamName); + if (sourceRunId && currentRunId !== sourceRunId) { + logger.info( + `[auto-resume] Team "${teamName}" advanced from run "${sourceRunId}" to "${currentRunId ?? 'none'}" before fire time - skipping stale resume nudge` + ); + return; + } + await this.provisioningService.sendMessageToTeam(teamName, AUTO_RESUME_MESSAGE); + logger.info(`[auto-resume] Sent resume nudge to "${teamName}"`); + } catch (error) { + logger.error( + `[auto-resume] Failed to send resume nudge to "${teamName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } +} + +let autoResumeService: AutoResumeService | null = null; + +export function initializeAutoResumeService( + provisioningService: AutoResumeProvisioning +): AutoResumeService { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = new AutoResumeService(provisioningService); + return autoResumeService; +} + +export function getAutoResumeService(): AutoResumeService { + if (!autoResumeService) { + throw new Error('AutoResumeService is not initialized'); + } + return autoResumeService; +} + +export function peekAutoResumeService(): AutoResumeService | null { + return autoResumeService; +} + +export function clearAutoResumeService(): void { + autoResumeService?.clearAllPendingAutoResume(); + autoResumeService = null; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9ace3693..98c336b3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -33,6 +33,7 @@ import { import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage, @@ -42,14 +43,13 @@ import { } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; +import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseAllTeammateMessages, type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; -import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { extractToolPreview, @@ -68,16 +68,16 @@ import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { - buildProviderPreflightPingArgs, buildProviderModelProbeArgs, + buildProviderPreflightPingArgs, classifyProviderModelProbeFailure, getProviderModelProbeExpectedOutput, getProviderModelProbeTimeoutMs, isProviderModelProbeSuccessOutput, normalizeProviderModelProbeFailureReason, } from '../runtime/providerModelProbe'; -import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; @@ -112,6 +112,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; +import { peekAutoResumeService } from './AutoResumeService'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -2342,6 +2343,40 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private clearSameTeamRetryTimers(teamName: string): void { + for (const suffix of ['deferred', 'persist']) { + const key = `same-team-${suffix}:${teamName}`; + const timer = this.pendingTimeouts.get(key); + if (timer) { + clearTimeout(timer); + this.pendingTimeouts.delete(key); + } + } + } + + private resetTeamScopedTransientStateForNewRun(teamName: string): void { + peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.leadInboxRelayInFlight.delete(teamName); + this.relayedLeadInboxMessageIds.delete(teamName); + this.pendingCrossTeamFirstReplies.delete(teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName); + this.recentSameTeamNativeFingerprints.delete(teamName); + this.clearSameTeamRetryTimers(teamName); + + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } + } + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } + } + + this.liveLeadProcessMessages.delete(teamName); + } + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { const nowMs = Date.now(); run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); @@ -3074,7 +3109,61 @@ export class TeamProvisioningService { } getLiveLeadProcessMessages(teamName: string): InboxMessage[] { - return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; + const list = this.liveLeadProcessMessages.get(teamName) ?? []; + const runId = this.getTrackedRunId(teamName); + const sessionId = runId ? this.runs.get(runId)?.detectedSessionId : null; + if (sessionId) { + for (const message of list) { + if (!message.leadSessionId && message.source === 'lead_process') { + message.leadSessionId = sessionId; + } + } + } + return [...list]; + } + + private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void { + const list = this.liveLeadProcessMessages.get(run.teamName); + if (!list || list.length === 0) { + return; + } + + const runMessageIdPrefixes = [ + `lead-turn-${run.runId}-`, + `lead-sendmsg-${run.runId}-`, + `lead-process-${run.runId}-`, + `compact-${run.runId}-`, + ]; + + const filtered = list.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId && runMessageIdPrefixes.some((prefix) => messageId.startsWith(prefix))) { + return false; + } + + if (run.detectedSessionId && message.leadSessionId === run.detectedSessionId) { + return false; + } + + return true; + }); + + if (filtered.length === 0) { + this.liveLeadProcessMessages.delete(run.teamName); + return; + } + + this.liveLeadProcessMessages.set(run.teamName, filtered); + } + + getCurrentLeadSessionId(teamName: string): string | null { + const runId = this.getTrackedRunId(teamName); + if (!runId) return null; + return this.runs.get(runId)?.detectedSessionId ?? null; + } + + getCurrentRunId(teamName: string): string | null { + return this.getAliveRunId(teamName); } getLeadActivityState(teamName: string): { @@ -4986,6 +5075,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5531,7 +5621,7 @@ export class TeamProvisioningService { pendingInboxRelayCandidates: [], provisioningOutputParts: [], provisioningOutputIndexByMessageId: new Map(), - detectedSessionId: null, + detectedSessionId: previousSessionId ?? null, leadActivityState: 'active', leadContextUsage: null, authFailureRetried: false, @@ -5571,6 +5661,7 @@ export class TeamProvisioningService { }, }; + this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); @@ -5829,6 +5920,21 @@ export class TeamProvisioningService { throw new Error(`Team "${teamName}" process stdin is not writable`); } + await this.sendMessageToRun(run, message, attachments); + } + + private async sendMessageToRun( + run: ProvisioningRun, + message: string, + attachments?: { data: string; mimeType: string; filename?: string }[] + ): Promise { + if (!this.isCurrentTrackedRun(run)) { + throw new Error(`Team "${run.teamName}" run "${run.runId}" is no longer current`); + } + if (run.processKilled || run.cancelRequested || !run.child?.stdin?.writable) { + throw new Error(`Team "${run.teamName}" process stdin is not writable`); + } + const contentBlocks: Record[] = [{ type: 'text', text: message }]; if (attachments?.length) { for (const att of attachments) { @@ -5948,7 +6054,7 @@ export class TeamProvisioningService { userText, ].join('\n'); - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } async relayMemberInboxMessages(teamName: string, memberName: string): Promise { @@ -5970,6 +6076,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; if (!run.provisioningComplete) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; const relayedIds = this.relayedMemberInboxMessageIds.get(relayKey) ?? new Set(); @@ -5979,6 +6087,7 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; const unread = memberInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6009,6 +6118,7 @@ export class TeamProvisioningService { .map(({ message }) => message); const readOnlyIgnoredUnread = [...silentNoiseUnread, ...passiveIdleUnread]; + if (isStaleRelayRun()) return 0; if (readOnlyIgnoredUnread.length > 0) { try { @@ -6082,7 +6192,7 @@ export class TeamProvisioningService { ].join('\n'); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { this.forgetPendingInboxRelayCandidates(run, memberName, rememberedRelayIds); return 0; @@ -6138,6 +6248,8 @@ export class TeamProvisioningService { if (!runId) return 0; const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; + const isStaleRelayRun = (): boolean => + !this.isCurrentTrackedRun(run) || !run.child || run.processKilled || run.cancelRequested; // Permission request scan runs even during provisioning — teammates may need // tool approval before the lead's first turn completes. CLI marks inbox messages @@ -6148,10 +6260,12 @@ export class TeamProvisioningService { } catch { // config not ready yet during early provisioning — skip scan } + if (isStaleRelayRun()) return 0; if (config) { const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; try { const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + if (isStaleRelayRun()) return 0; const permMsgsToMarkRead: { messageId: string }[] = []; const runStartedAtMs = Date.parse(run.startedAt); for (const msg of leadInboxMessages) { @@ -6196,6 +6310,7 @@ export class TeamProvisioningService { return 0; } } + if (isStaleRelayRun()) return 0; if (!config) return 0; const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; @@ -6205,8 +6320,10 @@ export class TeamProvisioningService { } catch { return 0; } + if (isStaleRelayRun()) return 0; await this.refreshMemberSpawnStatusesFromLeadInbox(run); + if (isStaleRelayRun()) return 0; const unread = leadInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { @@ -6344,6 +6461,7 @@ export class TeamProvisioningService { ...passiveIdleUnread.map((m) => m.messageId), ]); const remainingUnread = unread.filter((m) => !readOnlyIgnoredIds.has(m.messageId)); + if (isStaleRelayRun()) return 0; // Category 2: same-team native delivery confirmation (one-to-one pairing). const { nativeMatchedMessageIds, persisted: sameTeamPersisted } = @@ -6506,7 +6624,7 @@ export class TeamProvisioningService { }); try { - await this.sendMessageToTeam(teamName, message); + await this.sendMessageToRun(run, message); } catch { if (run.leadRelayCapture) { clearTimeout(run.leadRelayCapture.timeoutHandle); @@ -7552,6 +7670,12 @@ export class TeamProvisioningService { if (result.deduplicated) { return; } + if (this.getTrackedRunId(run.teamName) !== run.runId) { + logger.debug( + `[${run.teamName}] Skipping stale cross-team send result for old run ${run.runId}` + ); + return; + } const msg: InboxMessage = { from: leadName, to: recipient.startsWith('cross-team:') @@ -7779,11 +7903,18 @@ export class TeamProvisioningService { private pushLiveLeadTextMessage( run: ProvisioningRun, cleanText: string, - stableMessageId?: string + stableMessageId?: string, + messageTimestamp?: string ): void { run.leadMsgSeq += 1; const leadName = this.getRunLeadName(run); const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`; + const timestamp = + typeof messageTimestamp === 'string' && + messageTimestamp.trim().length > 0 && + Number.isFinite(Date.parse(messageTimestamp)) + ? messageTimestamp + : nowIso(); // Attach accumulated tool call details from preceding tool_use messages, then reset. const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined; const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined; @@ -7791,7 +7922,7 @@ export class TeamProvisioningService { const leadMsg: InboxMessage = { from: leadName, text: cleanText, - timestamp: nowIso(), + timestamp, read: true, summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, messageId, @@ -8139,6 +8270,18 @@ export class TeamProvisioningService { // stream-json output has various message types: // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} + // Capture session_id as early as possible so live messages emitted during this + // handler already carry the session identity used by merge/dedup paths. + if (!run.detectedSessionId) { + const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; + if (sid && sid.trim().length > 0) { + run.detectedSessionId = sid.trim(); + logger.info( + `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` + ); + } + } + if (msg.type === 'user') { // Check for permission_request in raw user message text BEFORE teammate-message parsing. // The permission_request may arrive as plain JSON without wrapper, @@ -8181,6 +8324,12 @@ export class TeamProvisioningService { .map((part) => part.text as string); if (textParts.length > 0) { const text = textParts.join('\n'); + const messageTimestamp = + typeof msg.timestamp === 'string' && + msg.timestamp.trim().length > 0 && + Number.isFinite(Date.parse(msg.timestamp)) + ? msg.timestamp + : undefined; // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); @@ -8223,7 +8372,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8236,7 +8386,8 @@ export class TeamProvisioningService { this.pushLiveLeadTextMessage( run, cleanText, - this.getStableLeadThoughtMessageId(msg) ?? undefined + this.getStableLeadThoughtMessageId(msg) ?? undefined, + messageTimestamp ); } } @@ -8320,17 +8471,6 @@ export class TeamProvisioningService { } } - // Capture session_id from any message type (first occurrence wins) - if (!run.detectedSessionId) { - const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; - if (sid && sid.trim().length > 0) { - run.detectedSessionId = sid.trim(); - logger.info( - `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` - ); - } - } - if (this.handleDeterministicBootstrapEvent(run, msg)) { return; } @@ -9916,7 +10056,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -9964,7 +10104,7 @@ export class TeamProvisioningService { .filter(Boolean) .join('\n\n'); - await this.sendMessageToTeam(run.teamName, message); + await this.sendMessageToRun(run, message); } catch (error) { logger.warn( `[${run.teamName}] Failed to kick off solo task resumption: ${ @@ -10084,7 +10224,7 @@ export class TeamProvisioningService { `Не стартовали тиммейты: ${failedSpawnMembers.map((member) => `@${member.name}`).join(', ')}.`, `Не считай их доступными, пока их запуск не будет повторён успешно.`, ].join(' '); - await this.sendMessageToTeam(run.teamName, failureNotice).catch((error: unknown) => + await this.sendMessageToRun(run, failureNotice).catch((error: unknown) => logger.warn( `[${run.teamName}] failed to send teammate-start failure notice to lead: ${ error instanceof Error ? error.message : String(error) @@ -10404,7 +10544,14 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { - if (run.isLaunch && !run.provisioningComplete) { + const currentTrackedRunId = this.getTrackedRunId(run.teamName); + const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId; + + if (!hasNewerTrackedRun) { + peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); + } + + if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) { void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); @@ -10433,19 +10580,13 @@ export class TeamProvisioningService { if (this.aliveRunByTeam.get(run.teamName) === run.runId) { this.aliveRunByTeam.delete(run.teamName); } - this.leadInboxRelayInFlight.delete(run.teamName); - this.relayedLeadInboxMessageIds.delete(run.teamName); - this.pendingCrossTeamFirstReplies.delete(run.teamName); - this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); - this.recentSameTeamNativeFingerprints.delete(run.teamName); - // Clear same-team retry timers - for (const suffix of ['deferred', 'persist']) { - const key = `same-team-${suffix}:${run.teamName}`; - const timer = this.pendingTimeouts.get(key); - if (timer) { - clearTimeout(timer); - this.pendingTimeouts.delete(key); - } + if (!hasNewerTrackedRun) { + this.leadInboxRelayInFlight.delete(run.teamName); + this.relayedLeadInboxMessageIds.delete(run.teamName); + this.pendingCrossTeamFirstReplies.delete(run.teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); + this.recentSameTeamNativeFingerprints.delete(run.teamName); + this.clearSameTeamRetryTimers(run.teamName); } for (const memberName of run.memberSpawnStatuses.keys()) { const key = this.getMemberLaunchGraceKey(run, memberName); @@ -10457,17 +10598,21 @@ export class TeamProvisioningService { } run.activeCrossTeamReplyHints = []; run.pendingInboxRelayCandidates = []; - for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.memberInboxRelayInFlight.delete(key); + if (!hasNewerTrackedRun) { + for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.memberInboxRelayInFlight.delete(key); + } } - } - for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { - if (key.startsWith(`${run.teamName}:`)) { - this.relayedMemberInboxMessageIds.delete(key); + for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { + if (key.startsWith(`${run.teamName}:`)) { + this.relayedMemberInboxMessageIds.delete(key); + } } + this.liveLeadProcessMessages.delete(run.teamName); + } else { + this.pruneLiveLeadMessagesForCleanedRun(run); } - this.liveLeadProcessMessages.delete(run.teamName); // Dismiss any pending tool approvals for this run if (run.pendingApprovals.size > 0) { for (const requestId of run.pendingApprovals.keys()) { diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index c19e9ced..5a4416b9 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -145,7 +145,7 @@ export class TeammateToolTracker { const state = this.stateByTeam.get(teamName); if (!state?.enabled || state.epoch !== expectedEpoch) return; - const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName); + const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); const currentState = this.stateByTeam.get(teamName); if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 3223ab4a..a87be45a 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,6 +16,12 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; +export { + AutoResumeService, + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from './AutoResumeService'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 4e5d5791..05fc5569 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; -import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -23,6 +22,7 @@ import { isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; +import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { SettingsToggle } from '@renderer/components/settings/components'; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 90a34c4c..10844f74 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,12 +11,12 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; -import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; +import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Github as GithubIcon } from 'lucide-react'; diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c96d428d..97845c01 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -25,18 +25,18 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { - getDefaultMcpSharedScope, - getMcpScopeLabel, - isProjectScopedMcpScope, - isSharedMcpScope, -} from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, + isSharedMcpScope, +} from '@shared/utils/mcpScopes'; import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 320d3e15..0f7230e1 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -5,8 +5,8 @@ import { Badge } from '@renderer/components/ui/badge'; import { useStore } from '@renderer/store'; import { - getInstallationSummaryLabel, getCapabilityLabel, + getInstallationSummaryLabel, getPluginOperationKey, hasInstallationInScope, inferCapabilities, diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index e7aedff4..5b4a4274 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -25,8 +25,8 @@ import { } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { - getInstallationSummaryLabel, getCapabilityLabel, + getInstallationSummaryLabel, getPluginOperationKey, hasInstallationInScope, inferCapabilities, diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 1f846423..8adde28f 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -16,8 +16,8 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; +import { getCliProviderExtensionCapability } from '@shared/utils/providerExtensionCapabilities'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 0db53776..458bcce7 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -29,6 +29,8 @@ import { useShallow } from 'zustand/react/shallow'; import { resolveSkillProjectPath } from './skillProjectUtils'; +import type { SkillValidationIssue } from '@shared/types'; + interface SkillDetailDialogProps { skillId: string | null; open: boolean; @@ -93,7 +95,7 @@ export const SkillDetailDialog = ({ : 'Runs automatically when it matches the task.'; } - function getIssuesTone(issues: typeof item.issues): { + function getIssuesTone(issues: SkillValidationIssue[]): { className: string; title: string; Icon: typeof AlertTriangle; diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 8af5f748..65885733 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -41,8 +41,8 @@ import { validateSkillFolderName } from './skillValidationUtils'; import type { SkillDetail, SkillInvocationMode, - SkillRootKind, SkillReviewPreview, + SkillRootKind, } from '@shared/types/extensions'; type EditorMode = 'create' | 'edit'; diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 5270af35..5f1ed04e 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -24,8 +24,8 @@ import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots'; import { FileSearch, FolderOpen, X } from 'lucide-react'; import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils'; -import { SkillReviewDialog } from './SkillReviewDialog'; import { resolveSkillProjectPath } from './skillProjectUtils'; +import { SkillReviewDialog } from './SkillReviewDialog'; import { validateSkillFolderName, validateSkillImportSourceDir } from './skillValidationUtils'; import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions'; diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 149bc98a..9c95959d 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -127,7 +127,7 @@ function formatRuntimeAudienceLabel(providerNames: readonly string[]): string { return 'the configured runtime'; } if (providerNames.length === 1) { - return providerNames[0]!; + return providerNames[0]; } if (providerNames.length === 2) { return `${providerNames[0]} and ${providerNames[1]}`; @@ -165,7 +165,7 @@ export const SkillsPanel = ({ const [successMessage, setSuccessMessage] = useState(null); const [highlightedSkillId, setHighlightedSkillId] = useState(null); const selectedSkillIdRef = useRef(selectedSkillId); - const selectedSkillItemRef = useRef(null); + const selectedSkillItemRef = useRef(null); selectedSkillIdRef.current = selectedSkillId; const mergedSkills = useMemo( diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx index b4e0495f..ad1fcf0a 100644 --- a/src/renderer/components/runtime/ProviderModelBadges.tsx +++ b/src/renderer/components/runtime/ProviderModelBadges.tsx @@ -1,8 +1,8 @@ +import { cn } from '@renderer/lib/utils'; import { getTeamModelBadgeLabel, getVisibleTeamProviderModels, } from '@renderer/utils/teamModelCatalog'; -import { cn } from '@renderer/lib/utils'; import type { CliProviderId, @@ -43,7 +43,7 @@ function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): } } -export function ProviderModelBadges({ +export const ProviderModelBadges = ({ providerId, models, modelAvailability, @@ -53,7 +53,7 @@ export function ProviderModelBadges({ readonly models: string[]; readonly modelAvailability?: CliProviderModelAvailability[]; readonly providerStatus?: Pick | null; -}): React.JSX.Element { +}): React.JSX.Element => { const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus); return ( @@ -94,4 +94,4 @@ export function ProviderModelBadges({ })} ); -} +}; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 0ab735d2..85ffa332 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -54,6 +54,7 @@ export interface SafeConfig { notifyOnCrossTeamMessage: boolean; notifyOnTeamLaunched: boolean; notifyOnToolApproval: boolean; + autoResumeOnRateLimit: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -195,6 +196,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true, notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true, + autoResumeOnRateLimit: displayConfig?.notifications?.autoResumeOnRateLimit ?? false, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 94245140..ac027199 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -311,6 +311,7 @@ export function useSettingsHandlers({ notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, + autoResumeOnRateLimit: false, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index c861e1c0..ba98a554 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -10,7 +10,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; -import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -21,6 +20,7 @@ import { isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; +import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; import { SettingsToggle } from '@renderer/components/settings/components'; diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index fb4babd5..0de99515 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -76,6 +76,7 @@ interface NotificationsSectionProps { | 'notifyOnCrossTeamMessage' | 'notifyOnTeamLaunched' | 'notifyOnToolApproval' + | 'autoResumeOnRateLimit' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -360,6 +361,17 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + } + > + onNotificationToggle('autoResumeOnRateLimit', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 1a3037f6..e2aaee78 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -45,7 +45,10 @@ import { getTeamModelSelectionError, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { + getTeamProviderLabel as getCatalogTeamProviderLabel, + normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, +} from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -53,22 +56,22 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { + getProviderPrepareCachedSnapshot, + type ProviderPrepareDiagnosticsModelResult, + runProviderPrepareDiagnostics, +} from './providerPrepareDiagnostics'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; import { failIncompleteProviderChecks, - getProvisioningFailureHint, getPrimaryProvisioningFailureDetail, + getProvisioningFailureHint, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; -import { getProvisioningModelIssue } from './provisioningModelIssues'; -import { - getProviderPrepareCachedSnapshot, - runProviderPrepareDiagnostics, - type ProviderPrepareDiagnosticsModelResult, -} from './providerPrepareDiagnostics'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; @@ -108,7 +111,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index fb9b0a30..ad9e4fd0 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -47,7 +47,10 @@ import { getTeamModelSelectionError, normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; -import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { + getTeamProviderLabel as getCatalogTeamProviderLabel, + normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, +} from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { @@ -69,22 +72,22 @@ import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { + getProviderPrepareCachedSnapshot, + type ProviderPrepareDiagnosticsModelResult, + runProviderPrepareDiagnostics, +} from './providerPrepareDiagnostics'; +import { getProvisioningModelIssue } from './provisioningModelIssues'; import { failIncompleteProviderChecks, - getProvisioningFailureHint, getPrimaryProvisioningFailureDetail, + getProvisioningFailureHint, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; -import { getProvisioningModelIssue } from './provisioningModelIssues'; -import { - getProviderPrepareCachedSnapshot, - runProviderPrepareDiagnostics, - type ProviderPrepareDiagnosticsModelResult, -} from './providerPrepareDiagnostics'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -192,7 +195,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored); + return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function getProviderLabel(providerId: TeamProviderId): string { diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 0e593d34..08b44e1e 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -11,7 +11,6 @@ import { } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { GEMINI_UI_DISABLED_BADGE_LABEL, GEMINI_UI_DISABLED_REASON, @@ -30,6 +29,7 @@ import { getTeamProviderLabel as getCatalogTeamProviderLabel, } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { AlertTriangle, Info } from 'lucide-react'; export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts index 5651a207..638097b1 100644 --- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts +++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts @@ -1,5 +1,5 @@ import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({ return { providerId, model: matchingModel - ? normalizeTeamModelForUi(providerId, matchingModel) + ? normalizeCatalogTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, limitContext, diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index 91826258..cf1bb17d 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -5,15 +5,13 @@ import type { TeamProviderId, TeamProvisioningPrepareResult } from '@shared/type export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed'; -interface PrepareProvisioningFn { - ( - cwd?: string, - providerId?: TeamProviderId, - providerIds?: TeamProviderId[], - selectedModels?: string[], - limitContext?: boolean - ): Promise; -} +type PrepareProvisioningFn = ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean +) => Promise; interface ProviderPrepareDiagnosticsProgress { details: string[]; @@ -156,15 +154,15 @@ function normalizeModelReason(rawReason: string | null | undefined): string | nu return 'Model verification timed out'; } - const detailMatch = trimmed.match(/"detail":"((?:\\"|[^"])*)"/i); + const detailMatch = /"detail":"((?:\\"|[^"])*)"/i.exec(trimmed); if (detailMatch?.[1]) { return normalizeModelReason(detailMatch[1].replace(/\\"/g, '"').trim()); } - const messageMatch = trimmed.match(/"message":"((?:\\"|[^"])*)"/i); + const messageMatch = /"message":"((?:\\"|[^"])*)"/i.exec(trimmed); if (messageMatch?.[1]) { const decodedMessage = messageMatch[1].replace(/\\"/g, '"'); - const nestedDetailMatch = decodedMessage.match(/"detail":"([^"]+)"/i); + const nestedDetailMatch = /"detail":"([^"]+)"/i.exec(decodedMessage); if (nestedDetailMatch?.[1]) { return normalizeModelReason(nestedDetailMatch[1].trim()); } diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index bcf7ebc2..3937e09f 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -111,8 +111,7 @@ export const MemberCard = ({ !isRemoved && presenceLabel === 'starting' && spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; + !activityTask; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; const showRuntimeAdvisoryBadge = !isRemoved && diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 79deb794..a419f6b0 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -16,8 +16,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; -import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { cn } from '@renderer/lib/utils'; +import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { AlertTriangle, ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react'; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 58db6a84..341a19c1 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isLeadMember } from '@shared/utils/leadDetection'; import { MemberCard } from './MemberCard'; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 9fafc7db..599ebd15 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -3,6 +3,8 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog'; +import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -32,6 +34,7 @@ function newDraftId(): string { export function createMemberDraft(initial?: Partial): MemberDraft { const providerId = initial?.providerId; + const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? ''; return { id: initial?.id ?? newDraftId(), name: initial?.name ?? '', @@ -39,7 +42,7 @@ export function createMemberDraft(initial?: Partial): MemberDraft { customRole: initial?.customRole ?? '', workflow: initial?.workflow, providerId, - model: normalizeTeamModelForUi(providerId, initial?.model ?? ''), + model: normalizeCatalogTeamModelForUi(providerId, normalizedModel), effort: initial?.effort, removedAt: initial?.removedAt, }; diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index ca766aaf..0fcb0ac8 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,14 +4,14 @@ */ import { api } from '@renderer/api'; -import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getExtensionActionDisableReason, getMcpDiagnosticKey, - getMcpProjectStateKey, getMcpOperationKey, + getMcpProjectStateKey, getPluginOperationKey, } from '@shared/utils/extensionNormalizers'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8060f2f1..110f2120 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -94,10 +94,10 @@ type TeamGraphConfigMemberSeedInput = Pick< NonNullable[number], 'name' | 'agentId' | 'removedAt' >; -type TeamGraphLayoutSessionState = { +interface TeamGraphLayoutSessionState { mode: 'default' | 'manual'; signature: string | null; -}; +} export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -1025,8 +1025,7 @@ function areTeamGraphSlotAssignmentsEqual( for (const [stableOwnerId, leftAssignment] of leftEntries) { const rightAssignment = right?.[stableOwnerId]; if ( - !rightAssignment || - rightAssignment.ringIndex !== leftAssignment.ringIndex || + rightAssignment?.ringIndex !== leftAssignment.ringIndex || rightAssignment.sectorIndex !== leftAssignment.sectorIndex ) { return false; @@ -2063,8 +2062,7 @@ export const createTeamSlice: StateCreator = (set, return ( (nextAssignment.ringIndex === assignment.ringIndex && nextAssignment.sectorIndex === assignment.sectorIndex) || - (displacedAssignment != null && - nextAssignment.ringIndex === displacedAssignment.ringIndex && + (nextAssignment.ringIndex === displacedAssignment?.ringIndex && nextAssignment.sectorIndex === displacedAssignment.sectorIndex) ); } diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 937a4f0f..b8246167 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -1,8 +1,8 @@ import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types'; -import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { if (!spawnEntry) { diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 63bc0b43..62ebbf20 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -2,8 +2,8 @@ import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/util import { isSupportedSlashCommandName } from '@shared/utils/slashCommands'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { SkillCatalogItem } from '@shared/types/extensions'; import type { TeamProviderId } from '@shared/types'; +import type { SkillCatalogItem } from '@shared/types/extensions'; import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; function orderSkillsForProvider( diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index f42d2275..d588a1bc 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,3 +1,22 @@ +import { + getProviderScopedTeamModelLabel, + getRuntimeAwareTeamModelUiDisabledReason, + getTeamProviderLabel, + getTeamProviderModelOptions, + getVisibleTeamProviderModels, + GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, + GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, + GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + GPT_5_2_CODEX_UI_DISABLED_MODEL, + GPT_5_2_CODEX_UI_DISABLED_REASON, + GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, + GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, + sortTeamProviderModels, + TEAM_MODEL_UI_DISABLED_BADGE_LABEL, + type TeamProviderModelOption, +} from './teamModelCatalog'; + import type { CliProviderId, CliProviderModelAvailability, @@ -6,29 +25,10 @@ import type { TeamProviderId, } from '@shared/types'; -import { - getProviderScopedTeamModelLabel, - getRuntimeAwareTeamModelUiDisabledReason, - getTeamProviderLabel, - getTeamProviderModelOptions, - sortTeamProviderModels, - getVisibleTeamProviderModels, - normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, - GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, - GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, - GPT_5_2_CODEX_UI_DISABLED_MODEL, - GPT_5_2_CODEX_UI_DISABLED_REASON, - GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, - GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, - type TeamProviderModelOption, -} from './teamModelCatalog'; - export { + GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, - GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_2_CODEX_UI_DISABLED_MODEL, GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 47248f19..e53172d8 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,4 +1,3 @@ -import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; import { filterVisibleProviderRuntimeModels, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, @@ -6,6 +5,8 @@ import { GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, } from '@shared/utils/providerModelVisibility'; +import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; + export { GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_2_CODEX_UI_DISABLED_MODEL, diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 44b89771..46e57e03 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -291,6 +291,8 @@ export interface AppConfig { notifyOnTeamLaunched: boolean; /** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */ notifyOnToolApproval: boolean; + /** Whether to automatically nudge a rate-limited team after the limit resets */ + autoResumeOnRateLimit: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index e3da4d7d..484c441d 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -9,9 +9,9 @@ import { import type { CliInstallationStatus, - InstallScope, InstalledMcpEntry, InstalledPluginEntry, + InstallScope, PluginCapability, PluginCatalogItem, } from '@shared/types'; @@ -206,7 +206,7 @@ export function getPreferredMcpInstallationEntry( return [...installations].sort( (left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope] - )[0]!; + )[0]; } /** diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts index 732b51aa..20f54acf 100644 --- a/src/shared/utils/rateLimitDetector.ts +++ b/src/shared/utils/rateLimitDetector.ts @@ -1,5 +1,5 @@ /** - * Detects rate limit messages from Claude. + * Detects rate limit messages from Claude and parses reset time from them. */ const RATE_LIMIT_SUBSTRING = "You've hit your limit"; @@ -10,3 +10,229 @@ const RATE_LIMIT_SUBSTRING = "You've hit your limit"; export function isRateLimitMessage(text: string): boolean { return text.includes(RATE_LIMIT_SUBSTRING); } + +// --------------------------------------------------------------------------- +// Reset-time parsing +// --------------------------------------------------------------------------- + +/** + * Maps known Claude timezone abbreviations to fixed UTC offsets in minutes. + * We only include zones Claude's API has been observed to emit. When the + * message contains an explicit parenthesized timezone that is NOT in this + * map, the parser returns `null` rather than guessing. When no timezone is + * present at all, the hour:minute is treated as user-local time. + */ +const TIMEZONE_OFFSETS_MIN: Record = { + UTC: 0, + GMT: 0, + // North America — standard times + EST: -5 * 60, + CST: -6 * 60, + MST: -7 * 60, + PST: -8 * 60, + // North America — daylight times + EDT: -4 * 60, + CDT: -5 * 60, + MDT: -6 * 60, + PDT: -7 * 60, +}; + +/** + * Attempts to parse the reset time from a Claude rate-limit message. + * + * Supported formats (case-insensitive): + * - "limit will reset at 3pm (PST)" + * - "limit will reset at 3:30 pm (PST)" + * - "limit will reset at 15:30 UTC" + * - "resets at 3pm" (local time assumed) + * - "resets in 2 hours" + * - "resets in 45 minutes" + * + * Returns `null` when the reset time cannot be extracted reliably. Also returns + * null for text that does not look like a rate-limit message, so the parser is + * safe to call on arbitrary strings. + * + * @param text the full rate-limit message text + * @param now reference "now" used to resolve wall-clock times and relative + * offsets (exposed for testability; defaults to `new Date()`) + */ +export function parseRateLimitResetTime(text: string, now: Date = new Date()): Date | null { + if (!text) return null; + // Defensive gate: only parse text that actually looks like a rate-limit + // message. Prevents false positives from unrelated prose containing + // words like "reset" (e.g. "reset the 5pm meeting"). + if (!isRateLimitMessage(text)) return null; + + const relative = parseRelativeResetDuration(text); + if (relative !== null) { + return new Date(now.getTime() + relative); + } + + return parseAbsoluteResetClockTime(text, now); +} + +/** + * Matches trailing qualifiers that shift the reset to a different day. + * When present, we can't reliably resolve the date without more context, so + * the parser bails out. Example: "reset at 3pm (PST) next week" — the naive + * "today or tomorrow" rollover would fire in hours instead of a week. + */ +const DAY_SHIFT_QUALIFIER_RE = + /\b(?:next\s+week|next\s+month|tomorrow|yesterday|on\s+(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*)\b/i; + +// --------------------------------------------------------------------------- +// Relative durations: "resets in 2 hours", "resets in 45 minutes" +// --------------------------------------------------------------------------- + +const RESET_VERB_RE = /\breset(?:s|ting)?\b/i; +const LEADING_FILLER_RE = /^(?:about|around)\s+/i; +const LEADING_TIME_VALUE_RE = /^(\d+(?:\.\d+)?)\s*([a-z]+)\b/i; + +function parseRelativeResetDuration(text: string): number | null { + const resetVerbMatch = RESET_VERB_RE.exec(text); + if (!resetVerbMatch) return null; + + const afterVerb = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart(); + if (!afterVerb.toLowerCase().startsWith('in')) return null; + + let tail = afterVerb.slice(2).trimStart(); + if (tail.startsWith('~')) { + tail = tail.slice(1).trimStart(); + } + tail = tail.replace(LEADING_FILLER_RE, ''); + + const match = LEADING_TIME_VALUE_RE.exec(tail); + if (!match) return null; + + const amount = Number.parseFloat(match[1]!); + if (!Number.isFinite(amount) || amount < 0) return null; + + const unit = match[2]!.toLowerCase(); + if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) { + return Math.round(amount * 1000); + } + if (['minute', 'minutes', 'min', 'mins', 'm'].includes(unit)) { + return Math.round(amount * 60 * 1000); + } + if (['hour', 'hours', 'hr', 'hrs', 'h'].includes(unit)) { + return Math.round(amount * 60 * 60 * 1000); + } + return null; +} + +// --------------------------------------------------------------------------- +// Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC" +// --------------------------------------------------------------------------- + +/** + * Captures the clock time + optional timezone abbreviation from phrases like + * "reset at 3pm (PST)" or "resets at 15:30 UTC". + */ +const LEADING_CLOCK_RE = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i; +const PAREN_TZ_RE = /^\(([A-Za-z]{2,5})\)/; +const TRAILING_TZ_RE = /^([A-Za-z]{2,5})\b/; + +function parseAbsoluteResetClockTime(text: string, now: Date): Date | null { + const resetVerbMatch = RESET_VERB_RE.exec(text); + if (!resetVerbMatch) return null; + + let tail = text.slice(resetVerbMatch.index + resetVerbMatch[0].length).trimStart(); + if (tail.toLowerCase().startsWith('at ')) { + tail = tail.slice(3).trimStart(); + } + + const match = LEADING_CLOCK_RE.exec(tail); + if (!match) return null; + + tail = tail.slice(match[0].length).trimStart(); + const parenthesizedTzMatch = PAREN_TZ_RE.exec(tail); + const bareWordMatch = parenthesizedTzMatch ? null : TRAILING_TZ_RE.exec(tail); + const bareTzMatch = + bareWordMatch && bareWordMatch[1].toUpperCase() in TIMEZONE_OFFSETS_MIN ? bareWordMatch : null; + const tzTokenLength = parenthesizedTzMatch?.[0].length ?? bareTzMatch?.[0].length ?? 0; + + // If the text contains a day-shift qualifier ("next week", "on Tuesday", + // etc.), the "today or tomorrow" rollover below would produce a materially + // wrong time. Bail out and let the caller fall back to no auto-resume. + const afterMatch = tail.slice(tzTokenLength); + if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null; + + const hourRaw = Number.parseInt(match[1]!, 10); + const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0; + const ampm = match[3]?.toLowerCase() ?? null; + const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? ''; + const trailingTz = bareTzMatch?.[1]?.toUpperCase() ?? ''; + + if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null; + if (minuteRaw < 0 || minuteRaw > 59) return null; + + let hour = hourRaw; + if (ampm === 'pm' && hour < 12) hour += 12; + else if (ampm === 'am' && hour === 12) hour = 0; + + if (hour < 0 || hour > 23) return null; + + // Timezone resolution treats parenthesized vs bare tokens differently. + // + // "reset at 3pm (PST)" — parenthesized, authoritative. Unknown zone + // here means the sender meant a specific zone + // we don't model; bail out rather than guess. + // "reset at 3pm PST" — bare known abbreviation, same effect. + // "reset at 3pm today" — bare unknown word ("TODAY"). This is just a + // trailing word, not a real TZ claim; fall + // back to local time instead of suppressing. + // "reset at 3pm" — no token. Treat as user-local. + let tzOffset: number | null; + if (parenthesizedTz) { + if (!(parenthesizedTz in TIMEZONE_OFFSETS_MIN)) return null; + tzOffset = TIMEZONE_OFFSETS_MIN[parenthesizedTz]!; + } else if (trailingTz && trailingTz in TIMEZONE_OFFSETS_MIN) { + tzOffset = TIMEZONE_OFFSETS_MIN[trailingTz]!; + } else { + tzOffset = null; + } + + const candidateSeed = + tzOffset === null + ? buildLocalToday(now, hour, minuteRaw) + : buildUtcTodayWithOffset(now, hour, minuteRaw, tzOffset); + let candidate: Date = candidateSeed; + + // If the computed time is materially in the past (e.g. "3pm" parsed while + // it's already 4pm), roll forward by one day. A small tolerance prevents + // near-present timestamps — stale messages, clock skew, sub-second drift — + // from being bumped 24 h forward, which would then trip the scheduler's + // 12 h ceiling and silently drop auto-resume altogether. Timestamps within + // `ROLLOVER_TOLERANCE_MS` of now fire immediately after the scheduler's + // own 30 s buffer and `Math.max(0, rawDelayMs)` clamp. + if (candidate.getTime() <= now.getTime() - ROLLOVER_TOLERANCE_MS) { + candidate = new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + } + return candidate; +} + +const ROLLOVER_TOLERANCE_MS = 60 * 1000; + +function buildLocalToday(now: Date, hour: number, minute: number): Date { + const d = new Date(now); + d.setHours(hour, minute, 0, 0); + return d; +} + +function buildUtcTodayWithOffset( + now: Date, + hour: number, + minute: number, + offsetMinutes: number +): Date { + // The caller's "hour:minute" is expressed in the target zone. Anchor the + // calendar date in that zone too — not in UTC — otherwise we get a 24h + // error when the zone-local day differs from UTC's day (e.g. 01:00 UTC is + // still "yesterday" for any negative-offset zone like PST). + const zoned = new Date(now.getTime() + offsetMinutes * 60 * 1000); + const offsetMs = offsetMinutes * 60 * 1000; + return new Date( + Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate(), hour, minute, 0, 0) - + offsetMs + ); +} diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index ca4c1752..86dd613f 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -115,6 +115,7 @@ describe('configValidation', () => { 'notifyOnClarifications', 'notifyOnStatusChange', 'notifyOnTeamLaunched', + 'autoResumeOnRateLimit', 'statusChangeOnlySolo', ] as const)('accepts boolean %s toggle', (key) => { const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); @@ -136,6 +137,7 @@ describe('configValidation', () => { 'notifyOnClarifications', 'notifyOnStatusChange', 'notifyOnTeamLaunched', + 'autoResumeOnRateLimit', 'statusChangeOnlySolo', ] as const)('rejects non-boolean %s', (key) => { const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 24412b24..007c9e2b 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -1,5 +1,5 @@ import * as os from 'os'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BoardTaskActivityDetailResult, BoardTaskActivityEntry, @@ -116,6 +116,7 @@ import { registerTeamHandlers, removeTeamHandlers, } from '../../../src/main/ipc/teams'; +import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager'; describe('ipc teams handlers', () => { const handlers = new Map Promise>(); @@ -192,10 +193,12 @@ describe('ipc teams handlers', () => { launchTeam: vi.fn(async () => ({ runId: 'run-2' })), sendMessageToTeam: vi.fn(async () => undefined), isTeamAlive: vi.fn(() => true), + getCurrentRunId: vi.fn(() => 'run-2' as string | null), pushLiveLeadProcessMessage: vi.fn(), relayLeadInboxMessages: vi.fn(async () => 0), relayMemberInboxMessages: vi.fn(async () => 0), getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]), + getCurrentLeadSessionId: vi.fn(() => null as string | null), getAliveTeams: vi.fn(() => ['my-team']), getLeadActivityState: vi.fn(() => 'idle'), stopTeam: vi.fn(() => undefined), @@ -249,6 +252,10 @@ describe('ipc teams handlers', () => { registerTeamHandlers(ipcMain as never); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('registers all expected handlers', () => { expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true); @@ -799,6 +806,81 @@ describe('ipc teams handlers', () => { expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); }); + it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:00:30.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-123'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'persisted-rate-limit-1', + leadSessionId: 'sess-123', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:02.000Z', + read: true, + source: 'lead_process' as const, + messageId: 'live-rate-limit-1', + leadSessionId: 'sess-123', + }, + ]); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + data: { messages: Array<{ source?: string; messageId?: string }> }; + }; + + expect(result.success).toBe(true); + expect(result.data.messages).toEqual([ + expect.objectContaining({ + source: 'lead_session', + messageId: 'persisted-rate-limit-1', + }), + ]); + + await vi.advanceTimersByTimeAsync(4 * 60 * 1000 + 59 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + } finally { + getConfigSpy.mockRestore(); + } + }); + it('merges early live messages before durable lead_session backfill exists', async () => { // Simulate: team just became readable but lead_session JSONL hasn't been written yet. // Only live in-memory messages exist from the provisioning process. @@ -846,6 +928,357 @@ describe('ipc teams handlers', () => { expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.'); }); + it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_session' as const, + leadSessionId: 'sess-live', + messageId: 'rate-limit-1', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + data: { messages: { source?: string; text: string }[] }; + }; + + expect(result.success).toBe(true); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + } finally { + getConfigSpy.mockRestore(); + } + }); + + it('can schedule auto-resume when the setting is enabled after an earlier history scan', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + let autoResumeEnabled = false; + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: autoResumeEnabled, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_session' as const, + leadSessionId: 'sess-live', + messageId: 'rate-limit-enable-later', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + + const firstResult = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(firstResult.success).toBe(true); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + autoResumeEnabled = true; + + const secondResult = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(secondResult.success).toBe(true); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + } finally { + getConfigSpy.mockRestore(); + } + }); + + it('retries a previously over-ceiling history message once it becomes schedulable', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T00:00:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets at 12:20 UTC.", + timestamp: '2026-04-17T00:00:00.000Z', + read: true, + source: 'lead_session' as const, + leadSessionId: 'sess-live', + messageId: 'rate-limit-over-ceiling', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + + const firstResult = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(firstResult.success).toBe(true); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date('2026-04-17T12:20:00.000Z')); + + const secondResult = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(secondResult.success).toBe(true); + + await vi.advanceTimersByTimeAsync(29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + getConfigSpy.mockRestore(); + } + }); + + it('does not rebuild auto-resume from persisted history while the team is offline', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(false); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'rate-limit-offline-history', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(result.success).toBe(true); + + // Simulate the user manually starting a fresh run later; stale persisted history + // should not have armed an auto-resume timer while the team was offline. + provisioningService.isTeamAlive.mockReturnValue(true); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + } + }); + + it('does not rebuild auto-resume from an older lead session after the team was manually restarted', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-new'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_session' as const, + leadSessionId: 'sess-old', + messageId: 'rate-limit-old-session', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(result.success).toBe(true); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + } + }); + + it('does not arm lead auto-resume from a teammate inbox rate-limit message', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T12:02:00.000Z')); + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.getCurrentLeadSessionId.mockReturnValue('sess-live'); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + service.getTeamData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [ + { + from: 'alice', + to: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: false, + messageId: 'member-rate-limit-1', + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + }; + expect(result.success).toBe(true); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 31 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + } + }); + it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index c1a1c5d1..d9a2e8f2 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -13,6 +13,10 @@ vi.mock('@main/utils/pathDecoder', () => ({ vi.mock('node:fs/promises'); +function toPortablePath(filePath: unknown): string { + return String(filePath).replaceAll('\\', '/'); +} + describe('McpInstallationStateService', () => { let service: McpInstallationStateService; const mockedFs = vi.mocked(fs); @@ -31,7 +35,7 @@ describe('McpInstallationStateService', () => { describe('getInstalled', () => { it('includes local scope from the current project entry in ~/.claude.json', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath === '/tmp/mock-home/.claude.json') { return JSON.stringify({ mcpServers: { @@ -69,7 +73,7 @@ describe('McpInstallationStateService', () => { it('caches results within TTL for the same project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath === '/tmp/mock-home/.claude.json') { return JSON.stringify({ mcpServers: { @@ -97,7 +101,7 @@ describe('McpInstallationStateService', () => { it('caches results independently per project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath === '/tmp/mock-home/.claude.json') { return JSON.stringify({ mcpServers: { diff --git a/test/main/services/extensions/PluginInstallationStateService.test.ts b/test/main/services/extensions/PluginInstallationStateService.test.ts index 16f2d2e1..e0c74ca7 100644 --- a/test/main/services/extensions/PluginInstallationStateService.test.ts +++ b/test/main/services/extensions/PluginInstallationStateService.test.ts @@ -11,6 +11,10 @@ vi.mock('@main/utils/pathDecoder', () => ({ // Mock filesystem vi.mock('node:fs/promises'); +function toPortablePath(filePath: unknown): string { + return String(filePath).replaceAll('\\', '/'); +} + describe('PluginInstallationStateService', () => { let service: PluginInstallationStateService; const mockedFs = vi.mocked(fs); @@ -27,7 +31,7 @@ describe('PluginInstallationStateService', () => { describe('getInstalledPlugins', () => { it('returns user-scoped plugins enabled in user settings', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -75,7 +79,7 @@ describe('PluginInstallationStateService', () => { it('includes project and local scopes only for the active project', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -143,7 +147,7 @@ describe('PluginInstallationStateService', () => { it('does not leak another project scope into the current project', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, @@ -182,7 +186,7 @@ describe('PluginInstallationStateService', () => { it('returns empty array for unexpected version', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 1, plugins: {} }); } @@ -198,7 +202,7 @@ describe('PluginInstallationStateService', () => { it('caches within TTL', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, plugins: {} }); } @@ -216,7 +220,7 @@ describe('PluginInstallationStateService', () => { it('caches results independently per project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { - const normalizedPath = String(filePath); + const normalizedPath = toPortablePath(filePath); if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { return JSON.stringify({ version: 2, plugins: {} }); } diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index ffad35a7..8e7549c7 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -231,7 +231,7 @@ describe('CliInstallerService', () => { verificationState: 'verified', modelVerificationState: 'idle', statusMessage: null, - models: ['gpt-5.4', 'gpt-5.2-codex'], + models: ['gpt-5.4', 'gpt-5.4-mini'], modelAvailability: [], canLoginFromUi: true, capabilities: { teamLaunch: true, oneShot: true }, @@ -267,14 +267,12 @@ describe('CliInstallerService', () => { if (normalizedArgs === '--version') { return { stdout: '2.3.4', stderr: '' }; } + if (normalizedArgs.includes('--model gpt-5.4-mini')) { + throw new Error("The 'gpt-5.4-mini' model is not supported in this Codex runtime."); + } if (normalizedArgs.includes('--model gpt-5.4')) { return { stdout: 'PONG', stderr: '' }; } - if (normalizedArgs.includes('--model gpt-5.2-codex')) { - throw new Error( - "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account." - ); - } throw new Error(`Unexpected execCli call: ${normalizedArgs}`); }); @@ -291,7 +289,7 @@ describe('CliInstallerService', () => { expect(verifiedProvider?.modelAvailability).toEqual( expect.arrayContaining([ expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }), - expect.objectContaining({ modelId: 'gpt-5.2-codex', status: 'checking' }), + expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }), ]) ); @@ -303,7 +301,7 @@ describe('CliInstallerService', () => { expect(latestCodexProvider?.modelAvailability).toEqual([ expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }), expect.objectContaining({ - modelId: 'gpt-5.2-codex', + modelId: 'gpt-5.4-mini', status: 'unavailable', }), ]); diff --git a/test/main/services/infrastructure/ConfigManager.notifications.test.ts b/test/main/services/infrastructure/ConfigManager.notifications.test.ts new file mode 100644 index 00000000..c839d0c7 --- /dev/null +++ b/test/main/services/infrastructure/ConfigManager.notifications.test.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('ConfigManager notification config shape', () => { + let overrideRoot: string | null = null; + + afterEach(async () => { + if (overrideRoot) { + fs.rmSync(overrideRoot, { recursive: true, force: true }); + overrideRoot = null; + } + vi.resetModules(); + const pathDecoder = await import('../../../../src/main/utils/pathDecoder'); + pathDecoder.setClaudeBasePathOverride(null); + }); + + it('strips unknown notification keys while keeping autoResumeOnRateLimit', async () => { + vi.resetModules(); + + overrideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-notifications-')); + const pathDecoder = await import('../../../../src/main/utils/pathDecoder'); + pathDecoder.setClaudeBasePathOverride(overrideRoot); + + fs.writeFileSync( + path.join(overrideRoot, 'claude-devtools-config.json'), + JSON.stringify({ + notifications: { + notifyOnInboxMessages: true, + autoResumeOnRateLimit: true, + notifyOnTeamLaunched: false, + }, + }) + ); + + const { configManager } = await import( + '../../../../src/main/services/infrastructure/ConfigManager' + ); + const config = configManager.getConfig(); + + expect(config.notifications.autoResumeOnRateLimit).toBe(true); + expect(config.notifications.notifyOnTeamLaunched).toBe(false); + expect('notifyOnInboxMessages' in config.notifications).toBe(false); + }); +}); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index ef0dd4c4..bc7984e5 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -765,8 +765,13 @@ describe('FileWatcher', () => { filePath: string ) => Promise; lastProcessedLineCount: Map; + instanceCreatedAt: number; }; + // Make the "new file after startup" case deterministic across filesystems + // whose birthtime precision can differ on CI runners. + watcherAny.instanceCreatedAt = 0; + // First read of a NEW file should detect errors (not baseline-skip) await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath); diff --git a/test/main/services/team/AutoResumeService.test.ts b/test/main/services/team/AutoResumeService.test.ts new file mode 100644 index 00000000..c7ca7d6d --- /dev/null +++ b/test/main/services/team/AutoResumeService.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AutoResumeService } from '../../../../src/main/services/team/AutoResumeService'; + +import type { ConfigManager } from '../../../../src/main/services/infrastructure/ConfigManager'; + +const TEAM = 'test-team'; +const RATE_LIMIT_MSG = "You've hit your limit. Resets in 5 minutes."; + +describe('AutoResumeService', () => { + const mockConfig = { autoResumeOnRateLimit: false }; + const configManagerMock = { + getConfig: vi.fn(() => ({ + notifications: { + autoResumeOnRateLimit: mockConfig.autoResumeOnRateLimit, + }, + })), + }; + const configManager = configManagerMock as unknown as Pick; + const provisioningService = { + getCurrentRunId: vi.fn<(teamName: string) => string | null>(), + isTeamAlive: vi.fn<(teamName: string) => boolean>(), + sendMessageToTeam: vi.fn<(teamName: string, text: string) => Promise>(), + }; + + let service: AutoResumeService; + + beforeEach(() => { + mockConfig.autoResumeOnRateLimit = false; + provisioningService.getCurrentRunId.mockReset(); + provisioningService.isTeamAlive.mockReset(); + provisioningService.sendMessageToTeam.mockReset(); + configManagerMock.getConfig.mockClear(); + provisioningService.getCurrentRunId.mockReturnValue('run-1'); + service = new AutoResumeService(provisioningService, configManager); + vi.useFakeTimers(); + }); + + afterEach(() => { + service.clearAllPendingAutoResume(); + vi.useRealTimers(); + }); + + it('does nothing when the feature flag is off', () => { + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('does not schedule when the reset time is unparseable', () => { + mockConfig.autoResumeOnRateLimit = true; + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, "You've hit your limit.", now); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('reschedules when a later rate-limit message changes the reset time', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, `You've hit your limit. Resets in 1 minute.`, now); + service.handleRateLimitMessage(TEAM, `You've hit your limit. Resets in 10 minutes.`, now); + + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(8 * 60 * 1000 + 30 * 1000 + 100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + + it('ignores an older rate-limit message when a newer timer is already pending', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + + const observedAt = new Date('2026-04-17T12:01:30Z'); + const newerMessageAt = new Date('2026-04-17T12:01:00Z'); + const olderMessageAt = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage( + TEAM, + `You've hit your limit. Resets in 10 minutes.`, + observedAt, + newerMessageAt + ); + service.handleRateLimitMessage( + TEAM, + `You've hit your limit. Resets in 15 minutes.`, + observedAt, + olderMessageAt + ); + + await vi.advanceTimersByTimeAsync(9 * 60 * 1000 + 59 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1200); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + + it('keeps only one timer when the same reset time is reported again', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + + it('clears a stale pending timer when a newer reset exceeds the ceiling', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T16:00:00Z'); + + service.handleRateLimitMessage(TEAM, `You've hit your limit. Resets in 5 minutes.`, now); + service.handleRateLimitMessage(TEAM, `You've hit your limit. Resets at 15:00 UTC.`, now); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('exceeds ceiling') + ); + warnSpy.mockRestore(); + }); + + it('reconstructs the remaining delay from a persisted rate-limit message timestamp', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + + const observedAt = new Date('2026-04-17T12:02:00Z'); + const messageAt = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, observedAt, messageAt); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + + it('uses only the remaining buffer when the reset already happened shortly before replay', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + + const observedAt = new Date('2026-04-17T12:05:20Z'); + const messageAt = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, observedAt, messageAt); + + await vi.advanceTimersByTimeAsync(9 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + + it('skips stale persisted history once the parsed reset is materially in the past', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + + const observedAt = new Date('2026-04-17T12:05:40Z'); + const messageAt = new Date('2026-04-17T11:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, observedAt, messageAt); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('skips replay after the buffered fire deadline already passed', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + + const observedAt = new Date('2026-04-17T12:05:40Z'); + const messageAt = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, observedAt, messageAt); + + await vi.advanceTimersByTimeAsync(60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('sends the resume nudge when the team is alive at fire time', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + + expect(provisioningService.isTeamAlive).toHaveBeenCalledWith(TEAM); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + expect(provisioningService.sendMessageToTeam.mock.calls[0]![0]).toBe(TEAM); + expect(provisioningService.sendMessageToTeam.mock.calls[0]![1]).toMatch( + /Your rate limit has reset/ + ); + }); + + it('skips the nudge when the team is no longer alive at fire time', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(false); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + + expect(provisioningService.isTeamAlive).toHaveBeenCalledWith(TEAM); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('skips the nudge when the team has moved to a newer run before fire time', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.getCurrentRunId.mockReturnValue('run-1'); + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + provisioningService.getCurrentRunId.mockReturnValue('run-2'); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + + expect(provisioningService.isTeamAlive).toHaveBeenCalledWith(TEAM); + expect(provisioningService.getCurrentRunId).toHaveBeenLastCalledWith(TEAM); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('re-checks the config flag at fire time and aborts when toggled off', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + mockConfig.autoResumeOnRateLimit = false; + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + expect(provisioningService.isTeamAlive).not.toHaveBeenCalled(); + }); + + it('swallows errors from sendMessageToTeam without crashing', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockRejectedValue(new Error('stdin closed')); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + + await expect( + vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100) + ).resolves.not.toThrow(); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Failed to send resume nudge') + ); + errorSpy.mockRestore(); + }); + + it('clears a pending timer so the nudge never fires', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, RATE_LIMIT_MSG, now); + service.cancelPendingAutoResume(TEAM); + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); + + it('cancels every pending timer across teams', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage('team-a', RATE_LIMIT_MSG, now); + service.handleRateLimitMessage('team-b', `You've hit your limit. Resets in 10 minutes.`, now); + + service.clearAllPendingAutoResume(); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 889c74c8..32753ec5 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -51,6 +51,11 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from '@main/services/team/AutoResumeService'; import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; @@ -160,6 +165,7 @@ describe('TeamProvisioningService', () => { }); afterEach(() => { + clearAutoResumeService(); vi.useRealTimers(); try { fs.rmSync(tempClaudeRoot, { recursive: true, force: true }); @@ -674,6 +680,141 @@ describe('TeamProvisioningService', () => { expect(launchArgs).toContain(leadSessionId); }); + it('seeds the current lead session id immediately when launch resumes an existing session', async () => { + allowConsoleLogs(); + const teamName = 'resume-seed-session-team'; + const leadSessionId = 'lead-session-seeded'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = createRunningChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + + expect(svc.getCurrentLeadSessionId(teamName)).toBe(leadSessionId); + + await svc.cancelProvisioning(runId); + }); + + it('clears stale team-scoped transient state before starting a new launch run', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + + const teamName = 'launch-clears-stale-runtime-state'; + const leadSessionId = 'lead-session-stale-state'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('launch spawn EINVAL'); + }); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + + const autoResumeProvisioning = { + getCurrentRunId: vi.fn(() => 'run-1' as string | null), + isTeamAlive: vi.fn(() => true), + sendMessageToTeam: vi.fn(async () => undefined), + }; + initializeAutoResumeService(autoResumeProvisioning); + + const configManagerModule = await import('@main/services/infrastructure/ConfigManager'); + const configManager = configManagerModule.ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + getAutoResumeService().handleRateLimitMessage( + teamName, + "You've hit your limit. Resets in 5 minutes.", + new Date('2026-04-17T12:00:00.000Z') + ); + + (svc as any).relayedLeadInboxMessageIds.set(teamName, new Set(['stale-msg'])); + (svc as any).liveLeadProcessMessages.set(teamName, [ + { + from: 'team-lead', + text: 'Old transient message', + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_process', + messageId: 'lead-turn-old-run-1', + }, + ]); + (svc as any).pendingTimeouts.set( + `same-team-deferred:${teamName}`, + setTimeout(() => undefined, 60_000) + ); + + await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow( + 'launch spawn EINVAL' + ); + + expect((svc as any).relayedLeadInboxMessageIds.has(teamName)).toBe(false); + expect((svc as any).liveLeadProcessMessages.has(teamName)).toBe(false); + expect((svc as any).pendingTimeouts.has(`same-team-deferred:${teamName}`)).toBe(false); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + } + }); + it('marks persisted bootstrap as failed when member transcript shows an unsupported model error', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-unsupported-model'; diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index c2e41871..2404327a 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -113,6 +113,12 @@ vi.mock('agent-teams-controller', () => ({ })); import type { TeamChangeEvent } from '@shared/types/team'; +import { ConfigManager } from '../../../../src/main/services/infrastructure/ConfigManager'; +import { + clearAutoResumeService, + getAutoResumeService, + initializeAutoResumeService, +} from '../../../../src/main/services/team/AutoResumeService'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; function seedConfig(teamName: string): void { @@ -133,6 +139,7 @@ interface RunLike { runId: string; teamName: string; provisioningComplete: boolean; + detectedSessionId?: string | null; leadMsgSeq: number; pendingToolCalls: { name: string; preview: string }[]; activeToolCalls: Map; @@ -150,6 +157,8 @@ interface RunLike { request: { members: { name: string; role?: string }[] }; activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>; pendingInboxRelayCandidates?: unknown[]; + memberSpawnStatuses: Map; + pendingApprovals: Map; } /** @@ -159,13 +168,14 @@ interface RunLike { function attachRun( service: TeamProvisioningService, teamName: string, - opts?: { provisioningComplete?: boolean } + opts?: { provisioningComplete?: boolean; runId?: string; detectedSessionId?: string | null } ): RunLike { - const runId = 'run-1'; + const runId = opts?.runId ?? 'run-1'; const run: RunLike = { runId, teamName, provisioningComplete: opts?.provisioningComplete ?? false, + detectedSessionId: opts?.detectedSessionId ?? null, leadMsgSeq: 0, pendingToolCalls: [], activeToolCalls: new Map(), @@ -180,6 +190,8 @@ function attachRun( provisioningOutputParts: [], request: { members: [{ name: 'team-lead', role: 'Team Lead' }] }, activeCrossTeamReplyHints: [], + memberSpawnStatuses: new Map(), + pendingApprovals: new Map(), }; (service as unknown as { aliveRunByTeam: Map }).aliveRunByTeam.set( @@ -227,6 +239,64 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(run.provisioningOutputParts).toHaveLength(1); }); + it('attaches leadSessionId to a live message when the same assistant payload carries session_id', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: false }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + session_id: 'sess-123', + content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], + }); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].leadSessionId).toBe('sess-123'); + }); + + it('makes leadSessionId visible to synchronous lead-message listeners in the same turn', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const seenSessionIds: Array = []; + service.setTeamChangeEmitter((event) => { + if (event.type === 'lead-message') { + seenSessionIds.push(service.getLiveLeadProcessMessages('my-team')[0]?.leadSessionId); + } + }); + const run = attachRun(service, 'my-team', { provisioningComplete: false }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + session_id: 'sess-sync', + content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], + }); + + expect(seenSessionIds).toEqual(['sess-sync']); + }); + + it('retrofits leadSessionId onto earlier live messages after session detection', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: false }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [{ type: 'text', text: 'Команда создана. Запускаю всех тиммейтов параллельно.' }], + }); + expect(service.getLiveLeadProcessMessages('my-team')[0]?.leadSessionId).toBeUndefined(); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + session_id: 'sess-456', + content: [], + }); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].leadSessionId).toBe('sess-456'); + }); + it('emits lead-message event type (not inbox)', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); @@ -547,6 +617,82 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + it('ignores stale cross-team send completions from an older run after a new run starts', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + + let resolveSend: ((value: { deliveredToInbox: boolean; messageId: string }) => void) | null = + null; + const crossTeamSender = vi.fn( + () => + new Promise<{ deliveredToInbox: boolean; messageId: string }>((resolve) => { + resolveSend = resolve; + }) + ); + service.setCrossTeamSender(crossTeamSender); + + const oldRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-old', + detectedSessionId: 'sess-old', + }); + oldRun.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-old' }]; + + callHandleStreamJsonMessage(service, oldRun, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'SendMessage', + input: { + type: 'message', + recipient: 'team-best.user', + content: 'Old run cross-team reply.', + summary: 'Old run reply', + }, + }, + ], + }); + + await vi.waitFor(() => { + expect(crossTeamSender).toHaveBeenCalledTimes(1); + }); + + const newRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-new', + detectedSessionId: 'sess-new', + }); + service.pushLiveLeadProcessMessage('my-team', { + from: 'team-lead', + text: 'Current run is active.', + timestamp: '2026-04-17T12:00:10.000Z', + read: true, + source: 'lead_process', + messageId: 'lead-turn-run-new-1', + leadSessionId: 'sess-new', + }); + + expect(resolveSend).not.toBeNull(); + const finishSend = resolveSend as unknown as (( + value: { deliveredToInbox: boolean; messageId: string } + ) => void); + finishSend({ deliveredToInbox: true, messageId: 'cross-stale-old-run' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(service.getLiveLeadProcessMessages('my-team')).toEqual([ + expect.objectContaining({ + text: 'Current run is active.', + messageId: 'lead-turn-run-new-1', + leadSessionId: 'sess-new', + }), + ]); + + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); + }); + it('upgrades pseudo cross-team recipients into cross-team sends', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); @@ -964,3 +1110,238 @@ describe('TeamProvisioningService pre-ready live messages', () => { ); }); }); + +describe('TeamProvisioningService auto-resume cleanup', () => { + beforeEach(() => { + hoisted.files.clear(); + hoisted.appendSentMessage.mockClear(); + hoisted.sendInboxMessage.mockClear(); + clearAutoResumeService(); + vi.useFakeTimers(); + }); + + afterEach(() => { + clearAutoResumeService(); + vi.useRealTimers(); + }); + + it('cancels pending auto-resume timers when a run is cleaned up', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + const autoResumeProvisioning = { + getCurrentRunId: vi.fn(() => 'run-1' as string | null), + isTeamAlive: vi.fn(() => true), + sendMessageToTeam: vi.fn(async () => undefined), + }; + initializeAutoResumeService(autoResumeProvisioning); + + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + getAutoResumeService().handleRateLimitMessage( + 'my-team', + "You've hit your limit. Resets in 5 minutes.", + new Date('2026-04-17T12:00:00.000Z') + ); + + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(run); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + } + }); + + it('does not let stale cleanup from an older run cancel the current run state', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const oldRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-old', + detectedSessionId: 'sess-old', + }); + const newRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-new', + detectedSessionId: 'sess-new', + }); + + const autoResumeProvisioning = { + getCurrentRunId: vi.fn(() => 'run-1' as string | null), + isTeamAlive: vi.fn(() => true), + sendMessageToTeam: vi.fn(async () => undefined), + }; + initializeAutoResumeService(autoResumeProvisioning); + + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + getAutoResumeService().handleRateLimitMessage( + 'my-team', + "You've hit your limit. Resets in 5 minutes.", + new Date('2026-04-17T12:00:00.000Z') + ); + + service.pushLiveLeadProcessMessage('my-team', { + from: 'team-lead', + text: 'Current run is active.', + timestamp: '2026-04-17T12:00:01.000Z', + read: true, + source: 'lead_process', + messageId: 'live-new-run', + }); + expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(1); + + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); + + expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 30 * 1000 + 100); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + expect.stringContaining('rate limit has reset') + ); + } finally { + getConfigSpy.mockRestore(); + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); + } + }); + + it('removes stale live lead messages from an older run while preserving the current run', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const oldRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-old', + detectedSessionId: 'sess-old', + }); + + service.pushLiveLeadProcessMessage('my-team', { + from: 'team-lead', + text: "You've hit your limit. Resets in 5 minutes.", + timestamp: '2026-04-17T12:00:00.000Z', + read: true, + source: 'lead_process', + messageId: 'lead-turn-run-old-1', + leadSessionId: 'sess-old', + }); + + const newRun = attachRun(service, 'my-team', { + provisioningComplete: true, + runId: 'run-new', + detectedSessionId: 'sess-new', + }); + + service.pushLiveLeadProcessMessage('my-team', { + from: 'team-lead', + text: 'Current run is active.', + timestamp: '2026-04-17T12:00:10.000Z', + read: true, + source: 'lead_process', + messageId: 'lead-turn-run-new-1', + leadSessionId: 'sess-new', + }); + + expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(2); + + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(oldRun); + + expect(service.getLiveLeadProcessMessages('my-team')).toEqual([ + expect.objectContaining({ + text: 'Current run is active.', + messageId: 'lead-turn-run-new-1', + leadSessionId: 'sess-new', + }), + ]); + + (service as unknown as { cleanupRun: (runLike: unknown) => void }).cleanupRun(newRun); + }); + + it('preserves the canonical assistant timestamp for live rate-limit messages', async () => { + vi.setSystemTime(new Date('2026-04-17T12:00:20.000Z')); + + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { + provisioningComplete: true, + detectedSessionId: 'sess-live', + }); + + const autoResumeProvisioning = { + getCurrentRunId: vi.fn(() => 'run-1' as string | null), + isTeamAlive: vi.fn(() => true), + sendMessageToTeam: vi.fn(async () => undefined), + }; + initializeAutoResumeService(autoResumeProvisioning); + + const configManager = ConfigManager.getInstance(); + const actualConfig = configManager.getConfig(); + const getConfigSpy = vi.spyOn(configManager, 'getConfig').mockImplementation( + () => + ({ + ...actualConfig, + notifications: { + ...actualConfig.notifications, + autoResumeOnRateLimit: true, + }, + }) as never + ); + + try { + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + timestamp: '2026-04-17T12:00:00.000Z', + content: [{ type: 'text', text: "You've hit your limit. Resets in 5 minutes." }], + }); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].timestamp).toBe('2026-04-17T12:00:00.000Z'); + + getAutoResumeService().handleRateLimitMessage( + 'my-team', + live[0].text, + new Date('2026-04-17T12:00:20.000Z'), + new Date(live[0].timestamp) + ); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 9 * 1000); + expect(autoResumeProvisioning.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledTimes(1); + expect(autoResumeProvisioning.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + expect.stringContaining('rate limit has reset') + ); + } finally { + getConfigSpy.mockRestore(); + } + }); +}); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index d9453af8..6ab47e2d 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -151,12 +151,26 @@ function seedMemberInbox(teamName: string, memberName: string, messages: unknown hoisted.files.set(`/mock/teams/${teamName}/inboxes/${memberName}.json`, JSON.stringify(messages)); } +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function attachAliveRun( service: TeamProvisioningService, teamName: string, - opts?: { writable?: boolean } -): { writeSpy: ReturnType } { - const runId = 'run-1'; + opts?: { writable?: boolean; runId?: string; provisioningComplete?: boolean } +): { writeSpy: ReturnType; runId: string } { + const runId = opts?.runId ?? 'run-1'; const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => { if (typeof cb === 'function') cb(null); return true; @@ -174,6 +188,7 @@ function attachAliveRun( teamName, members: [{ name: 'team-lead', role: 'team-lead' }], }, + startedAt: '2026-02-23T09:59:00.000Z', leadMsgSeq: 0, pendingToolCalls: [], activeToolCalls: new Map(), @@ -181,6 +196,8 @@ function attachAliveRun( lastLeadTextEmitMs: 0, activeCrossTeamReplyHints: [], pendingInboxRelayCandidates: [], + pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), silentUserDmForward: null, silentUserDmForwardClearHandle: null, child: { @@ -191,11 +208,11 @@ function attachAliveRun( }, processKilled: false, cancelRequested: false, - provisioningComplete: true, + provisioningComplete: opts?.provisioningComplete ?? true, leadRelayCapture: null, }); - return { writeSpy }; + return { writeSpy, runId }; } async function waitForCapture(service: TeamProvisioningService): Promise { @@ -435,6 +452,111 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(writeSpy).toHaveBeenCalledTimes(1); }); + it('does not let stale lead inbox relay work write into a newer run', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const inboxMessages = [ + { + from: 'bob', + text: 'Please pick this up.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + messageId: 'm-stale-lead-1', + }, + ]; + seedConfig(teamName); + seedLeadInbox(teamName, inboxMessages); + + const { writeSpy: oldWriteSpy, runId: oldRunId } = attachAliveRun(service, teamName, { + runId: 'run-old', + }); + const inboxDeferred = createDeferred(); + const inboxReader = (service as unknown as { + inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; + }).inboxReader; + const inboxSpy = vi + .spyOn(inboxReader, 'getMessagesFor') + .mockImplementationOnce(async () => await inboxDeferred.promise) + .mockImplementation(async () => inboxMessages); + + const relayPromise = service.relayLeadInboxMessages(teamName); + await Promise.resolve(); + + const oldRun = (service as unknown as { runs: Map }).runs.get(oldRunId); + oldRun.processKilled = true; + oldRun.cancelRequested = true; + oldRun.child.stdin.writable = false; + + const { writeSpy: newWriteSpy } = attachAliveRun(service, teamName, { runId: 'run-new' }); + inboxDeferred.resolve(inboxMessages); + + await expect(relayPromise).resolves.toBe(0); + expect(oldWriteSpy).not.toHaveBeenCalled(); + expect(newWriteSpy).not.toHaveBeenCalled(); + inboxSpy.mockRestore(); + }); + + it('does not let stale lead relay consume a newer run permission_request', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const permissionMessage = { + from: 'alice', + text: JSON.stringify({ + type: 'permission_request', + request_id: 'perm-new-run-1', + agent_id: 'alice', + tool_name: 'Bash', + input: { command: 'git status' }, + }), + timestamp: '2026-02-23T10:00:30.000Z', + read: false, + messageId: 'perm-inbox-1', + }; + seedConfig(teamName); + seedLeadInbox(teamName, [permissionMessage]); + + const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); + const inboxDeferred = createDeferred<[typeof permissionMessage]>(); + const inboxReader = (service as unknown as { + inboxReader: { + getMessagesFor: ( + team: string, + member: string + ) => Promise<[typeof permissionMessage]>; + }; + }).inboxReader; + const inboxSpy = vi + .spyOn(inboxReader, 'getMessagesFor') + .mockImplementationOnce(async () => await inboxDeferred.promise) + .mockImplementation(async () => [permissionMessage]); + + const relayPromise = service.relayLeadInboxMessages(teamName); + await Promise.resolve(); + + const oldRun = (service as unknown as { runs: Map }).runs.get(oldRunId); + oldRun.processKilled = true; + oldRun.cancelRequested = true; + oldRun.child.stdin.writable = false; + + attachAliveRun(service, teamName, { runId: 'run-new' }); + inboxDeferred.resolve([permissionMessage]); + + await expect(relayPromise).resolves.toBe(0); + + const inbox = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' + ) as Array<{ messageId?: string; read?: boolean }>; + expect(inbox).toEqual([ + expect.objectContaining({ + messageId: 'perm-inbox-1', + read: false, + }), + ]); + expect(oldRun.pendingApprovals.size).toBe(0); + expect(oldRun.processedPermissionRequestIds.size).toBe(0); + inboxSpy.mockRestore(); + }); + it('relays legacy lead inbox rows with generated messageId', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; @@ -910,6 +1032,50 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(payload).toContain('Please review my changes'); }); + it('does not let stale member inbox relay work write into a newer run', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const inboxMessages = [ + { + from: 'user', + text: 'Please sync with Alice.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + messageId: 'm-stale-member-1', + }, + ]; + seedConfig(teamName); + seedMemberInbox(teamName, 'alice', inboxMessages); + + const { writeSpy: oldWriteSpy, runId: oldRunId } = attachAliveRun(service, teamName, { + runId: 'run-old', + }); + const inboxDeferred = createDeferred(); + const inboxReader = (service as unknown as { + inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; + }).inboxReader; + const inboxSpy = vi + .spyOn(inboxReader, 'getMessagesFor') + .mockImplementationOnce(async () => await inboxDeferred.promise) + .mockImplementation(async () => inboxMessages); + + const relayPromise = service.relayMemberInboxMessages(teamName, 'alice'); + await Promise.resolve(); + + const oldRun = (service as unknown as { runs: Map }).runs.get(oldRunId); + oldRun.processKilled = true; + oldRun.cancelRequested = true; + oldRun.child.stdin.writable = false; + + const { writeSpy: newWriteSpy } = attachAliveRun(service, teamName, { runId: 'run-new' }); + inboxDeferred.resolve(inboxMessages); + + await expect(relayPromise).resolves.toBe(0); + expect(oldWriteSpy).not.toHaveBeenCalled(); + expect(newWriteSpy).not.toHaveBeenCalled(); + inboxSpy.mockRestore(); + }); + it('marks pure member heartbeat idle as read without relaying it', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 34997fc9..2b988637 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -40,7 +40,7 @@ interface StoreState { installed?: boolean; binaryPath?: string | null; launchError?: string | null; - }; + } | null; } const storeState = {} as StoreState; diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index ced38887..87579c28 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; interface StoreState { fetchSkillsCatalog: ReturnType; @@ -213,12 +214,9 @@ function makeMultimodelStatus( capabilities: { teamLaunch: true, oneShot: true, - extensions: { - plugins: { status: 'supported', ownership: 'provider', reason: null }, - mcp: { status: 'supported', ownership: 'shared', reason: null }, - skills: { status: 'supported', ownership: 'shared', reason: null }, - apiKeys: { status: 'supported', ownership: 'shared', reason: null }, - }, + extensions: createDefaultCliExtensionCapabilities({ + plugins: { status: 'supported', ownership: 'provider-scoped', reason: null }, + }), }, connection: null, backend: null, @@ -405,12 +403,9 @@ describe('SkillsPanel', () => { capabilities: { teamLaunch: true, oneShot: true, - extensions: { - plugins: { status: 'unsupported', ownership: 'provider', reason: null }, - mcp: { status: 'supported', ownership: 'shared', reason: null }, - skills: { status: 'supported', ownership: 'shared', reason: null }, - apiKeys: { status: 'supported', ownership: 'shared', reason: null }, - }, + extensions: createDefaultCliExtensionCapabilities({ + plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null }, + }), }, connection: null, backend: null, diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index e994b3b9..25fa2d96 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -55,12 +55,14 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; +import type { CliInstallationStatus } from '../../../src/shared/types'; import { getMcpDiagnosticKey, getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; +import { createDefaultCliExtensionCapabilities } from '../../../src/shared/utils/providerExtensionCapabilities'; import type { EnrichedPlugin, @@ -137,7 +139,7 @@ const makeSkillDetail = (overrides: Partial = {}): SkillDetail => ( ...overrides, }); -const makeReadyCliStatus = () => ({ +const makeReadyCliStatus = (): CliInstallationStatus => ({ flavor: 'claude' as const, displayName: 'Claude', supportsSelfUpdate: true, @@ -154,7 +156,10 @@ const makeReadyCliStatus = () => ({ providers: [], }); -const makeLimitedMultimodelCliStatus = (section: 'plugins' | 'mcp', reason: string) => ({ +const makeLimitedMultimodelCliStatus = ( + section: 'plugins' | 'mcp', + reason: string +): CliInstallationStatus => ({ flavor: 'agent_teams_orchestrator' as const, displayName: 'Claude Multimodel', supportsSelfUpdate: false, @@ -181,21 +186,22 @@ const makeLimitedMultimodelCliStatus = (section: 'plugins' | 'mcp', reason: stri capabilities: { teamLaunch: true, oneShot: true, - extensions: { + extensions: createDefaultCliExtensionCapabilities({ plugins: { status: section === 'plugins' ? 'unsupported' : 'supported', - ownership: 'shared' as const, + ownership: 'shared', reason: section === 'plugins' ? reason : null, }, mcp: { status: section === 'mcp' ? 'read-only' : 'supported', - ownership: 'shared' as const, + ownership: 'shared', reason: section === 'mcp' ? reason : null, }, - skills: { status: 'supported', ownership: 'shared' as const, reason: null }, - apiKeys: { status: 'supported', ownership: 'shared' as const, reason: null }, - }, + }), }, + statusMessage: null, + connection: null, + backend: null, }, ], }); diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts index 84ab2872..4ee98ca3 100644 --- a/test/renderer/utils/multimodelProviderVisibility.test.ts +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -7,6 +7,7 @@ import { } from '@renderer/utils/multimodelProviderVisibility'; import type { CliInstallationStatus, CliProviderStatus } from '@shared/types'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; function createProvider(providerId: CliProviderStatus['providerId']): CliProviderStatus { return { @@ -21,9 +22,9 @@ function createProvider(providerId: CliProviderStatus['providerId']): CliProvide capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, statusMessage: null, - detailMessage: null, selectedBackendId: null, resolvedBackendId: null, availableBackends: [], diff --git a/test/shared/utils/rateLimitDetector.test.ts b/test/shared/utils/rateLimitDetector.test.ts new file mode 100644 index 00000000..ecdcfb9d --- /dev/null +++ b/test/shared/utils/rateLimitDetector.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest'; + +import { + isRateLimitMessage, + parseRateLimitResetTime, +} from '../../../src/shared/utils/rateLimitDetector'; + +// Helper: every production rate-limit message starts with this substring. +// Prefix test inputs so they clear the parser's rate-limit-context gate. +const RL = "You've hit your limit. "; + +describe('isRateLimitMessage', () => { + it('detects the canonical substring', () => { + expect(isRateLimitMessage("You've hit your limit")).toBe(true); + expect( + isRateLimitMessage("You've hit your limit. Your limit will reset at 3pm (PST).") + ).toBe(true); + }); + + it('returns false for unrelated text', () => { + expect(isRateLimitMessage('All good here')).toBe(false); + expect(isRateLimitMessage('hit the limit')).toBe(false); // missing "You've" + expect(isRateLimitMessage('')).toBe(false); + }); +}); + +describe('parseRateLimitResetTime', () => { + // --------------------------------------------------------------------- + // Rate-limit context gate + // --------------------------------------------------------------------- + + it('returns null for text that is not a rate-limit message', () => { + // Even if the text contains a parseable "reset at X" clause, the parser + // must refuse to interpret it when the rate-limit context is absent. + // Protects against false positives like "reset at 3pm (PST)" appearing + // in unrelated prose. + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime('Please reset your expectations at 3pm (PST).', now) + ).toBeNull(); + expect(parseRateLimitResetTime('Resets in 2 hours.', now)).toBeNull(); + }); + + // --------------------------------------------------------------------- + // Relative durations + // --------------------------------------------------------------------- + + it('parses "resets in N hours"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets in 2 hours.`, now); + expect(result?.toISOString()).toBe('2026-04-17T14:00:00.000Z'); + }); + + it('parses "resets in N minutes"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Will reset in 45 minutes.`, now); + expect(result?.toISOString()).toBe('2026-04-17T12:45:00.000Z'); + }); + + it('parses "resets in N seconds"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets in 90 seconds.`, now); + expect(result?.toISOString()).toBe('2026-04-17T12:01:30.000Z'); + }); + + it('parses "hrs" and "mins" abbreviations', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime(`${RL}Resets in 3 hrs.`, now)?.toISOString() + ).toBe('2026-04-17T15:00:00.000Z'); + expect( + parseRateLimitResetTime(`${RL}Resets in 15 mins.`, now)?.toISOString() + ).toBe('2026-04-17T12:15:00.000Z'); + }); + + it('parses bare "h" / "m" / "s" single-letter units', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect(parseRateLimitResetTime(`${RL}Resets in 2 h.`, now)?.toISOString()).toBe( + '2026-04-17T14:00:00.000Z' + ); + expect(parseRateLimitResetTime(`${RL}Resets in 30 m.`, now)?.toISOString()).toBe( + '2026-04-17T12:30:00.000Z' + ); + expect(parseRateLimitResetTime(`${RL}Resets in 45 s.`, now)?.toISOString()).toBe( + '2026-04-17T12:00:45.000Z' + ); + }); + + it('parses "resets in about 30 minutes" with filler words', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime( + `${RL}Your limit will reset in about 30 minutes.`, + now + ); + expect(result?.toISOString()).toBe('2026-04-17T12:30:00.000Z'); + }); + + it('parses "around" and "~" filler variants', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime(`${RL}Your limit will reset in around 30 minutes.`, now)?.toISOString() + ).toBe('2026-04-17T12:30:00.000Z'); + expect( + parseRateLimitResetTime(`${RL}Your limit will reset in ~ 45 seconds.`, now)?.toISOString() + ).toBe('2026-04-17T12:00:45.000Z'); + }); + + it('parses fractional hours', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets in 1.5 hours.`, now); + expect(result?.toISOString()).toBe('2026-04-17T13:30:00.000Z'); + }); + + // --------------------------------------------------------------------- + // Absolute clock times with timezone + // --------------------------------------------------------------------- + + it('parses "resets at 3pm (PST)"', () => { + // 3pm PST = 23:00 UTC (PST = UTC-8) + const now = new Date('2026-04-17T12:00:00Z'); // earlier than 23:00 UTC + const result = parseRateLimitResetTime( + `${RL}Your limit will reset at 3pm (PST).`, + now + ); + expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z'); + }); + + it('parses "resets at 3:30 pm (PST)"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime( + `${RL}Your limit will reset at 3:30 pm (PST).`, + now + ); + expect(result?.toISOString()).toBe('2026-04-17T23:30:00.000Z'); + }); + + it('parses 24-hour time with UTC', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime( + `${RL}Your limit will reset at 15:30 UTC.`, + now + ); + expect(result?.toISOString()).toBe('2026-04-17T15:30:00.000Z'); + }); + + it('parses bare timezone abbreviation without parentheses', () => { + // Regex group 5 path: "3pm PST" (no parens) should parse same as "(PST)". + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime( + `${RL}Your limit will reset at 3pm PST.`, + now + ); + expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z'); + }); + + it('parses non-PST North American timezones', () => { + // Cover each zone in the whitelist — regression guard against map typos. + const now = new Date('2026-04-17T02:00:00Z'); + // 3am EST = UTC-5 → 08:00 UTC + expect( + parseRateLimitResetTime(`${RL}Resets at 3am (EST).`, now)?.toISOString() + ).toBe('2026-04-17T08:00:00.000Z'); + // 3am EDT = UTC-4 → 07:00 UTC + expect( + parseRateLimitResetTime(`${RL}Resets at 3am (EDT).`, now)?.toISOString() + ).toBe('2026-04-17T07:00:00.000Z'); + // 3am CST = UTC-6 → 09:00 UTC + expect( + parseRateLimitResetTime(`${RL}Resets at 3am (CST).`, now)?.toISOString() + ).toBe('2026-04-17T09:00:00.000Z'); + // 3am MDT = UTC-6 → 09:00 UTC + expect( + parseRateLimitResetTime(`${RL}Resets at 3am (MDT).`, now)?.toISOString() + ).toBe('2026-04-17T09:00:00.000Z'); + }); + + it('rolls forward to tomorrow when the time has already passed today', () => { + // 3pm PST = 23:00 UTC; if "now" is 23:30 UTC, the parsed 23:00 should + // roll to tomorrow rather than return a time in the past. + const now = new Date('2026-04-17T23:30:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 3pm (PST).`, now); + expect(result?.toISOString()).toBe('2026-04-18T23:00:00.000Z'); + }); + + it('does NOT roll forward for near-present timestamps (within the 1-minute tolerance)', () => { + // Parsed time is 20s in the past (stale message / clock skew). A full + // 24h rollover here would trip the scheduler's 12h ceiling and silently + // drop auto-resume. Instead, the parser returns the near-past time and + // lets the scheduler's buffer + Math.max(0, ...) clamp take over. + const now = new Date('2026-04-17T23:00:20Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 3pm (PST).`, now); + // 3pm PST = 23:00 UTC (today) — stays in the past, not rolled. + expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z'); + }); + + it('resolves the zone-local calendar date when UTC and zone disagree on the day', () => { + // now = 2026-04-18T01:00:00Z which is still 2026-04-17 17:00 PST. + // "8pm (PST)" on that PST day = 2026-04-17T20:00 PST = 2026-04-18T04:00Z. + // A naive UTC-anchored build would emit 2026-04-19T04:00Z (24h off). + const now = new Date('2026-04-18T01:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 8pm (PST).`, now); + expect(result?.toISOString()).toBe('2026-04-18T04:00:00.000Z'); + }); + + it('handles the mirror case for positive offsets crossing the UTC day', () => { + // 02:00 UTC today is already in the past vs 23:00 UTC → roll to tomorrow. + const now = new Date('2026-04-17T23:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 02:00 UTC.`, now); + expect(result?.toISOString()).toBe('2026-04-18T02:00:00.000Z'); + }); + + it('handles 12am (midnight) correctly', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 12am UTC.`, now); + // Same day midnight is already in the past relative to noon; rolls to next day. + expect(result?.toISOString()).toBe('2026-04-18T00:00:00.000Z'); + }); + + it('handles 12pm (noon) correctly', () => { + const now = new Date('2026-04-17T06:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Resets at 12pm UTC.`, now); + expect(result?.toISOString()).toBe('2026-04-17T12:00:00.000Z'); + }); + + // --------------------------------------------------------------------- + // Day-shift qualifiers — should bail out rather than guess today/tomorrow + // --------------------------------------------------------------------- + + it('returns null when the reset is qualified with "next week"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime(`${RL}Reset at 3pm (PST) next week.`, now) + ).toBeNull(); + }); + + it('returns null when the reset is qualified with "tomorrow"', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime(`${RL}Reset at 9am UTC tomorrow.`, now) + ).toBeNull(); + }); + + it('returns null when the reset is qualified with a day of week', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect( + parseRateLimitResetTime(`${RL}Reset at 3pm (PST) on Tuesday.`, now) + ).toBeNull(); + expect( + parseRateLimitResetTime(`${RL}Reset at 9am UTC on Mon.`, now) + ).toBeNull(); + }); + + // --------------------------------------------------------------------- + // Unparseable / ambiguous cases + // --------------------------------------------------------------------- + + it('returns null when no reset time is present', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect(parseRateLimitResetTime("You've hit your limit.", now)).toBeNull(); + expect(parseRateLimitResetTime('', now)).toBeNull(); + }); + + it('returns null for unknown parenthesized timezone abbreviations', () => { + // Parenthesized TZ is authoritative — unknown means "sender meant a + // specific zone we don't model"; bail out rather than guess. + const now = new Date('2026-04-17T12:00:00Z'); + expect(parseRateLimitResetTime(`${RL}Resets at 3pm (CEST).`, now)).toBeNull(); + }); + + it('falls back to local time when a trailing word looks like a TZ but is not one', () => { + // "3pm today" used to capture "TODAY" as an unknown TZ and suppress + // the whole message. Now the parser ignores the bare token and treats + // "3pm" as user-local. Assert a parse happens (non-null result) rather + // than pinning the UTC value, since local time depends on the runner. + const now = new Date('2026-04-17T06:00:00Z'); + const result = parseRateLimitResetTime(`${RL}Reset at 3pm today.`, now); + expect(result).not.toBeNull(); + }); + + it('returns null for invalid clock values', () => { + const now = new Date('2026-04-17T12:00:00Z'); + expect(parseRateLimitResetTime(`${RL}Resets at 25:00 UTC.`, now)).toBeNull(); + expect(parseRateLimitResetTime(`${RL}Resets at 10:99 UTC.`, now)).toBeNull(); + }); + + it('returns null for negative relative durations', () => { + const now = new Date('2026-04-17T12:00:00Z'); + // Regex requires \d+ so "-2" won't match; we'd get null anyway, but verify. + expect(parseRateLimitResetTime(`${RL}Resets in -2 hours.`, now)).toBeNull(); + }); +});