feat(team): enable task stall monitoring by default

This commit is contained in:
777genius 2026-04-28 17:40:16 +03:00
parent 28d0ab20c0
commit 7bb24934a5
22 changed files with 213 additions and 40 deletions

View file

@ -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', name: 'team-transcript-project-resolver-sonar-override',
files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'], files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'],

View file

@ -3,6 +3,7 @@ import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMoni
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
import { getAppIconPath } from '@main/utils/appIcon'; import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { stripMarkdown } from '@main/utils/textFormatting'; import { stripMarkdown } from '@main/utils/textFormatting';
import { import {
TEAM_ADD_MEMBER, 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( async function handleCreateTeam(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
request: unknown request: unknown
@ -1769,16 +1777,12 @@ async function handleCreateTeam(
if (!validation.valid) { if (!validation.valid) {
return { success: false, error: validation.error }; return { success: false, error: validation.error };
} }
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
return wrapTeamHandler('create', () => { return wrapTeamHandler('create', () => {
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
return getTeamProvisioningService().createTeam(validation.value, (progress) => { return getTeamProvisioningService().createTeam(validation.value, (progress) => {
try { sendProvisioningProgress(progressTargetWindow, progress);
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}`);
}
}); });
}); });
} }
@ -1790,6 +1794,7 @@ async function handleLaunchTeam(
if (!request || typeof request !== 'object') { if (!request || typeof request !== 'object') {
return { success: false, error: 'Invalid team launch request' }; return { success: false, error: 'Invalid team launch request' };
} }
const progressTargetWindow = BrowserWindow.fromWebContents(event.sender);
const payload = request as Partial<TeamLaunchRequest>; const payload = request as Partial<TeamLaunchRequest>;
const validatedTeamName = validateTeamName(payload.teamName); const validatedTeamName = validateTeamName(payload.teamName);
@ -1912,12 +1917,7 @@ async function handleLaunchTeam(
return wrapTeamHandler('create', () => return wrapTeamHandler('create', () =>
getTeamProvisioningService().createTeam(createRequest, (progress) => { getTeamProvisioningService().createTeam(createRequest, (progress) => {
try { sendProvisioningProgress(progressTargetWindow, progress);
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}`);
}
}) })
); );
} }
@ -1985,12 +1985,7 @@ async function handleLaunchTeam(
: undefined, : undefined,
}, },
(progress) => { (progress) => {
try { sendProvisioningProgress(progressTargetWindow, progress);
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}`);
}
} }
); );
}); });

View file

@ -16,6 +16,11 @@ interface TeamLogSourceTrackingHandle {
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>; ): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
} }
function unrefBackgroundTimer(timer: ReturnType<typeof setInterval>): void {
const maybeTimer = timer as { unref?: () => void };
maybeTimer.unref?.();
}
export class ActiveTeamRegistry { export class ActiveTeamRegistry {
private readonly activeTeams = new Set<string>(); private readonly activeTeams = new Set<string>();
private reconcileTimer: ReturnType<typeof setInterval> | null = null; private reconcileTimer: ReturnType<typeof setInterval> | null = null;
@ -61,6 +66,7 @@ export class ActiveTeamRegistry {
this.reconcileTimer = setInterval(() => { this.reconcileTimer = setInterval(() => {
void this.reconcile(); void this.reconcile();
}, this.reconcileIntervalMs); }, this.reconcileIntervalMs);
unrefBackgroundTimer(this.reconcileTimer);
} }
async stop(): Promise<void> { async stop(): Promise<void> {

View file

@ -26,6 +26,11 @@ interface TeamObservationState {
lastActivationAtMs: number; lastActivationAtMs: number;
} }
function unrefBackgroundTimer(timer: ReturnType<typeof setTimeout>): void {
const maybeTimer = timer as { unref?: () => void };
maybeTimer.unref?.();
}
export class TeamTaskStallMonitor { export class TeamTaskStallMonitor {
private scanTimer: ReturnType<typeof setTimeout> | null = null; private scanTimer: ReturnType<typeof setTimeout> | null = null;
private nudgeTimer: ReturnType<typeof setTimeout> | null = null; private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
@ -68,10 +73,10 @@ export class TeamTaskStallMonitor {
} }
noteTeamChange(event: TeamChangeEvent): void { noteTeamChange(event: TeamChangeEvent): void {
this.registry.noteTeamChange(event);
if (!isTeamTaskStallScannerEnabled()) { if (!isTeamTaskStallScannerEnabled()) {
return; return;
} }
this.registry.noteTeamChange(event);
if ( if (
event.type === 'member-spawn' || event.type === 'member-spawn' ||
@ -103,6 +108,7 @@ export class TeamTaskStallMonitor {
this.scanTimer = null; this.scanTimer = null;
void this.runScan(); void this.runScan();
}, delayMs); }, delayMs);
unrefBackgroundTimer(this.scanTimer);
} }
private scheduleNudgedScan(): void { private scheduleNudgedScan(): void {
@ -113,6 +119,7 @@ export class TeamTaskStallMonitor {
this.nudgeTimer = null; this.nudgeTimer = null;
void this.runScan(); void this.runScan();
}, 5_000); }, 5_000);
unrefBackgroundTimer(this.nudgeTimer);
} }
private async runScan(): Promise<void> { private async runScan(): Promise<void> {
@ -179,10 +186,11 @@ export class TeamTaskStallMonitor {
evaluations.push(this.policy.evaluateReview({ now, task, snapshot })); evaluations.push(this.policy.evaluateReview({ now, task, snapshot }));
} }
const remediationOnly = const fullMonitorEnabled = isTeamTaskStallMonitorEnabled();
isOpenCodeTaskStallRemediationEnabled() && !isTeamTaskStallMonitorEnabled(); const openCodeRemediationEnabled = isOpenCodeTaskStallRemediationEnabled();
const scopedTaskIds = remediationOnly ? this.getOpenCodeOwnedTaskIds(snapshot) : undefined; const openCodeOnlyMode = openCodeRemediationEnabled && !fullMonitorEnabled;
const journalEvaluations = remediationOnly const scopedTaskIds = openCodeOnlyMode ? this.getOpenCodeOwnedTaskIds(snapshot) : undefined;
const journalEvaluations = openCodeOnlyMode
? evaluations.filter((evaluation) => this.isOpenCodeOwnerWorkEvaluation(snapshot, evaluation)) ? evaluations.filter((evaluation) => this.isOpenCodeOwnerWorkEvaluation(snapshot, evaluation))
: evaluations; : evaluations;
const activeTaskIds = [ const activeTaskIds = [
@ -205,7 +213,7 @@ export class TeamTaskStallMonitor {
} }
const alertedEpochKeys = new Set<string>(); const alertedEpochKeys = new Set<string>();
if (isOpenCodeTaskStallRemediationEnabled()) { if (openCodeRemediationEnabled) {
const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts); const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts);
for (const alert of remediatedAlerts) { for (const alert of remediatedAlerts) {
alertedEpochKeys.add(alert.epochKey); alertedEpochKeys.add(alert.epochKey);

View file

@ -22,19 +22,25 @@ function readInt(value: string | undefined, defaultValue: number): number {
} }
export function isTeamTaskStallMonitorEnabled(): boolean { 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 { 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 { export function isTeamTaskStallScannerEnabled(): boolean {
// The scanner must run for either full monitoring or OpenCode-only remediation mode.
return isTeamTaskStallMonitorEnabled() || isOpenCodeTaskStallRemediationEnabled(); return isTeamTaskStallMonitorEnabled() || isOpenCodeTaskStallRemediationEnabled();
} }
export function isTeamTaskStallAlertsEnabled(): boolean { 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 { export function getTeamTaskStallScanIntervalMs(): number {
@ -50,5 +56,6 @@ export function getTeamTaskStallActivationGraceMs(): number {
} }
export function getOpenCodeWeakStartStallThresholdMs(): 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);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -8,6 +8,7 @@ import {
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -198,6 +199,7 @@ export const AddMemberDialog = ({
showWorktreeIsolationControls showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault} teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()}
/> />
</div> </div>

View file

@ -23,6 +23,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { import {
agentAvatarUrl, agentAvatarUrl,
buildMemberColorMap, buildMemberColorMap,
@ -579,6 +580,7 @@ export const EditTeamDialog = ({
disableAddMember={isTeamAlive} disableAddMember={isTeamAlive}
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live." addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
memberWarningById={memberWarningById} memberWarningById={memberWarningById}
disableGeminiOption={isGeminiUiFrozen()}
/> />
</div> </div>
{isTeamProvisioning ? ( {isTeamProvisioning ? (

View file

@ -8,10 +8,37 @@ describe('TeamTaskStallMonitor', () => {
vi.unstubAllEnvs(); 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.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_SCAN_INTERVAL_MS', '1000');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');
@ -62,6 +89,7 @@ describe('TeamTaskStallMonitor', () => {
}; };
const notifier = { const notifier = {
notifyLead: vi.fn(async () => undefined), notifyLead: vi.fn(async () => undefined),
notifyOpenCodeOwners: vi.fn(async () => []),
}; };
const monitor = new TeamTaskStallMonitor( 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 () => { it('uses OpenCode owner remediation without lead alerts when only remediation is enabled', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); 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_ALERTS_ENABLED', 'false');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); 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_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 () => { it('does not journal non-OpenCode task alerts when only OpenCode remediation is enabled', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); 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_ALERTS_ENABLED', 'false');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); 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_STARTUP_GRACE_MS', '1');
@ -233,10 +334,8 @@ describe('TeamTaskStallMonitor', () => {
expect(journal.markAlerted).not.toHaveBeenCalled(); 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.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_SCAN_INTERVAL_MS', '1000');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');

View file

@ -16,15 +16,15 @@ afterEach(() => {
}); });
describe('stallMonitor feature gates', () => { describe('stallMonitor feature gates', () => {
it('defaults both monitor and alerts to disabled', () => { it('defaults general monitor, OpenCode remediation, scanner, and alerts to enabled', () => {
expect(isTeamTaskStallMonitorEnabled()).toBe(false); expect(isTeamTaskStallMonitorEnabled()).toBe(true);
expect(isOpenCodeTaskStallRemediationEnabled()).toBe(false); expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true);
expect(isTeamTaskStallScannerEnabled()).toBe(false); expect(isTeamTaskStallScannerEnabled()).toBe(true);
expect(isTeamTaskStallAlertsEnabled()).toBe(false); expect(isTeamTaskStallAlertsEnabled()).toBe(true);
expect(getTeamTaskStallScanIntervalMs()).toBe(60_000); expect(getTeamTaskStallScanIntervalMs()).toBe(60_000);
expect(getTeamTaskStallStartupGraceMs()).toBe(180_000); expect(getTeamTaskStallStartupGraceMs()).toBe(180_000);
expect(getTeamTaskStallActivationGraceMs()).toBe(120_000); expect(getTeamTaskStallActivationGraceMs()).toBe(120_000);
expect(getOpenCodeWeakStartStallThresholdMs()).toBe(360_000); expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000);
}); });
it('parses truthy and falsy environment values', () => { 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', () => { it('enables the scanner when only OpenCode remediation is enabled', () => {
vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true');
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false');
expect(isTeamTaskStallMonitorEnabled()).toBe(false); expect(isTeamTaskStallMonitorEnabled()).toBe(false);
expect(isTeamTaskStallScannerEnabled()).toBe(true); 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);
});
}); });