From 376480b84fd1b6526ac6446443d54900d610c4c2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 20:01:05 +0300 Subject: [PATCH] feat(team): improve stall monitor signals --- ...xSessionFileRecentProjectsSourceAdapter.ts | 287 ++++++++++++++++++ .../createRecentProjectsFeature.ts | 13 +- src/main/index.ts | 2 +- .../TaskProgressSignalClassifier.ts | 105 +++++++ .../team/stallMonitor/TeamTaskStallJournal.ts | 5 + .../team/stallMonitor/TeamTaskStallMonitor.ts | 77 ++++- .../stallMonitor/TeamTaskStallNotifier.ts | 150 ++++++++- .../team/stallMonitor/TeamTaskStallPolicy.ts | 26 +- .../TeamTaskStallSnapshotSource.ts | 54 +++- .../team/stallMonitor/TeamTaskStallTypes.ts | 8 +- .../team/stallMonitor/featureGates.ts | 12 + .../stream/BoardTaskLogStreamService.ts | 144 ++++++++- .../chat/viewers/MarkdownViewer.tsx | 2 +- .../components/team/activity/ActivityItem.tsx | 7 +- .../team/taskLogs/TaskLogStreamSection.tsx | 3 + ...ionFileRecentProjectsSourceAdapter.test.ts | 258 ++++++++++++++++ .../team/BoardTaskLogStreamService.test.ts | 164 ++++++++++ .../TaskProgressSignalClassifier.test.ts | 125 ++++++++ .../stallMonitor/TeamTaskStallJournal.test.ts | 53 ++++ .../stallMonitor/TeamTaskStallMonitor.test.ts | 208 +++++++++++++ .../TeamTaskStallNotifier.test.ts | 208 +++++++++++++ .../stallMonitor/TeamTaskStallPolicy.test.ts | 260 ++++++++++++++++ .../TeamTaskStallSnapshotSource.test.ts | 18 +- .../team/stallMonitor/featureGates.test.ts | 18 ++ 24 files changed, 2160 insertions(+), 47 deletions(-) create mode 100644 src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts create mode 100644 src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts create mode 100644 test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts create mode 100644 test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts create mode 100644 test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..7f1b517e --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -0,0 +1,287 @@ +import { createReadStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; + +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; +import type { ServiceContext } from '@main/services'; + +const CODEX_SESSION_FILE_PARSE_LIMIT = 500; +const CODEX_PROJECT_CANDIDATE_LIMIT = 40; +const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 3_500; +const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24; + +interface CodexSessionFileEntry { + filePath: string; + mtimeMs: number; +} + +interface CodexSessionEvent { + timestamp?: unknown; + payload?: { + cwd?: unknown; + source?: unknown; + timestamp?: unknown; + git?: { + branch?: unknown; + } | null; + }; +} + +interface CodexSessionProjectSnapshot { + cwd: string; + source: unknown; + lastActivityAt: number; + branchName?: string; +} + +function isInteractiveSource(source: unknown): boolean { + return source === 'vscode' || source === 'cli'; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; +} + +function getCodexHome(codexHome?: string): string { + return codexHome?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex'); +} + +async function readFirstLine(filePath: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const lines = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + try { + for await (const line of lines) { + return line; + } + return null; + } catch { + return null; + } finally { + lines.close(); + stream.destroy(); + } +} + +async function listJsonlFiles(root: string, maxDepth: number): Promise { + async function walk(directory: string, depth: number): Promise { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return []; + } + + const files = await Promise.all( + entries.map(async (entry): Promise => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return depth < maxDepth ? walk(entryPath, depth + 1) : []; + } + + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + return []; + } + + try { + const stats = await fs.stat(entryPath); + return [ + { + filePath: entryPath, + mtimeMs: stats.mtimeMs, + }, + ]; + } catch { + return []; + } + }) + ); + + return files.flat(); + } + + return walk(root, 0); +} + +function parseSessionSnapshot( + firstLine: string, + mtimeMs: number +): CodexSessionProjectSnapshot | null { + let event: CodexSessionEvent; + try { + event = JSON.parse(firstLine) as CodexSessionEvent; + } catch { + return null; + } + + const cwd = typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : ''; + if (!cwd || !isInteractiveSource(event.payload?.source) || isEphemeralProjectPath(cwd)) { + return null; + } + + const timestamp = + mtimeMs || normalizeTimestamp(event.payload?.timestamp) || normalizeTimestamp(event.timestamp); + const branchName = + typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : ''; + + return { + cwd, + source: event.payload?.source, + lastActivityAt: timestamp, + branchName: branchName || undefined, + }; +} + +export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly sourceId = 'codex-session-files'; + readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS; + readonly #codexHome: string; + + constructor( + private readonly deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + identityResolver: RecentProjectIdentityResolver; + logger: LoggerPort; + codexHome?: string; + } + ) { + this.#codexHome = getCodexHome(deps.codexHome); + } + + async list(): Promise { + const activeContext = this.deps.getActiveContext(); + const localContext = this.deps.getLocalContext(); + + if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { + return { + candidates: [], + degraded: false, + }; + } + + try { + const snapshots = await this.#listRecentSessionSnapshots(); + const candidates = await Promise.all( + snapshots.map((snapshot) => this.#toCandidate(snapshot)) + ); + + const validCandidates = candidates.filter( + (candidate): candidate is RecentProjectCandidate => candidate !== null + ); + + this.deps.logger.info('codex session-file recent-projects source loaded', { + count: validCandidates.length, + codexHome: this.#codexHome, + }); + + return { + candidates: validCandidates, + degraded: false, + }; + } catch (error) { + this.deps.logger.warn('codex session-file recent-projects source failed', { + error: error instanceof Error ? error.message : String(error), + }); + + return { + candidates: [], + degraded: true, + }; + } + } + + async #listRecentSessionSnapshots(): Promise { + const files = [ + ...(await listJsonlFiles(path.join(this.#codexHome, 'sessions'), 4)), + ...(await listJsonlFiles(path.join(this.#codexHome, 'archived_sessions'), 1)), + ].sort((left, right) => right.mtimeMs - left.mtimeMs); + + const snapshotsByCwd = new Map(); + + const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT); + + for ( + let offset = 0; + offset < candidateFiles.length && snapshotsByCwd.size < CODEX_PROJECT_CANDIDATE_LIMIT; + offset += CODEX_SESSION_FILE_READ_BATCH_SIZE + ) { + const batch = candidateFiles.slice(offset, offset + CODEX_SESSION_FILE_READ_BATCH_SIZE); + const firstLines = await Promise.all( + batch.map(async (file) => ({ + file, + firstLine: await readFirstLine(file.filePath), + })) + ); + + for (const { file, firstLine } of firstLines) { + if (!firstLine) { + continue; + } + + const snapshot = parseSessionSnapshot(firstLine, file.mtimeMs); + if (!snapshot) { + continue; + } + + const previous = snapshotsByCwd.get(snapshot.cwd); + if (!previous || snapshot.lastActivityAt > previous.lastActivityAt) { + snapshotsByCwd.set(snapshot.cwd, snapshot); + } + + if (snapshotsByCwd.size >= CODEX_PROJECT_CANDIDATE_LIMIT) { + break; + } + } + } + + return Array.from(snapshotsByCwd.values()) + .sort((left, right) => right.lastActivityAt - left.lastActivityAt) + .slice(0, CODEX_PROJECT_CANDIDATE_LIMIT); + } + + async #toCandidate( + snapshot: CodexSessionProjectSnapshot + ): Promise { + const identity = await this.deps.identityResolver.resolve(snapshot.cwd); + const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd; + + return { + identity: identity?.id ?? `path:${normalizeIdentityPath(snapshot.cwd)}`, + displayName, + primaryPath: snapshot.cwd, + associatedPaths: [snapshot.cwd], + lastActivityAt: snapshot.lastActivityAt, + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: snapshot.cwd, + }, + branchName: snapshot.branchName, + }; + } +} diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index b0f36022..c85173d0 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -2,17 +2,12 @@ import { type DashboardRecentProjectsPayload, normalizeDashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; -import { - CodexBinaryResolver, - JsonRpcStdioClient, -} from '@main/services/infrastructure/codexAppServer'; import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter'; -import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; +import { CodexSessionFileRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter'; import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; -import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; import type { ClockPort } from '../../core/application/ports/ClockPort'; @@ -31,16 +26,12 @@ export function createRecentProjectsFeature(deps: { const cache = new InMemoryRecentProjectsCache(); const presenter = new DashboardRecentProjectsPresenter(); const clock: ClockPort = { now: () => Date.now() }; - const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); - const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient); const identityResolver = new RecentProjectIdentityResolver(); const sources = [ new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger), - new CodexRecentProjectsSourceAdapter({ + new CodexSessionFileRecentProjectsSourceAdapter({ getActiveContext: deps.getActiveContext, getLocalContext: deps.getLocalContext, - resolveBinary: () => CodexBinaryResolver.resolve(), - appServerClient: codexAppServerClient, identityResolver, logger: deps.logger, }), diff --git a/src/main/index.ts b/src/main/index.ts index e63de9a8..8e8a9e39 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1079,7 +1079,7 @@ async function initializeServices(): Promise { new TeamTaskStallSnapshotSource(), new TeamTaskStallPolicy(), new TeamTaskStallJournal(), - new TeamTaskStallNotifier(teamDataService) + new TeamTaskStallNotifier(teamDataService, teamProvisioningService) ); let teammateToolTracker: TeammateToolTracker | null = null; branchStatusService = new BranchStatusService((event) => { diff --git a/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts b/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts new file mode 100644 index 00000000..71bbbc80 --- /dev/null +++ b/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts @@ -0,0 +1,105 @@ +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; + +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TaskComment, TeamTask } from '@shared/types'; + +export type TaskProgressSignal = + | 'strong_progress' + | 'weak_start_only' + | 'blocker_or_clarification' + | 'terminal_progress' + | 'unknown'; + +export interface TaskProgressTouchClassification { + signal: TaskProgressSignal; + reason: string; +} + +const CONCRETE_FILE_OR_PATH_RE = + /(?:^|\s)(?:\.{1,2}\/|~\/|\/|\w[\w.-]*\/)[\w./\s-]+|\b[\w.-]+\.(?:[cm]?[tj]sx?|json|md|css|scss|py|go|rs|java|kt|swift|ya?ml|toml|lock|sh|sql)\b/i; +const TASK_OR_ISSUE_REF_RE = /#[a-f0-9]{6,}|\btask-[\w-]+/i; +const TEST_OR_BUILD_RESULT_RE = + /\b(?:test(?:s|ed|ing)?|vitest|jest|playwright|pnpm|npm|bun|build|typecheck|lint|passed|failed|green|red|error|exception|stack trace)\b|тест|сборк|линт|ошибк|упал|прош[её]л/i; +const SUBSTANTIVE_WORK_RE = + /\b(?:implemented|fixed|added|updated|changed|removed|found|verified|confirmed|completed|created|refactored|patched|root cause|next step)\b|исправ|добав|обнов|измен|удал|наш[её]л|подтверд|готово|сделал|сделана|причин|следующ/i; +const BLOCKER_OR_CLARIFICATION_RE = + /\?|(?:^|\b)(?:blocked|blocker|cannot|can't|need|needs|waiting|clarification|question|permission|access denied|not enough context)\b|не могу|не получается|нужн|жду|блок|уточн|вопрос|нет доступа|недостаточно контекст/i; +const WEAK_START_ONLY_RE = + /^(?:я\s+)?(?:начинаю(?:\s+работу)?|начну|приступаю(?:\s+к\s+работе)?|беру\s+в\s+работу|проверю|сейчас\s+проверю|посмотрю|разберусь|готов(?:а)?\s+приступить|готов(?:а)?\s+к\s+работе|will\s+start|starting\s+work|starting|taking\s+this|i(?:'|’)?ll\s+start|i\s+will\s+start|i\s+am\s+starting|i(?:'|’)?ll\s+check|i\s+will\s+check|checking\s+now|on\s+it)(?:[.!…\s]*)$/i; + +function normalizeCommentText(text: string): string { + return stripAgentBlocks(text).replace(/\s+/g, ' ').trim(); +} + +function isConcreteProgress(text: string): boolean { + return ( + CONCRETE_FILE_OR_PATH_RE.test(text) || + TASK_OR_ISSUE_REF_RE.test(text) || + TEST_OR_BUILD_RESULT_RE.test(text) || + SUBSTANTIVE_WORK_RE.test(text) + ); +} + +function classifyTaskCommentText(text: string): TaskProgressTouchClassification { + const normalized = normalizeCommentText(text); + if (!normalized) { + return { signal: 'unknown', reason: 'comment_text_empty' }; + } + + if (BLOCKER_OR_CLARIFICATION_RE.test(normalized)) { + return { + signal: 'blocker_or_clarification', + reason: 'comment_mentions_blocker_or_clarification', + }; + } + + if (isConcreteProgress(normalized)) { + return { signal: 'strong_progress', reason: 'comment_contains_concrete_progress' }; + } + + if (normalized.length <= 120 && WEAK_START_ONLY_RE.test(normalized)) { + return { signal: 'weak_start_only', reason: 'comment_is_start_only' }; + } + + return { signal: 'unknown', reason: 'comment_progress_signal_unclear' }; +} + +export function getTaskCommentForActivityRecord( + task: TeamTask, + record: BoardTaskActivityRecord +): TaskComment | null { + const commentId = record.action?.details?.commentId?.trim(); + if (!commentId) { + return null; + } + return task.comments?.find((comment) => comment.id === commentId) ?? null; +} + +export function classifyTaskProgressTouch(args: { + task: TeamTask; + record: BoardTaskActivityRecord; +}): TaskProgressTouchClassification { + const toolName = args.record.action?.canonicalToolName; + if (toolName === 'task_start' || toolName === 'task_set_status') { + return { signal: 'strong_progress', reason: `${toolName}_is_authoritative_touch` }; + } + if (toolName === 'task_complete') { + return { signal: 'terminal_progress', reason: 'task_complete_is_terminal' }; + } + if (toolName === 'task_set_clarification') { + return { + signal: 'blocker_or_clarification', + reason: 'task_set_clarification_is_blocker_signal', + }; + } + if (toolName !== 'task_add_comment') { + return { signal: 'unknown', reason: 'tool_is_not_classified_for_task_progress' }; + } + + const comment = getTaskCommentForActivityRecord(args.task, args.record); + if (!comment) { + return { signal: 'unknown', reason: 'task_comment_text_unavailable' }; + } + + return classifyTaskCommentText(comment.text); +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts index 5667929b..8a8a4e15 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts @@ -24,6 +24,7 @@ export class TeamTaskStallJournal { teamName: string; evaluations: TaskStallEvaluation[]; activeTaskIds: string[]; + scopeTaskIds?: string[]; now: string; }): Promise { const filePath = this.getFilePath(args.teamName); @@ -48,8 +49,12 @@ export class TeamTaskStallJournal { ); const activeTaskIdSet = new Set(args.activeTaskIds); + const scopeTaskIdSet = args.scopeTaskIds ? new Set(args.scopeTaskIds) : null; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; + if (scopeTaskIdSet && !scopeTaskIdSet.has(entry.taskId)) { + continue; + } if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) { entries.splice(i, 1); } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts index a1b12321..68d1eaed 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts @@ -5,8 +5,10 @@ import { getTeamTaskStallActivationGraceMs, getTeamTaskStallScanIntervalMs, getTeamTaskStallStartupGraceMs, + isOpenCodeTaskStallRemediationEnabled, isTeamTaskStallAlertsEnabled, isTeamTaskStallMonitorEnabled, + isTeamTaskStallScannerEnabled, } from './featureGates'; import type { ActiveTeamRegistry } from './ActiveTeamRegistry'; @@ -40,7 +42,7 @@ export class TeamTaskStallMonitor { ) {} start(): void { - if (!isTeamTaskStallMonitorEnabled()) { + if (!isTeamTaskStallScannerEnabled()) { logger.debug('Task stall monitor disabled by feature gate'); return; } @@ -67,7 +69,7 @@ export class TeamTaskStallMonitor { noteTeamChange(event: TeamChangeEvent): void { this.registry.noteTeamChange(event); - if (!isTeamTaskStallMonitorEnabled()) { + if (!isTeamTaskStallScannerEnabled()) { return; } @@ -177,13 +179,20 @@ 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 + ? evaluations.filter((evaluation) => this.isOpenCodeOwnerWorkEvaluation(snapshot, evaluation)) + : evaluations; const activeTaskIds = [ ...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)), ]; const readyEvaluations = await this.journal.reconcileScan({ teamName, - evaluations, + evaluations: journalEvaluations, activeTaskIds, + ...(scopedTaskIds ? { scopeTaskIds: scopedTaskIds } : {}), now: now.toISOString(), }); @@ -195,14 +204,31 @@ export class TeamTaskStallMonitor { return; } - if (!isTeamTaskStallAlertsEnabled()) { + const alertedEpochKeys = new Set(); + if (isOpenCodeTaskStallRemediationEnabled()) { + const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts); + for (const alert of remediatedAlerts) { + alertedEpochKeys.add(alert.epochKey); + } + } + + const leadFallbackAlerts = alerts.filter((alert) => !alertedEpochKeys.has(alert.epochKey)); + if (leadFallbackAlerts.length > 0 && isTeamTaskStallAlertsEnabled()) { + await this.notifier.notifyLead(teamName, leadFallbackAlerts); + for (const alert of leadFallbackAlerts) { + alertedEpochKeys.add(alert.epochKey); + } + } + + if (alertedEpochKeys.size === 0) { logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`); return; } - await this.notifier.notifyLead(teamName, alerts); await Promise.all( - alerts.map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString())) + alerts + .filter((alert) => alertedEpochKeys.has(alert.epochKey)) + .map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString())) ); } @@ -227,6 +253,9 @@ export class TeamTaskStallMonitor { } const displayId = getTaskDisplayId(task); + const ownerProviderId = task.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; return { teamName: snapshot.teamName, taskId: task.id, @@ -234,8 +263,11 @@ export class TeamTaskStallMonitor { subject: task.subject, branch: evaluation.branch, signal: evaluation.signal, + ...(evaluation.progressSignal ? { progressSignal: evaluation.progressSignal } : {}), reason: evaluation.reason, epochKey: evaluation.epochKey, + ...(task.owner ? { owner: task.owner } : {}), + ...(ownerProviderId ? { ownerProviderId } : {}), taskRef: { taskId: task.id, displayId, @@ -243,4 +275,37 @@ export class TeamTaskStallMonitor { }, }; } + + private isOpenCodeOwnerWorkEvaluation( + snapshot: Awaited>, + evaluation: TaskStallEvaluation + ): boolean { + if ( + !snapshot || + evaluation.status !== 'alert' || + evaluation.branch !== 'work' || + !evaluation.taskId + ) { + return false; + } + + const task = snapshot.allTasksById.get(evaluation.taskId); + const ownerProviderId = task?.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; + return ownerProviderId === 'opencode'; + } + + private getOpenCodeOwnedTaskIds( + snapshot: NonNullable>> + ): string[] { + return [...snapshot.allTasksById.values()] + .filter((task) => { + const ownerProviderId = task.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; + return ownerProviderId === 'opencode'; + }) + .map((task) => task.id); + } } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index c86dc6a6..730ad1bf 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -1,7 +1,23 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { createLogger } from '@shared/utils/logger'; +import { TeamInboxReader } from '../TeamInboxReader'; +import { TeamInboxWriter } from '../TeamInboxWriter'; import type { TeamDataService } from '../TeamDataService'; +import type { TeamProvisioningService } from '../TeamProvisioningService'; import type { TaskStallAlert } from './TeamTaskStallTypes'; +import type { SendMessageRequest } from '@shared/types'; + +const logger = createLogger('Service:TeamTaskStallNotifier'); + +type OpenCodeTaskStallRelayService = Pick< + TeamProvisioningService, + 'relayOpenCodeMemberInboxMessages' +>; +type OpenCodeTaskStallRelayResult = Awaited< + ReturnType +>; +type OpenCodeTaskStallDelivery = NonNullable; function buildLeadAlertText(alerts: TaskStallAlert[]): string { return alerts @@ -12,9 +28,37 @@ function buildLeadAlertText(alerts: TaskStallAlert[]): string { .join('\n'); } +function buildOpenCodeOwnerNudgeText(alert: TaskStallAlert): string { + const taskLabel = formatTaskDisplayLabel({ + id: alert.taskId, + displayId: alert.displayId, + }); + return [ + `Task ${taskLabel} may be stalled after a low-signal progress update.`, + 'Continue the task now. If blocked, add a concrete task comment explaining the blocker and needed input. If done, add a final task comment with the result and complete the task.', + 'Do not send acknowledgement-only replies.', + ].join('\n'); +} + +function isOpenCodeDeliveryAccepted(delivery: OpenCodeTaskStallDelivery): boolean { + if (delivery.queuedBehindMessageId) { + return false; + } + if (delivery.accepted === true) { + return true; + } + if (delivery.delivered === true && delivery.responsePending !== true) { + return true; + } + return Boolean(delivery.responsePending === true && delivery.ledgerRecordId); +} + export class TeamTaskStallNotifier { constructor( - private readonly teamDataService: Pick + private readonly teamDataService: Pick, + private readonly teamProvisioningService?: OpenCodeTaskStallRelayService, + private readonly inboxReader: Pick = new TeamInboxReader(), + private readonly inboxWriter: Pick = new TeamInboxWriter() ) {} async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise { @@ -29,4 +73,108 @@ export class TeamTaskStallNotifier { taskRefs: alerts.map((alert) => alert.taskRef), }); } + + private async ensureOpenCodeOwnerNudgeInboxMessage(args: { + teamName: string; + alert: TaskStallAlert; + messageId: string; + text: string; + timestamp: string; + }): Promise { + const owner = args.alert.owner?.trim(); + if (!owner) { + return false; + } + + try { + const existing = await this.inboxReader.getMessagesFor(args.teamName, owner); + if (existing.some((message) => message.messageId === args.messageId)) { + return true; + } + + const request: SendMessageRequest = { + member: owner, + from: 'system', + to: owner, + messageId: args.messageId, + timestamp: args.timestamp, + summary: 'Potential stalled task', + text: args.text, + taskRefs: [args.alert.taskRef], + actionMode: 'do', + source: 'system_notification', + }; + await this.inboxWriter.sendMessage(args.teamName, request); + return true; + } catch (error) { + logger.warn( + `OpenCode task stall remediation inbox write failed for ${args.teamName}/${args.alert.taskId}: ${String( + error + )}` + ); + return false; + } + } + + async notifyOpenCodeOwners( + teamName: string, + alerts: TaskStallAlert[] + ): Promise { + if (!this.teamProvisioningService || alerts.length === 0) { + return []; + } + + const deliveredAlerts: TaskStallAlert[] = []; + for (const alert of alerts) { + if (alert.branch !== 'work' || alert.ownerProviderId !== 'opencode' || !alert.owner?.trim()) { + continue; + } + + try { + const messageId = `task-stall:${teamName}:${alert.taskId}:${alert.epochKey}`; + const timestamp = new Date().toISOString(); + const text = buildOpenCodeOwnerNudgeText(alert); + const inboxReady = await this.ensureOpenCodeOwnerNudgeInboxMessage({ + teamName, + alert, + messageId, + text, + timestamp, + }); + if (!inboxReady) { + continue; + } + + const relay = await this.teamProvisioningService.relayOpenCodeMemberInboxMessages( + teamName, + alert.owner, + { + onlyMessageId: messageId, + source: 'watchdog', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + taskRefs: [alert.taskRef], + }, + } + ); + const delivery = relay.lastDelivery; + if (delivery && isOpenCodeDeliveryAccepted(delivery)) { + deliveredAlerts.push(alert); + continue; + } + logger.debug( + `OpenCode task stall remediation was not accepted for ${teamName}/${alert.taskId}: ${ + delivery?.reason ?? relay.diagnostics?.[0] ?? 'unknown' + }` + ); + } catch (error) { + logger.warn( + `OpenCode task stall remediation failed for ${teamName}/${alert.taskId}: ${String(error)}` + ); + } + } + + return deliveredAlerts; + } } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index c09a494d..f9debadc 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -1,4 +1,5 @@ import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier'; import type { ReviewTaskContext, TaskStallBranch, @@ -8,6 +9,7 @@ import type { TeamTaskStallSnapshot, WorkTaskContext, } from './TeamTaskStallTypes'; +import { getOpenCodeWeakStartStallThresholdMs } from './featureGates'; import type { TaskHistoryEvent, TaskWorkInterval, TeamTask } from '@shared/types'; const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']); @@ -286,6 +288,7 @@ function buildAlertEvaluation(args: { task: TeamTask; branch: TaskStallBranch; signal: TaskStallSignal; + progressSignal?: TaskProgressSignal; touch: BoardTaskActivityRecord; reason: string; }): TaskStallEvaluation { @@ -294,11 +297,17 @@ function buildAlertEvaluation(args: { taskId: args.task.id, branch: args.branch, signal: args.signal, + ...(args.progressSignal ? { progressSignal: args.progressSignal } : {}), epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch), reason: args.reason, }; } +function normalizeMemberNameKey(name: string | undefined): string | null { + const normalized = name?.trim().toLowerCase(); + return normalized ? normalized : null; +} + export class TeamTaskStallPolicy { evaluateWork(args: { now: Date; @@ -383,8 +392,18 @@ export class TeamTaskStallPolicy { return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state'); } + const progressClassification = classifyTaskProgressTouch({ + task, + record: workContext.lastMeaningfulTouch, + }); + const ownerProviderId = + snapshot.providerByMemberName.get(normalizeMemberNameKey(task.owner) ?? '') ?? null; + const isOpenCodeWeakStartOnly = + ownerProviderId === 'opencode' && progressClassification.signal === 'weak_start_only'; const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt); - const thresholdMs = WORK_THRESHOLDS_MS[signal]; + const thresholdMs = isOpenCodeWeakStartOnly + ? getOpenCodeWeakStartStallThresholdMs() + : WORK_THRESHOLDS_MS[signal]; if (elapsedMs < thresholdMs) { return skip( task.id, @@ -397,8 +416,11 @@ export class TeamTaskStallPolicy { task, branch: 'work', signal, + progressSignal: progressClassification.signal, touch: workContext.lastMeaningfulTouch, - reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`, + reason: isOpenCodeWeakStartOnly + ? 'Potential work stall after weak start-only task comment.' + : `Potential work stall after ${signal.replaceAll('_', ' ')}.`, }); } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts index 810f9637..0ba409da 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -4,21 +4,62 @@ import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscrip import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'; import { TeamKanbanManager } from '../TeamKanbanManager'; import { TeamTaskReader } from '../TeamTaskReader'; +import { TeamMembersMetaStore } from '../TeamMembersMetaStore'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; import { buildResolvedReviewerIndex } from './reviewerResolution'; import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader'; import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes'; -import type { TeamConfig, TeamTask } from '@shared/types'; +import type { TeamConfig, TeamMember, TeamProviderId, TeamTask } from '@shared/types'; function resolveLeadNameFromConfig(config: TeamConfig): string { const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead')); return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; } +function normalizeMemberNameKey(name: string | undefined): string | null { + const normalized = name?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function resolveMemberProvider(member: TeamMember): TeamProviderId | undefined { + const legacyProvider = (member as { provider?: unknown }).provider; + return ( + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(legacyProvider) ?? + inferTeamProviderIdFromModel(member.model) + ); +} + +function buildProviderByMemberName(args: { + configMembers: TeamMember[]; + metaMembers: TeamMember[]; +}): Map { + const providerByMemberName = new Map(); + for (const member of args.configMembers) { + const memberName = normalizeMemberNameKey(member.name); + const providerId = resolveMemberProvider(member); + if (memberName && providerId) { + providerByMemberName.set(memberName, providerId); + } + } + for (const member of args.metaMembers) { + const memberName = normalizeMemberNameKey(member.name); + const providerId = resolveMemberProvider(member); + if (memberName && providerId) { + providerByMemberName.set(memberName, providerId); + } + } + return providerByMemberName; +} + export class TeamTaskStallSnapshotSource { constructor( private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), @@ -27,7 +68,8 @@ export class TeamTaskStallSnapshotSource { private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(), private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(), - private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader() + private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() ) {} async getSnapshot(teamName: string): Promise { @@ -36,10 +78,11 @@ export class TeamTaskStallSnapshotSource { return null; } - const [activeTasks, deletedTasks, kanbanState] = await Promise.all([ + const [activeTasks, deletedTasks, kanbanState, metaMembers] = await Promise.all([ this.taskReader.getTasks(teamName), this.taskReader.getDeletedTasks(teamName), this.kanbanManager.getState(teamName), + this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const allTasks = [...activeTasks, ...deletedTasks]; const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); @@ -50,6 +93,10 @@ export class TeamTaskStallSnapshotSource { const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); const activityReadsEnabled = isBoardTaskActivityReadEnabled(); const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); + const providerByMemberName = buildProviderByMemberName({ + configMembers: transcriptContext.config.members ?? [], + metaMembers, + }); let recordsByTaskId = new Map(); if ( @@ -98,6 +145,7 @@ export class TeamTaskStallSnapshotSource { recordsByTaskId, freshnessByTaskId, exactRowsByFilePath, + providerByMemberName, }; } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts index 46550e05..30c68581 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts @@ -1,6 +1,7 @@ import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TaskProgressSignal } from './TaskProgressSignalClassifier'; import type { ParsedMessage } from '@main/types'; -import type { TeamTask } from '@shared/types'; +import type { TeamProviderId, TeamTask } from '@shared/types'; export type TaskStallBranch = 'work' | 'review'; @@ -47,6 +48,7 @@ export interface TaskStallEvaluation { taskId?: string; branch?: TaskStallBranch; signal?: TaskStallSignal; + progressSignal?: TaskProgressSignal; epochKey?: string; reason: string; skipReason?: TaskStallSkipReason; @@ -91,6 +93,7 @@ export interface TeamTaskStallSnapshot { recordsByTaskId: Map; freshnessByTaskId: Map; exactRowsByFilePath: Map; + providerByMemberName: Map; } export interface WorkTaskContext { @@ -114,8 +117,11 @@ export interface TaskStallAlert { subject: string; branch: TaskStallBranch; signal: TaskStallSignal; + progressSignal?: TaskProgressSignal; reason: string; epochKey: string; + owner?: string; + ownerProviderId?: TeamProviderId; taskRef: { taskId: string; displayId: string; diff --git a/src/main/services/team/stallMonitor/featureGates.ts b/src/main/services/team/stallMonitor/featureGates.ts index f9c24682..2f3e9465 100644 --- a/src/main/services/team/stallMonitor/featureGates.ts +++ b/src/main/services/team/stallMonitor/featureGates.ts @@ -25,6 +25,14 @@ export function isTeamTaskStallMonitorEnabled(): boolean { return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false); } +export function isOpenCodeTaskStallRemediationEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED, false); +} + +export function isTeamTaskStallScannerEnabled(): boolean { + return isTeamTaskStallMonitorEnabled() || isOpenCodeTaskStallRemediationEnabled(); +} + export function isTeamTaskStallAlertsEnabled(): boolean { return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false); } @@ -40,3 +48,7 @@ export function getTeamTaskStallStartupGraceMs(): number { export function getTeamTaskStallActivationGraceMs(): number { return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000); } + +export function getOpenCodeWeakStartStallThresholdMs(): number { + return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 6 * 60_000); +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 0a035df8..f4080f60 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -4,6 +4,8 @@ import { createLogger } from '@shared/utils/logger'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; +import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamMembersMetaStore } from '../../TeamMembersMetaStore'; import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; @@ -57,6 +59,7 @@ interface TimeWindow { interface StreamLayout { participants: BoardTaskLogParticipant[]; visibleSlices: StreamSlice[]; + shouldMergeOpenCodeRuntimeFallback?: boolean; } const logger = createLogger('Service:BoardTaskLogStreamService'); @@ -1421,6 +1424,64 @@ function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number { return segmentCount; } +function mergeParticipants( + primary: BoardTaskLogParticipant[], + fallback: BoardTaskLogParticipant[] +): BoardTaskLogParticipant[] { + const participantsByKey = new Map(); + for (const participant of [...primary, ...fallback]) { + if (!participantsByKey.has(participant.key)) { + participantsByKey.set(participant.key, participant); + } + } + + return Array.from(participantsByKey.values()).sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return 0; + }); +} + +function mergeSegments( + primary: BoardTaskLogSegment[], + fallback: BoardTaskLogSegment[] +): BoardTaskLogSegment[] { + const segmentsById = new Map(); + for (const segment of [...primary, ...fallback]) { + if (!segmentsById.has(segment.id)) { + segmentsById.set(segment.id, segment); + } + } + + return Array.from(segmentsById.values()).sort((left, right) => { + const leftTs = Date.parse(left.startTimestamp); + const rightTs = Date.parse(right.startTimestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + return left.id.localeCompare(right.id); + }); +} + +function chooseDefaultFilter(participants: BoardTaskLogParticipant[]): 'all' | string { + const namedParticipants = participants.filter((participant) => !participant.isLead); + return namedParticipants.length === 1 ? namedParticipants[0]!.key : 'all'; +} + +function mergeRuntimeFallbackResponse( + primary: BoardTaskLogStreamResponse, + fallback: BoardTaskLogStreamResponse +): BoardTaskLogStreamResponse { + const participants = mergeParticipants(primary.participants, fallback.participants); + return { + participants, + defaultFilter: chooseDefaultFilter(participants), + segments: mergeSegments(primary.segments, fallback.segments), + source: primary.source, + runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection, + }; +} + export class BoardTaskLogStreamService { private readonly layoutCache = new Map< string, @@ -1440,7 +1501,9 @@ export class BoardTaskLogStreamService { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), - private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource() + private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly configReader: TeamConfigReader = new TeamConfigReader() ) {} private buildLayoutCacheKey(teamName: string, taskId: string): string { @@ -1898,9 +1961,63 @@ export class BoardTaskLogStreamService { return { participants: buildOrderedParticipants(visibleSlices), visibleSlices, + shouldMergeOpenCodeRuntimeFallback: await this.shouldMergeOpenCodeRuntimeFallback( + teamName, + taskId, + records + ), }; } + private async shouldMergeOpenCodeRuntimeFallback( + teamName: string, + taskId: string, + records: BoardTaskActivityRecord[] + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return false; + } + + try { + const [activeTasks, deletedTasks, metaMembers, config] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => []), + this.taskReader.getDeletedTasks(teamName).catch(() => []), + this.membersMetaStore.getMembers(teamName).catch(() => []), + this.configReader.getConfig(teamName).catch(() => null), + ]); + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + const ownerName = task?.owner?.trim(); + if (!ownerName) { + return false; + } + + const normalizedOwner = normalizeMemberName(ownerName); + const member = [...metaMembers, ...(config?.members ?? [])].find( + (candidate) => normalizeMemberName(candidate.name) === normalizedOwner + ); + return member?.providerId === 'opencode'; + } catch { + return false; + } + } + + private async loadRuntimeFallback( + teamName: string, + taskId: string + ): Promise { + const startedAt = Date.now(); + const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { + logger.warn( + `Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( + fallback + )} elapsedMs=${elapsedMs}` + ); + } + return fallback; + } + async getTaskLogStreamSummary( teamName: string, taskId: string @@ -1926,16 +2043,7 @@ export class BoardTaskLogStreamService { const layout = await this.getStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - const startedAt = Date.now(); - const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); - const elapsedMs = Date.now() - startedAt; - if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { - logger.warn( - `Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( - fallback - )} elapsedMs=${elapsedMs}` - ); - } + const fallback = await this.loadRuntimeFallback(teamName, taskId); return fallback ?? emptyResponse(); } @@ -1984,14 +2092,18 @@ export class BoardTaskLogStreamService { } flushSegment(); - const namedParticipants = layout.participants.filter((participant) => !participant.isLead); - const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; - - return { + const primaryResponse: BoardTaskLogStreamResponse = { participants: layout.participants, - defaultFilter, + defaultFilter: chooseDefaultFilter(layout.participants), segments, source: 'transcript', }; + + if (!layout.shouldMergeOpenCodeRuntimeFallback) { + return primaryResponse; + } + + const fallback = await this.loadRuntimeFallback(teamName, taskId); + return fallback ? mergeRuntimeFallbackResponse(primaryResponse, fallback) : primaryResponse; } } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a6ec0fe2..8c2a7082 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1083,7 +1083,7 @@ export const MarkdownViewer: React.FC = ({ {/* Markdown content with scroll */}
-
+
+
{structured ? (
{autoSummary && autoSummary !== messageType ? ( @@ -1547,6 +1547,7 @@ export const ActivityItem = memo( ; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +async function writeRollout( + filePath: string, + payload: { + cwd: string; + source?: string; + timestamp?: string; + branch?: string; + }, + mtime: Date +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${JSON.stringify({ + timestamp: payload.timestamp ?? mtime.toISOString(), + type: 'session_meta', + payload: { + id: path.basename(filePath, '.jsonl'), + timestamp: payload.timestamp ?? mtime.toISOString(), + cwd: payload.cwd, + source: payload.source ?? 'cli', + git: payload.branch ? { branch: payload.branch } : undefined, + }, + })}\n${'x'.repeat(1024)}`, + 'utf8' + ); + await fs.utimes(filePath, mtime, mtime); +} + +describe('CodexSessionFileRecentProjectsSourceAdapter', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-session-files-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('loads recent interactive Codex projects from session files', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:alpha', + name: 'alpha', + }), + } as unknown as RecentProjectIdentityResolver; + const updatedAt = new Date('2026-04-14T12:00:00.000Z'); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-alpha.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'main', + }, + updatedAt + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/Users/test/projects/alpha', + lastActivityAt: updatedAt.getTime(), + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/test/projects/alpha', + }, + branchName: 'main', + }), + ], + degraded: false, + }); + expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha'); + }); + + it('deduplicates sessions by cwd and keeps the newest activity', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '13', 'rollout-alpha-old.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'old', + }, + new Date('2026-04-13T12:00:00.000Z') + ); + await writeRollout( + path.join(codexHome, 'archived_sessions', 'rollout-alpha-new.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'new', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + const result = await adapter.list(); + + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]).toEqual( + expect.objectContaining({ + primaryPath: '/Users/test/projects/alpha', + lastActivityAt: Date.parse('2026-04-14T12:00:00.000Z'), + branchName: 'new', + }) + ); + expect(identityResolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('keeps scanning past duplicate recent sessions to find more projects', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + const baseTime = Date.parse('2026-04-14T12:00:00.000Z'); + + await Promise.all( + Array.from({ length: 130 }).map((_, index) => + writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', `rollout-alpha-${index}.jsonl`), + { + cwd: '/Users/test/projects/alpha', + branch: 'main', + }, + new Date(baseTime - index * 1000) + ) + ) + ); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-beta.jsonl'), + { + cwd: '/Users/test/projects/beta', + branch: 'main', + }, + new Date(baseTime - 140_000) + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + const result = await adapter.list(); + + expect(result.candidates.map((candidate) => candidate.primaryPath)).toEqual([ + '/Users/test/projects/alpha', + '/Users/test/projects/beta', + ]); + }); + + it('skips non-interactive and ephemeral sessions', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-background.jsonl'), + { + cwd: '/Users/test/projects/background', + source: 'background', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-temp.jsonl'), + { + cwd: '/private/var/folders/x/T/codex-agent-teams-appstyle-123', + source: 'cli', + }, + new Date('2026-04-14T12:01:00.000Z') + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: false, + }); + expect(identityResolver.resolve).not.toHaveBeenCalled(); + }); + + it('returns an empty healthy result when Codex session folders are absent', async () => { + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome: path.join(tempDir, 'missing-codex-home'), + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: false, + }); + }); +}); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index 0b61c396..4ccf5540 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -163,6 +163,170 @@ describe('BoardTaskLogStreamService', () => { expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1); }); + it('merges OpenCode runtime stream when board transcript slices mask member execution', async () => { + const lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidate = { + ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'), + actionCategory: 'comment' as const, + canonicalToolName: 'task_add_comment', + }; + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => ({ + participants: [ + { + key: 'member:jack', + label: 'jack', + role: 'member' as const, + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'member:jack', + segments: [ + { + id: 'opencode:demo:task-a:jack', + participantKey: 'member:jack', + actor: { + memberName: 'jack', + role: 'member' as const, + sessionId: 'session-opencode', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:01:00.000Z', + endTimestamp: '2026-04-12T16:02:00.000Z', + chunks: [{ id: 'chunk-bash' }], + }, + ], + source: 'opencode_runtime_fallback' as const, + runtimeProjection: { + provider: 'opencode' as const, + mode: 'heuristic' as const, + attributionRecordCount: 0, + projectedMessageCount: 2, + fallbackReason: 'task_tool_markers' as const, + }, + })), + }; + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: lead, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], + })), + }; + const taskReader = { + getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'jack' }]), + getDeletedTasks: vi.fn(async () => []), + }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'jack', providerId: 'opencode' }]), + }; + const configReader = { + getConfig: vi.fn(async () => null), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + taskReader as never, + undefined as never, + runtimeFallbackSource as never, + membersMetaStore as never, + configReader as never + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledWith('demo', 'task-a'); + expect(response.defaultFilter).toBe('member:jack'); + expect(response.participants.map((participant) => participant.key)).toEqual([ + 'member:jack', + 'lead', + ]); + expect(response.segments.map((segment) => segment.id)).toEqual([ + 'lead:c1:c1', + 'opencode:demo:task-a:jack', + ]); + expect(response.runtimeProjection).toMatchObject({ + provider: 'opencode', + projectedMessageCount: 2, + }); + }); + + it('does not probe OpenCode runtime for non-OpenCode task owners', async () => { + const lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'); + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + const service = new BoardTaskLogStreamService( + { + getTaskRecords: vi.fn(async () => candidate.records), + } as never, + { + selectSummaries: vi.fn(() => [candidate]), + } as never, + { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + } as never, + { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: lead, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], + })), + } as never, + { + buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]), + } as never, + { + getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'alice' }]), + getDeletedTasks: vi.fn(async () => []), + } as never, + undefined as never, + runtimeFallbackSource as never, + { + getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]), + } as never, + { + getConfig: vi.fn(async () => null), + } as never + ); + + await service.getTaskLogStream('demo', 'task-a'); + + expect(runtimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled(); + }); + it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => { const tom = { memberName: 'tom', diff --git a/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts b/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts new file mode 100644 index 00000000..8fc0c323 --- /dev/null +++ b/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyTaskProgressTouch, + getTaskCommentForActivityRecord, +} from '../../../../../src/main/services/team/stallMonitor/TaskProgressSignalClassifier'; + +import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { TeamTask } from '../../../../../src/shared/types'; + +function createTask(commentText?: string): TeamTask { + return { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + comments: + commentText == null + ? [] + : [ + { + id: 'comment-a', + author: 'alice', + text: commentText, + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; +} + +function createCommentRecord(commentId: string | null = 'comment-a'): BoardTaskActivityRecord { + return { + id: 'record-a', + timestamp: '2026-04-19T12:00:00.000Z', + task: { + locator: { ref: 'task-a', refKind: 'canonical', canonicalId: 'task-a' }, + resolution: 'resolved', + taskRef: { taskId: 'task-a', displayId: 'abcd1234', teamName: 'demo' }, + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-a', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-a', + details: commentId ? { commentId } : {}, + }, + source: { + messageUuid: 'msg-a', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-a', + sourceOrder: 1, + }, + }; +} + +describe('TaskProgressSignalClassifier', () => { + it.each([ + 'Начинаю работу.', + 'Приступаю.', + 'Беру в работу.', + 'Проверю.', + 'Посмотрю.', + 'Will start.', + 'Starting work.', + 'Taking this.', + ])( + 'classifies start-only comment as weak: %s', + (text) => { + expect( + classifyTaskProgressTouch({ + task: createTask(text), + record: createCommentRecord(), + }) + ).toMatchObject({ signal: 'weak_start_only' }); + } + ); + + it.each([ + 'Found the failing test in src/app.ts and reproduced it with pnpm test.', + 'Проверил src/main.ts - причина в stale runtime metadata.', + 'Blocked: нет доступа к проекту.', + 'Нужно уточнение: какой файл менять?', + 'Tests failed with EADDRINUSE, next step is to isolate the server port.', + ])('does not classify substantive, blocker, or question comments as weak: %s', (text) => { + const classification = classifyTaskProgressTouch({ + task: createTask(text), + record: createCommentRecord(), + }); + + expect(classification.signal).not.toBe('weak_start_only'); + }); + + it('returns unknown when commentId is missing', () => { + expect( + classifyTaskProgressTouch({ + task: createTask('Начинаю работу.'), + record: createCommentRecord(null), + }) + ).toMatchObject({ signal: 'unknown' }); + }); + + it('returns unknown when comment text is unavailable', () => { + expect( + classifyTaskProgressTouch({ + task: createTask(), + record: createCommentRecord(), + }) + ).toMatchObject({ signal: 'unknown' }); + }); + + it('returns the matching task comment for an activity record', () => { + const task = createTask('Начинаю работу.'); + + expect(getTaskCommentForActivityRecord(task, createCommentRecord())?.id).toBe('comment-a'); + }); +}); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts index 5fe89983..519f7b28 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts @@ -48,4 +48,57 @@ describe('TeamTaskStallJournal', () => { expect(firstReady).toEqual([]); expect(secondReady).toEqual([evaluation]); }); + + it('does not prune journal entries outside an explicit task scope', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-')); + setClaudeBasePathOverride(tmpDir); + const teamDir = path.join(tmpDir, 'teams', 'demo'); + await fs.mkdir(teamDir, { recursive: true }); + const journalPath = path.join(teamDir, 'stall-monitor-journal.json'); + await fs.writeFile( + journalPath, + JSON.stringify( + [ + { + epochKey: 'task-codex:epoch-1', + teamName: 'demo', + taskId: 'task-codex', + branch: 'work', + signal: 'turn_ended_after_touch', + state: 'suspected', + consecutiveScans: 1, + createdAt: '2026-04-19T12:00:00.000Z', + updatedAt: '2026-04-19T12:00:00.000Z', + }, + { + epochKey: 'task-opencode:epoch-1', + teamName: 'demo', + taskId: 'task-opencode', + branch: 'work', + signal: 'turn_ended_after_touch', + state: 'suspected', + consecutiveScans: 1, + createdAt: '2026-04-19T12:00:00.000Z', + updatedAt: '2026-04-19T12:00:00.000Z', + }, + ], + null, + 2 + ) + ); + + const journal = new TeamTaskStallJournal(); + await journal.reconcileScan({ + teamName: 'demo', + evaluations: [], + activeTaskIds: ['task-codex', 'task-opencode'], + scopeTaskIds: ['task-opencode'], + now: '2026-04-19T12:10:00.000Z', + }); + + const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{ + epochKey: string; + }>; + expect(saved.map((entry) => entry.epochKey)).toEqual(['task-codex:epoch-1']); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts index 808696f4..92c37cda 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts @@ -84,4 +84,212 @@ describe('TeamTaskStallMonitor', () => { 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_ALERTS_ENABLED', 'false'); + 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 registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + }; + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const snapshot = { + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + }; + const snapshotSource = { + getSnapshot: vi.fn(async () => snapshot), + }; + 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 policy = { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + }; + 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( + registry as never, + snapshotSource as never, + policy 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(journal.reconcileScan).toHaveBeenLastCalledWith( + expect.objectContaining({ + evaluations: [readyEvaluation], + scopeTaskIds: ['task-a'], + }) + ); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.markAlerted).toHaveBeenCalledWith( + 'demo', + 'task-a:epoch', + expect.any(String) + ); + }); + + 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_ALERTS_ENABLED', 'false'); + 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-codex', + displayId: 'c0dex123', + subject: 'Codex task', + owner: 'alice', + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-codex', + branch: 'work', + signal: 'turn_ended_after_touch', + epochKey: 'task-codex:epoch', + reason: 'Potential work stall.', + }; + const journal = { + reconcileScan: vi.fn(async ({ evaluations }: { evaluations: unknown[] }) => evaluations), + 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-codex', task]]), + providerByMemberName: new Map([['alice', 'codex']]), + })), + } 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(1_100); + + expect(journal.reconcileScan).toHaveBeenCalledWith( + expect.objectContaining({ + evaluations: [], + scopeTaskIds: [], + }) + ); + expect(notifier.notifyOpenCodeOwners).not.toHaveBeenCalled(); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.markAlerted).not.toHaveBeenCalled(); + }); + + it('falls back to lead notification 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'); + + const registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + }; + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const snapshot = { + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + epochKey: 'task-a:epoch', + reason: 'Potential work stall.', + }; + const notifier = { + notifyOpenCodeOwners: vi.fn(async () => []), + notifyLead: vi.fn(async () => undefined), + }; + const monitor = new TeamTaskStallMonitor( + registry as never, + { getSnapshot: vi.fn(async () => snapshot) } as never, + { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + } as never, + { + reconcileScan: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([readyEvaluation]), + markAlerted: vi.fn(async () => undefined), + } as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + + expect(notifier.notifyLead).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts new file mode 100644 index 00000000..a9b622aa --- /dev/null +++ b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamTaskStallNotifier } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallNotifier'; + +import type { TaskStallAlert } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes'; + +function createAlert(overrides: Partial = {}): TaskStallAlert { + return { + teamName: 'demo', + taskId: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + branch: 'work', + signal: 'turn_ended_after_touch', + progressSignal: 'weak_start_only', + reason: 'Potential work stall after weak start-only task comment.', + epochKey: 'task-a:work:turn_ended_after_touch:stamp:file:msg:tool', + owner: 'alice', + ownerProviderId: 'opencode', + taskRef: { + taskId: 'task-a', + displayId: 'abcd1234', + teamName: 'demo', + }, + ...overrides, + }; +} + +describe('TeamTaskStallNotifier', () => { + it('sends OpenCode owner nudges with deterministic message ids', async () => { + const teamDataService = { + sendSystemNotificationToLead: vi.fn(async () => undefined), + }; + const teamProvisioningService = { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true, accepted: true }, + })), + }; + const inboxReader = { + getMessagesFor: vi.fn(async () => []), + }; + const inboxWriter = { + sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })), + }; + const notifier = new TeamTaskStallNotifier( + teamDataService as never, + teamProvisioningService as never, + inboxReader as never, + inboxWriter as never + ); + const alert = createAlert(); + const messageId = `task-stall:demo:task-a:${alert.epochKey}`; + + await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]); + + expect(inboxWriter.sendMessage).toHaveBeenCalledWith( + 'demo', + expect.objectContaining({ + member: 'alice', + from: 'system', + to: 'alice', + messageId, + summary: 'Potential stalled task', + taskRefs: [alert.taskRef], + actionMode: 'do', + source: 'system_notification', + }) + ); + expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( + 'demo', + 'alice', + { + onlyMessageId: messageId, + source: 'watchdog', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + taskRefs: [alert.taskRef], + }, + } + ); + expect(teamDataService.sendSystemNotificationToLead).not.toHaveBeenCalled(); + }); + + it('skips non-OpenCode owners', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + lastDelivery: { delivered: true }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect( + notifier.notifyOpenCodeOwners('demo', [ + createAlert({ ownerProviderId: 'codex', owner: 'alice' }), + ]) + ).resolves.toEqual([]); + }); + + it('skips review alerts because task owner is not necessarily the reviewer', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect( + notifier.notifyOpenCodeOwners('demo', [ + createAlert({ branch: 'review', ownerProviderId: 'opencode', owner: 'alice' }), + ]) + ).resolves.toEqual([]); + expect(relay).not.toHaveBeenCalled(); + }); + + it('returns no remediated alert when OpenCode delivery is rejected', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_runtime_not_active', + }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + }); + + it('does not mark queued-behind delivery as remediated even when active ledger exists', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: true, + accepted: false, + responsePending: true, + ledgerRecordId: 'active-ledger-record', + queuedBehindMessageId: 'msg-active', + reason: 'opencode_delivery_response_pending', + }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + }); + + it('does not deliver runtime nudge when inbox write fails', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => { throw new Error('disk full'); }) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + expect(relay).not.toHaveBeenCalled(); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode task stall remediation inbox write failed' + ); + vi.mocked(console.warn).mockClear(); + }); + + it('does not write or relay when existing inbox read fails', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const inboxWrite = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => { throw new Error('read failed'); }) } as never, + { sendMessage: inboxWrite } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + expect(inboxWrite).not.toHaveBeenCalled(); + expect(relay).not.toHaveBeenCalled(); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode task stall remediation inbox write failed' + ); + vi.mocked(console.warn).mockClear(); + }); +}); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts index ce214ad2..e817f5cf 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts @@ -97,6 +97,7 @@ function createSnapshot(overrides: Partial): TeamTaskStal recordsByTaskId: new Map(), freshnessByTaskId: new Map(), exactRowsByFilePath: new Map(), + providerByMemberName: new Map(), ...overrides, }; } @@ -155,6 +156,265 @@ describe('TeamTaskStallPolicy', () => { }); }); + it('alerts OpenCode-owned tasks faster after weak start-only task comments', () => { + const task: TeamTask = { + id: 'task-open-weak', + displayId: 'feed1111', + subject: 'OpenCode weak start', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-weak', + author: 'alice', + text: 'Начинаю работу.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-open-weak', + refKind: 'canonical', + canonicalId: 'task-open-weak', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-open-weak', + displayId: 'feed1111', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-weak', + details: { commentId: 'comment-weak' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-weak', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-weak'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'alert', + taskId: 'task-open-weak', + progressSignal: 'weak_start_only', + reason: 'Potential work stall after weak start-only task comment.', + }); + }); + + it('keeps existing thresholds for weak comments from non-OpenCode owners', () => { + const task: TeamTask = { + id: 'task-codex-weak', + displayId: 'feed2222', + subject: 'Codex weak start', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-weak', + author: 'alice', + text: 'Will start.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-codex-weak', + refKind: 'canonical', + canonicalId: 'task-codex-weak', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-codex-weak', + displayId: 'feed2222', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-weak', + details: { commentId: 'comment-weak' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-weak', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'codex']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-weak'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-codex-weak', + skipReason: 'below_threshold', + }); + }); + + it('does not apply weak-start threshold to concrete task comments', () => { + const task: TeamTask = { + id: 'task-open-strong', + displayId: 'feed3333', + subject: 'OpenCode concrete progress', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-strong', + author: 'alice', + text: 'Found the failing test in src/app.ts and reproduced it with pnpm test.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-open-strong', + refKind: 'canonical', + canonicalId: 'task-open-strong', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-open-strong', + displayId: 'feed3333', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-strong', + details: { commentId: 'comment-strong' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-strong', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-strong'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-open-strong', + skipReason: 'below_threshold', + }); + }); + it('fails closed on review branch when review has not started yet', () => { const task: TeamTask = { id: 'task-b', diff --git a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts index 5ee13bf8..b9ea798c 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts @@ -11,6 +11,7 @@ describe('TeamTaskStallSnapshotSource', () => { {} as never, {} as never, {} as never, + {} as never, {} as never ); @@ -42,7 +43,10 @@ describe('TeamTaskStallSnapshotSource', () => { projectDir: '/tmp/project', projectId: 'project-id', config: { - members: [{ name: 'team-lead', role: 'team lead' }], + members: [ + { name: 'team-lead', role: 'team lead', providerId: 'codex' }, + { name: 'alice', role: 'Developer', model: 'qwen/qwen3-coder' }, + ], } as never, sessionIds: ['session-a'], transcriptFiles: ['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl'], @@ -109,6 +113,9 @@ describe('TeamTaskStallSnapshotSource', () => { const exactRowReader = { parseFiles: vi.fn(async () => exactRowsByFilePath), }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'opencode' }]), + }; const source = new TeamTaskStallSnapshotSource( locator as never, @@ -117,7 +124,8 @@ describe('TeamTaskStallSnapshotSource', () => { transcriptReader as never, batchIndexer as never, freshnessReader as never, - exactRowReader as never + exactRowReader as never, + membersMetaStore as never ); const snapshot = await source.getSnapshot('demo'); @@ -133,6 +141,12 @@ describe('TeamTaskStallSnapshotSource', () => { expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']); expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']); expect(snapshot?.leadName).toBe('team-lead'); + expect(snapshot?.providerByMemberName).toEqual( + new Map([ + ['team-lead', 'codex'], + ['alice', 'opencode'], + ]) + ); expect(snapshot?.resolvedReviewersByTaskId.get('task-b')).toEqual({ reviewer: 'alice', source: 'kanban_state', diff --git a/test/main/services/team/stallMonitor/featureGates.test.ts b/test/main/services/team/stallMonitor/featureGates.test.ts index 49369b57..3b9fa951 100644 --- a/test/main/services/team/stallMonitor/featureGates.test.ts +++ b/test/main/services/team/stallMonitor/featureGates.test.ts @@ -2,10 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getTeamTaskStallActivationGraceMs, + getOpenCodeWeakStartStallThresholdMs, getTeamTaskStallScanIntervalMs, getTeamTaskStallStartupGraceMs, + isOpenCodeTaskStallRemediationEnabled, isTeamTaskStallAlertsEnabled, isTeamTaskStallMonitorEnabled, + isTeamTaskStallScannerEnabled, } from '../../../../../src/main/services/team/stallMonitor/featureGates'; afterEach(() => { @@ -15,10 +18,13 @@ 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); expect(getTeamTaskStallScanIntervalMs()).toBe(60_000); expect(getTeamTaskStallStartupGraceMs()).toBe(180_000); expect(getTeamTaskStallActivationGraceMs()).toBe(120_000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(360_000); }); it('parses truthy and falsy environment values', () => { @@ -27,11 +33,23 @@ describe('stallMonitor feature gates', () => { vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1500'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '2000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '3000'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'yes'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS', '4000'); expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); expect(isTeamTaskStallAlertsEnabled()).toBe(false); expect(getTeamTaskStallScanIntervalMs()).toBe(1500); expect(getTeamTaskStallStartupGraceMs()).toBe(2000); expect(getTeamTaskStallActivationGraceMs()).toBe(3000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(4000); + }); + + it('enables the scanner when only OpenCode remediation is enabled', () => { + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(false); + expect(isTeamTaskStallScannerEnabled()).toBe(true); }); });