diff --git a/eslint.config.js b/eslint.config.js index e207d1da..1fe2a49c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -459,6 +459,35 @@ export default defineConfig([ }, }, + { + name: 'electron-main-safe-renderer-send-guard', + files: ['src/main/**/*.ts'], + ignores: ['src/main/utils/safeWebContentsSend.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.type='MemberExpression'][callee.object.property.name='webContents']", + message: + 'Use safeSendToRenderer(...) instead of direct webContents.send(...) in the main process.', + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.type='MemberExpression'][callee.object.property.name='sender']", + message: + 'Use safeSendToRenderer(BrowserWindow.fromWebContents(event.sender), ...) instead of direct event.sender.send(...) in the main process.', + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.name='contents']", + message: + 'Use safeSendToRenderer(...) instead of aliasing webContents and calling contents.send(...) in the main process.', + }, + ], + }, + }, + { name: 'team-transcript-project-resolver-sonar-override', files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'], diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index fc122faa..d8ff4d8b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -3,6 +3,7 @@ import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMoni import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { TEAM_ADD_MEMBER, @@ -1761,6 +1762,13 @@ async function handleGetClaudeLogs( }); } +function sendProvisioningProgress( + targetWindow: BrowserWindow | null, + progress: TeamProvisioningProgress +): void { + safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown @@ -1769,16 +1777,12 @@ async function handleCreateTeam( if (!validation.valid) { return { success: false, error: validation.error }; } + const progressTargetWindow = BrowserWindow.fromWebContents(event.sender); return wrapTeamHandler('create', () => { addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); return getTeamProvisioningService().createTeam(validation.value, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); }); }); } @@ -1790,6 +1794,7 @@ async function handleLaunchTeam( if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid team launch request' }; } + const progressTargetWindow = BrowserWindow.fromWebContents(event.sender); const payload = request as Partial; const validatedTeamName = validateTeamName(payload.teamName); @@ -1912,12 +1917,7 @@ async function handleLaunchTeam( return wrapTeamHandler('create', () => getTeamProvisioningService().createTeam(createRequest, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit draft launch provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); }) ); } @@ -1985,12 +1985,7 @@ async function handleLaunchTeam( : undefined, }, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit launch provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); } ); }); diff --git a/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts index 8e838772..75a54c77 100644 --- a/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts +++ b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts @@ -16,6 +16,11 @@ interface TeamLogSourceTrackingHandle { ): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>; } +function unrefBackgroundTimer(timer: ReturnType): void { + const maybeTimer = timer as { unref?: () => void }; + maybeTimer.unref?.(); +} + export class ActiveTeamRegistry { private readonly activeTeams = new Set(); private reconcileTimer: ReturnType | null = null; @@ -61,6 +66,7 @@ export class ActiveTeamRegistry { this.reconcileTimer = setInterval(() => { void this.reconcile(); }, this.reconcileIntervalMs); + unrefBackgroundTimer(this.reconcileTimer); } async stop(): Promise { diff --git a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts index 68d1eaed..02b197ac 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts @@ -26,6 +26,11 @@ interface TeamObservationState { lastActivationAtMs: number; } +function unrefBackgroundTimer(timer: ReturnType): void { + const maybeTimer = timer as { unref?: () => void }; + maybeTimer.unref?.(); +} + export class TeamTaskStallMonitor { private scanTimer: ReturnType | null = null; private nudgeTimer: ReturnType | null = null; @@ -68,10 +73,10 @@ export class TeamTaskStallMonitor { } noteTeamChange(event: TeamChangeEvent): void { - this.registry.noteTeamChange(event); if (!isTeamTaskStallScannerEnabled()) { return; } + this.registry.noteTeamChange(event); if ( event.type === 'member-spawn' || @@ -103,6 +108,7 @@ export class TeamTaskStallMonitor { this.scanTimer = null; void this.runScan(); }, delayMs); + unrefBackgroundTimer(this.scanTimer); } private scheduleNudgedScan(): void { @@ -113,6 +119,7 @@ export class TeamTaskStallMonitor { this.nudgeTimer = null; void this.runScan(); }, 5_000); + unrefBackgroundTimer(this.nudgeTimer); } private async runScan(): Promise { @@ -179,10 +186,11 @@ export class TeamTaskStallMonitor { evaluations.push(this.policy.evaluateReview({ now, task, snapshot })); } - const remediationOnly = - isOpenCodeTaskStallRemediationEnabled() && !isTeamTaskStallMonitorEnabled(); - const scopedTaskIds = remediationOnly ? this.getOpenCodeOwnedTaskIds(snapshot) : undefined; - const journalEvaluations = remediationOnly + const fullMonitorEnabled = isTeamTaskStallMonitorEnabled(); + const openCodeRemediationEnabled = isOpenCodeTaskStallRemediationEnabled(); + const openCodeOnlyMode = openCodeRemediationEnabled && !fullMonitorEnabled; + const scopedTaskIds = openCodeOnlyMode ? this.getOpenCodeOwnedTaskIds(snapshot) : undefined; + const journalEvaluations = openCodeOnlyMode ? evaluations.filter((evaluation) => this.isOpenCodeOwnerWorkEvaluation(snapshot, evaluation)) : evaluations; const activeTaskIds = [ @@ -205,7 +213,7 @@ export class TeamTaskStallMonitor { } const alertedEpochKeys = new Set(); - if (isOpenCodeTaskStallRemediationEnabled()) { + if (openCodeRemediationEnabled) { const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts); for (const alert of remediatedAlerts) { alertedEpochKeys.add(alert.epochKey); diff --git a/src/main/services/team/stallMonitor/featureGates.ts b/src/main/services/team/stallMonitor/featureGates.ts index 2f3e9465..d3c2c551 100644 --- a/src/main/services/team/stallMonitor/featureGates.ts +++ b/src/main/services/team/stallMonitor/featureGates.ts @@ -22,19 +22,25 @@ function readInt(value: string | undefined, defaultValue: number): number { } export function isTeamTaskStallMonitorEnabled(): boolean { - return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false); + // General stall monitor for all providers. When enabled, stalled work/review tasks are + // evaluated and routed to the normal alert pipeline. + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, true); } export function isOpenCodeTaskStallRemediationEnabled(): boolean { - return readEnabledFlag(process.env.CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED, false); + // OpenCode-specific enhancement. It can directly nudge the OpenCode task owner before + // falling back to the lead alert path. + return readEnabledFlag(process.env.CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED, true); } export function isTeamTaskStallScannerEnabled(): boolean { + // The scanner must run for either full monitoring or OpenCode-only remediation mode. return isTeamTaskStallMonitorEnabled() || isOpenCodeTaskStallRemediationEnabled(); } export function isTeamTaskStallAlertsEnabled(): boolean { - return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false); + // Lead/system notifications for alerts that are not handled by provider-specific remediation. + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, true); } export function getTeamTaskStallScanIntervalMs(): number { @@ -50,5 +56,6 @@ export function getTeamTaskStallActivationGraceMs(): number { } export function getOpenCodeWeakStartStallThresholdMs(): number { - return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 6 * 60_000); + // Shorter OpenCode threshold for "started work" comments that do not contain concrete progress. + return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 120_000); } diff --git a/src/renderer/assets/participant-avatars/01.png b/src/renderer/assets/participant-avatars/01.png index df776c6a..555980fc 100644 Binary files a/src/renderer/assets/participant-avatars/01.png and b/src/renderer/assets/participant-avatars/01.png differ diff --git a/src/renderer/assets/participant-avatars/02.png b/src/renderer/assets/participant-avatars/02.png index a4478433..868dacec 100644 Binary files a/src/renderer/assets/participant-avatars/02.png and b/src/renderer/assets/participant-avatars/02.png differ diff --git a/src/renderer/assets/participant-avatars/03.png b/src/renderer/assets/participant-avatars/03.png index aa5c49b9..d2e053ca 100644 Binary files a/src/renderer/assets/participant-avatars/03.png and b/src/renderer/assets/participant-avatars/03.png differ diff --git a/src/renderer/assets/participant-avatars/04.png b/src/renderer/assets/participant-avatars/04.png index e174d49c..a9269b1f 100644 Binary files a/src/renderer/assets/participant-avatars/04.png and b/src/renderer/assets/participant-avatars/04.png differ diff --git a/src/renderer/assets/participant-avatars/05.png b/src/renderer/assets/participant-avatars/05.png index 36db6099..b3008d0e 100644 Binary files a/src/renderer/assets/participant-avatars/05.png and b/src/renderer/assets/participant-avatars/05.png differ diff --git a/src/renderer/assets/participant-avatars/06.png b/src/renderer/assets/participant-avatars/06.png index 77664676..10bdde45 100644 Binary files a/src/renderer/assets/participant-avatars/06.png and b/src/renderer/assets/participant-avatars/06.png differ diff --git a/src/renderer/assets/participant-avatars/07.png b/src/renderer/assets/participant-avatars/07.png index e375112c..71092d70 100644 Binary files a/src/renderer/assets/participant-avatars/07.png and b/src/renderer/assets/participant-avatars/07.png differ diff --git a/src/renderer/assets/participant-avatars/08.png b/src/renderer/assets/participant-avatars/08.png index 42c137a9..ee2d3b23 100644 Binary files a/src/renderer/assets/participant-avatars/08.png and b/src/renderer/assets/participant-avatars/08.png differ diff --git a/src/renderer/assets/participant-avatars/09.png b/src/renderer/assets/participant-avatars/09.png index 3c50cee5..ceebd09a 100644 Binary files a/src/renderer/assets/participant-avatars/09.png and b/src/renderer/assets/participant-avatars/09.png differ diff --git a/src/renderer/assets/participant-avatars/10.png b/src/renderer/assets/participant-avatars/10.png index 9e433a6c..388d005f 100644 Binary files a/src/renderer/assets/participant-avatars/10.png and b/src/renderer/assets/participant-avatars/10.png differ diff --git a/src/renderer/assets/participant-avatars/11.png b/src/renderer/assets/participant-avatars/11.png index 88d9e562..2dbf8426 100644 Binary files a/src/renderer/assets/participant-avatars/11.png and b/src/renderer/assets/participant-avatars/11.png differ diff --git a/src/renderer/assets/participant-avatars/12.png b/src/renderer/assets/participant-avatars/12.png index 80055539..1706574f 100644 Binary files a/src/renderer/assets/participant-avatars/12.png and b/src/renderer/assets/participant-avatars/12.png differ diff --git a/src/renderer/assets/participant-avatars/13.png b/src/renderer/assets/participant-avatars/13.png index 1a4f6174..3d26dee8 100644 Binary files a/src/renderer/assets/participant-avatars/13.png and b/src/renderer/assets/participant-avatars/13.png differ diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 6f28f45f..09a483d4 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -8,6 +8,7 @@ import { validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { Button } from '@renderer/components/ui/button'; +import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { Dialog, DialogContent, @@ -198,6 +199,7 @@ export const AddMemberDialog = ({ showWorktreeIsolationControls teammateWorktreeDefault={teammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} + disableGeminiOption={isGeminiUiFrozen()} /> diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 828b7982..47c01efc 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -23,6 +23,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { agentAvatarUrl, buildMemberColorMap, @@ -579,6 +580,7 @@ export const EditTeamDialog = ({ disableAddMember={isTeamAlive} addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live." memberWarningById={memberWarningById} + disableGeminiOption={isGeminiUiFrozen()} /> {isTeamProvisioning ? ( diff --git a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts index 92c37cda..82209636 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts @@ -8,10 +8,37 @@ describe('TeamTaskStallMonitor', () => { vi.unstubAllEnvs(); }); - it('runs end-to-end and notifies only after a second confirmed scan', async () => { + it('does not start scans or track team events when scanner gates are explicitly disabled', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'false'); + + const registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => []), + }; + const monitor = new TeamTaskStallMonitor( + registry as never, + { getSnapshot: vi.fn() } as never, + { evaluateWork: vi.fn(), evaluateReview: vi.fn() } as never, + { reconcileScan: vi.fn(), markAlerted: vi.fn() } as never, + { notifyLead: vi.fn(), notifyOpenCodeOwners: vi.fn() } as never + ); + + monitor.start(); + monitor.noteTeamChange({ + type: 'lead-activity', + teamName: 'demo', + detail: 'active', + }); + + expect(registry.start).not.toHaveBeenCalled(); + expect(registry.noteTeamChange).not.toHaveBeenCalled(); + }); + + it('defaults to monitoring non-OpenCode work stalls and notifies lead after a second confirmed scan', async () => { vi.useFakeTimers(); - vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'true'); - vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'true'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); @@ -62,6 +89,7 @@ describe('TeamTaskStallMonitor', () => { }; const notifier = { notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async () => []), }; const monitor = new TeamTaskStallMonitor( @@ -85,9 +113,81 @@ describe('TeamTaskStallMonitor', () => { ); }); + it('defaults to OpenCode owner remediation without duplicate lead alerts when remediation is accepted', async () => { + vi.useFakeTimers(); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); + + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + progressSignal: 'weak_start_only', + epochKey: 'task-a:epoch', + reason: 'Potential work stall after weak start-only task comment.', + }; + const journal = { + reconcileScan: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([readyEvaluation]), + markAlerted: vi.fn(async () => undefined), + }; + const notifier = { + notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async (_teamName: string, alerts: unknown[]) => alerts), + }; + const monitor = new TeamTaskStallMonitor( + { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + } as never, + { + getSnapshot: vi.fn(async () => ({ + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + })), + } as never, + { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + } as never, + journal as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + + expect(notifier.notifyOpenCodeOwners).toHaveBeenCalledTimes(1); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.reconcileScan).toHaveBeenLastCalledWith( + expect.not.objectContaining({ + scopeTaskIds: expect.any(Array), + }) + ); + expect(journal.markAlerted).toHaveBeenCalledWith( + 'demo', + 'task-a:epoch', + expect.any(String) + ); + }); + it('uses OpenCode owner remediation without lead alerts when only remediation is enabled', async () => { vi.useFakeTimers(); vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'false'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); @@ -167,6 +267,7 @@ describe('TeamTaskStallMonitor', () => { it('does not journal non-OpenCode task alerts when only OpenCode remediation is enabled', async () => { vi.useFakeTimers(); vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'false'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); @@ -233,10 +334,8 @@ describe('TeamTaskStallMonitor', () => { expect(journal.markAlerted).not.toHaveBeenCalled(); }); - it('falls back to lead notification when OpenCode remediation is not accepted', async () => { + it('defaults to lead fallback when OpenCode remediation is not accepted', async () => { vi.useFakeTimers(); - vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); - vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'true'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); diff --git a/test/main/services/team/stallMonitor/featureGates.test.ts b/test/main/services/team/stallMonitor/featureGates.test.ts index 3b9fa951..9ac0cf69 100644 --- a/test/main/services/team/stallMonitor/featureGates.test.ts +++ b/test/main/services/team/stallMonitor/featureGates.test.ts @@ -16,15 +16,15 @@ afterEach(() => { }); describe('stallMonitor feature gates', () => { - it('defaults both monitor and alerts to disabled', () => { - expect(isTeamTaskStallMonitorEnabled()).toBe(false); - expect(isOpenCodeTaskStallRemediationEnabled()).toBe(false); - expect(isTeamTaskStallScannerEnabled()).toBe(false); - expect(isTeamTaskStallAlertsEnabled()).toBe(false); + it('defaults general monitor, OpenCode remediation, scanner, and alerts to enabled', () => { + expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); + expect(isTeamTaskStallAlertsEnabled()).toBe(true); expect(getTeamTaskStallScanIntervalMs()).toBe(60_000); expect(getTeamTaskStallStartupGraceMs()).toBe(180_000); expect(getTeamTaskStallActivationGraceMs()).toBe(120_000); - expect(getOpenCodeWeakStartStallThresholdMs()).toBe(360_000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); }); it('parses truthy and falsy environment values', () => { @@ -48,8 +48,33 @@ describe('stallMonitor feature gates', () => { it('enables the scanner when only OpenCode remediation is enabled', () => { vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); expect(isTeamTaskStallMonitorEnabled()).toBe(false); expect(isTeamTaskStallScannerEnabled()).toBe(true); }); + + it('allows explicit falsy values to disable default-enabled gates', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'no'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', '0'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(false); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(false); + expect(isTeamTaskStallScannerEnabled()).toBe(false); + expect(isTeamTaskStallAlertsEnabled()).toBe(false); + }); + + it('falls back to new defaults for invalid environment values', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS', 'invalid'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); + expect(isTeamTaskStallAlertsEnabled()).toBe(true); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); + }); });