feat(team): enable task stall monitoring by default
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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<TeamLaunchRequest>;
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ interface TeamLogSourceTrackingHandle {
|
|||
): 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 {
|
||||
private readonly activeTeams = new Set<string>();
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
@ -61,6 +66,7 @@ export class ActiveTeamRegistry {
|
|||
this.reconcileTimer = setInterval(() => {
|
||||
void this.reconcile();
|
||||
}, this.reconcileIntervalMs);
|
||||
unrefBackgroundTimer(this.reconcileTimer);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ interface TeamObservationState {
|
|||
lastActivationAtMs: number;
|
||||
}
|
||||
|
||||
function unrefBackgroundTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
const maybeTimer = timer as { unref?: () => void };
|
||||
maybeTimer.unref?.();
|
||||
}
|
||||
|
||||
export class TeamTaskStallMonitor {
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private nudgeTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
|
|
@ -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<string>();
|
||||
if (isOpenCodeTaskStallRemediationEnabled()) {
|
||||
if (openCodeRemediationEnabled) {
|
||||
const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts);
|
||||
for (const alert of remediatedAlerts) {
|
||||
alertedEpochKeys.add(alert.epochKey);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.5 MiB |
|
|
@ -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()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
</div>
|
||||
{isTeamProvisioning ? (
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||