feat(team): improve stall monitor signals

This commit is contained in:
777genius 2026-04-27 20:01:05 +03:00
parent 122d13f066
commit 376480b84f
24 changed files with 2160 additions and 47 deletions

View file

@ -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<string | null> {
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<CodexSessionFileEntry[]> {
async function walk(directory: string, depth: number): Promise<CodexSessionFileEntry[]> {
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry): Promise<CodexSessionFileEntry[]> => {
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<RecentProjectsSourceResult> {
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<CodexSessionProjectSnapshot[]> {
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<string, CodexSessionProjectSnapshot>();
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<RecentProjectCandidate | null> {
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,
};
}
}

View file

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

View file

@ -1079,7 +1079,7 @@ async function initializeServices(): Promise<void> {
new TeamTaskStallSnapshotSource(),
new TeamTaskStallPolicy(),
new TeamTaskStallJournal(),
new TeamTaskStallNotifier(teamDataService)
new TeamTaskStallNotifier(teamDataService, teamProvisioningService)
);
let teammateToolTracker: TeammateToolTracker | null = null;
branchStatusService = new BranchStatusService((event) => {

View file

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

View file

@ -24,6 +24,7 @@ export class TeamTaskStallJournal {
teamName: string;
evaluations: TaskStallEvaluation[];
activeTaskIds: string[];
scopeTaskIds?: string[];
now: string;
}): Promise<TaskStallEvaluation[]> {
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);
}

View file

@ -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<string>();
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<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>,
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<Awaited<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>>
): 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);
}
}

View file

@ -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<OpenCodeTaskStallRelayService['relayOpenCodeMemberInboxMessages']>
>;
type OpenCodeTaskStallDelivery = NonNullable<OpenCodeTaskStallRelayResult['lastDelivery']>;
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<TeamDataService, 'sendSystemNotificationToLead'>
private readonly teamDataService: Pick<TeamDataService, 'sendSystemNotificationToLead'>,
private readonly teamProvisioningService?: OpenCodeTaskStallRelayService,
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
) {}
async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise<void> {
@ -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<boolean> {
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<TaskStallAlert[]> {
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;
}
}

View file

@ -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('_', ' ')}.`,
});
}

View file

@ -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<string, TeamProviderId> {
const providerByMemberName = new Map<string, TeamProviderId>();
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<TeamTaskStallSnapshot | null> {
@ -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<string, BoardTaskActivityRecord[]>();
if (
@ -98,6 +145,7 @@ export class TeamTaskStallSnapshotSource {
recordsByTaskId,
freshnessByTaskId,
exactRowsByFilePath,
providerByMemberName,
};
}

View file

@ -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<string, BoardTaskActivityRecord[]>;
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
providerByMemberName: Map<string, TeamProviderId>;
}
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;

View file

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

View file

@ -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<string, BoardTaskLogParticipant>();
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<string, BoardTaskLogSegment>();
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<boolean> {
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<BoardTaskLogStreamResponse | null> {
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;
}
}

View file

@ -1083,7 +1083,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-4">
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}

View file

@ -1161,8 +1161,8 @@ export const ActivityItem = memo(
tabIndex={isHeaderClickable ? 0 : undefined}
className={[
useCompactCollapsedHeader
? 'min-w-0 px-3 py-2'
: 'flex min-w-0 items-center gap-2 px-3 py-2',
? 'min-w-0 px-2.5 py-1.5'
: 'flex min-w-0 items-center gap-2 px-2.5 py-1.5',
isHeaderClickable ? 'cursor-pointer select-none' : '',
].join(' ')}
onClick={handleHeaderToggle}
@ -1396,7 +1396,7 @@ export const ActivityItem = memo(
{/* Content — collapsed for system messages, expanded for others */}
{isExpanded ? (
<div className="min-w-0 overflow-hidden px-3 pb-3">
<div className="min-w-0 overflow-hidden px-2.5 pb-2.5">
{structured ? (
<div className="space-y-2">
{autoSummary && autoSummary !== messageType ? (
@ -1547,6 +1547,7 @@ export const ActivityItem = memo(
<MarkdownViewer
content={displayText}
maxHeight="max-h-none"
className="[&_li]:text-[13px] [&_p]:text-[13px] [&_table]:text-[13px]"
bare
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}

View file

@ -81,6 +81,9 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string
}
return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.';
}
if (stream?.runtimeProjection?.provider === 'opencode') {
return 'Task-scoped transcript logs merged with OpenCode runtime logs and rendered with the same execution-log components used in Logs.';
}
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
}

View file

@ -0,0 +1,258 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CodexSessionFileRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
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<void> {
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,
});
});
});

View file

@ -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',

View file

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

View file

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

View file

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

View file

@ -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> = {}): 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();
});
});

View file

@ -97,6 +97,7 @@ function createSnapshot(overrides: Partial<TeamTaskStallSnapshot>): 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',

View file

@ -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',

View file

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