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