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',
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 { 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);
}
);
});

View file

@ -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> {

View file

@ -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);

View file

@ -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);
}

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,
} 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>

View file

@ -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 ? (

View file

@ -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');

View file

@ -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);
});
});