feat(team): improve stall monitor signals
This commit is contained in:
parent
122d13f066
commit
376480b84f
24 changed files with 2160 additions and 47 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('_', ' ')}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue