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,
|
type DashboardRecentProjectsPayload,
|
||||||
normalizeDashboardRecentProjectsPayload,
|
normalizeDashboardRecentProjectsPayload,
|
||||||
} from '@features/recent-projects/contracts';
|
} from '@features/recent-projects/contracts';
|
||||||
import {
|
|
||||||
CodexBinaryResolver,
|
|
||||||
JsonRpcStdioClient,
|
|
||||||
} from '@main/services/infrastructure/codexAppServer';
|
|
||||||
|
|
||||||
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
|
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
|
||||||
import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter';
|
import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter';
|
||||||
import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter';
|
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 { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache';
|
||||||
import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient';
|
|
||||||
import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver';
|
import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
|
|
||||||
import type { ClockPort } from '../../core/application/ports/ClockPort';
|
import type { ClockPort } from '../../core/application/ports/ClockPort';
|
||||||
|
|
@ -31,16 +26,12 @@ export function createRecentProjectsFeature(deps: {
|
||||||
const cache = new InMemoryRecentProjectsCache<DashboardRecentProjectsPayload>();
|
const cache = new InMemoryRecentProjectsCache<DashboardRecentProjectsPayload>();
|
||||||
const presenter = new DashboardRecentProjectsPresenter();
|
const presenter = new DashboardRecentProjectsPresenter();
|
||||||
const clock: ClockPort = { now: () => Date.now() };
|
const clock: ClockPort = { now: () => Date.now() };
|
||||||
const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger);
|
|
||||||
const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient);
|
|
||||||
const identityResolver = new RecentProjectIdentityResolver();
|
const identityResolver = new RecentProjectIdentityResolver();
|
||||||
const sources = [
|
const sources = [
|
||||||
new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger),
|
new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger),
|
||||||
new CodexRecentProjectsSourceAdapter({
|
new CodexSessionFileRecentProjectsSourceAdapter({
|
||||||
getActiveContext: deps.getActiveContext,
|
getActiveContext: deps.getActiveContext,
|
||||||
getLocalContext: deps.getLocalContext,
|
getLocalContext: deps.getLocalContext,
|
||||||
resolveBinary: () => CodexBinaryResolver.resolve(),
|
|
||||||
appServerClient: codexAppServerClient,
|
|
||||||
identityResolver,
|
identityResolver,
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1079,7 +1079,7 @@ async function initializeServices(): Promise<void> {
|
||||||
new TeamTaskStallSnapshotSource(),
|
new TeamTaskStallSnapshotSource(),
|
||||||
new TeamTaskStallPolicy(),
|
new TeamTaskStallPolicy(),
|
||||||
new TeamTaskStallJournal(),
|
new TeamTaskStallJournal(),
|
||||||
new TeamTaskStallNotifier(teamDataService)
|
new TeamTaskStallNotifier(teamDataService, teamProvisioningService)
|
||||||
);
|
);
|
||||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
branchStatusService = new BranchStatusService((event) => {
|
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;
|
teamName: string;
|
||||||
evaluations: TaskStallEvaluation[];
|
evaluations: TaskStallEvaluation[];
|
||||||
activeTaskIds: string[];
|
activeTaskIds: string[];
|
||||||
|
scopeTaskIds?: string[];
|
||||||
now: string;
|
now: string;
|
||||||
}): Promise<TaskStallEvaluation[]> {
|
}): Promise<TaskStallEvaluation[]> {
|
||||||
const filePath = this.getFilePath(args.teamName);
|
const filePath = this.getFilePath(args.teamName);
|
||||||
|
|
@ -48,8 +49,12 @@ export class TeamTaskStallJournal {
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTaskIdSet = new Set(args.activeTaskIds);
|
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) {
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
|
if (scopeTaskIdSet && !scopeTaskIdSet.has(entry.taskId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) {
|
if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) {
|
||||||
entries.splice(i, 1);
|
entries.splice(i, 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import {
|
||||||
getTeamTaskStallActivationGraceMs,
|
getTeamTaskStallActivationGraceMs,
|
||||||
getTeamTaskStallScanIntervalMs,
|
getTeamTaskStallScanIntervalMs,
|
||||||
getTeamTaskStallStartupGraceMs,
|
getTeamTaskStallStartupGraceMs,
|
||||||
|
isOpenCodeTaskStallRemediationEnabled,
|
||||||
isTeamTaskStallAlertsEnabled,
|
isTeamTaskStallAlertsEnabled,
|
||||||
isTeamTaskStallMonitorEnabled,
|
isTeamTaskStallMonitorEnabled,
|
||||||
|
isTeamTaskStallScannerEnabled,
|
||||||
} from './featureGates';
|
} from './featureGates';
|
||||||
|
|
||||||
import type { ActiveTeamRegistry } from './ActiveTeamRegistry';
|
import type { ActiveTeamRegistry } from './ActiveTeamRegistry';
|
||||||
|
|
@ -40,7 +42,7 @@ export class TeamTaskStallMonitor {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (!isTeamTaskStallMonitorEnabled()) {
|
if (!isTeamTaskStallScannerEnabled()) {
|
||||||
logger.debug('Task stall monitor disabled by feature gate');
|
logger.debug('Task stall monitor disabled by feature gate');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +69,7 @@ export class TeamTaskStallMonitor {
|
||||||
|
|
||||||
noteTeamChange(event: TeamChangeEvent): void {
|
noteTeamChange(event: TeamChangeEvent): void {
|
||||||
this.registry.noteTeamChange(event);
|
this.registry.noteTeamChange(event);
|
||||||
if (!isTeamTaskStallMonitorEnabled()) {
|
if (!isTeamTaskStallScannerEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,13 +179,20 @@ export class TeamTaskStallMonitor {
|
||||||
evaluations.push(this.policy.evaluateReview({ now, task, snapshot }));
|
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 = [
|
const activeTaskIds = [
|
||||||
...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)),
|
...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)),
|
||||||
];
|
];
|
||||||
const readyEvaluations = await this.journal.reconcileScan({
|
const readyEvaluations = await this.journal.reconcileScan({
|
||||||
teamName,
|
teamName,
|
||||||
evaluations,
|
evaluations: journalEvaluations,
|
||||||
activeTaskIds,
|
activeTaskIds,
|
||||||
|
...(scopedTaskIds ? { scopeTaskIds: scopedTaskIds } : {}),
|
||||||
now: now.toISOString(),
|
now: now.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -195,14 +204,31 @@ export class TeamTaskStallMonitor {
|
||||||
return;
|
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}`);
|
logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.notifier.notifyLead(teamName, alerts);
|
|
||||||
await Promise.all(
|
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 displayId = getTaskDisplayId(task);
|
||||||
|
const ownerProviderId = task.owner
|
||||||
|
? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase())
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
teamName: snapshot.teamName,
|
teamName: snapshot.teamName,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
|
@ -234,8 +263,11 @@ export class TeamTaskStallMonitor {
|
||||||
subject: task.subject,
|
subject: task.subject,
|
||||||
branch: evaluation.branch,
|
branch: evaluation.branch,
|
||||||
signal: evaluation.signal,
|
signal: evaluation.signal,
|
||||||
|
...(evaluation.progressSignal ? { progressSignal: evaluation.progressSignal } : {}),
|
||||||
reason: evaluation.reason,
|
reason: evaluation.reason,
|
||||||
epochKey: evaluation.epochKey,
|
epochKey: evaluation.epochKey,
|
||||||
|
...(task.owner ? { owner: task.owner } : {}),
|
||||||
|
...(ownerProviderId ? { ownerProviderId } : {}),
|
||||||
taskRef: {
|
taskRef: {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
displayId,
|
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 { 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 { TeamDataService } from '../TeamDataService';
|
||||||
|
import type { TeamProvisioningService } from '../TeamProvisioningService';
|
||||||
import type { TaskStallAlert } from './TeamTaskStallTypes';
|
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 {
|
function buildLeadAlertText(alerts: TaskStallAlert[]): string {
|
||||||
return alerts
|
return alerts
|
||||||
|
|
@ -12,9 +28,37 @@ function buildLeadAlertText(alerts: TaskStallAlert[]): string {
|
||||||
.join('\n');
|
.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 {
|
export class TeamTaskStallNotifier {
|
||||||
constructor(
|
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> {
|
async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise<void> {
|
||||||
|
|
@ -29,4 +73,108 @@ export class TeamTaskStallNotifier {
|
||||||
taskRefs: alerts.map((alert) => alert.taskRef),
|
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 type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
||||||
|
import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier';
|
||||||
import type {
|
import type {
|
||||||
ReviewTaskContext,
|
ReviewTaskContext,
|
||||||
TaskStallBranch,
|
TaskStallBranch,
|
||||||
|
|
@ -8,6 +9,7 @@ import type {
|
||||||
TeamTaskStallSnapshot,
|
TeamTaskStallSnapshot,
|
||||||
WorkTaskContext,
|
WorkTaskContext,
|
||||||
} from './TeamTaskStallTypes';
|
} from './TeamTaskStallTypes';
|
||||||
|
import { getOpenCodeWeakStartStallThresholdMs } from './featureGates';
|
||||||
import type { TaskHistoryEvent, TaskWorkInterval, TeamTask } from '@shared/types';
|
import type { TaskHistoryEvent, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']);
|
const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']);
|
||||||
|
|
@ -286,6 +288,7 @@ function buildAlertEvaluation(args: {
|
||||||
task: TeamTask;
|
task: TeamTask;
|
||||||
branch: TaskStallBranch;
|
branch: TaskStallBranch;
|
||||||
signal: TaskStallSignal;
|
signal: TaskStallSignal;
|
||||||
|
progressSignal?: TaskProgressSignal;
|
||||||
touch: BoardTaskActivityRecord;
|
touch: BoardTaskActivityRecord;
|
||||||
reason: string;
|
reason: string;
|
||||||
}): TaskStallEvaluation {
|
}): TaskStallEvaluation {
|
||||||
|
|
@ -294,11 +297,17 @@ function buildAlertEvaluation(args: {
|
||||||
taskId: args.task.id,
|
taskId: args.task.id,
|
||||||
branch: args.branch,
|
branch: args.branch,
|
||||||
signal: args.signal,
|
signal: args.signal,
|
||||||
|
...(args.progressSignal ? { progressSignal: args.progressSignal } : {}),
|
||||||
epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch),
|
epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch),
|
||||||
reason: args.reason,
|
reason: args.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMemberNameKey(name: string | undefined): string | null {
|
||||||
|
const normalized = name?.trim().toLowerCase();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamTaskStallPolicy {
|
export class TeamTaskStallPolicy {
|
||||||
evaluateWork(args: {
|
evaluateWork(args: {
|
||||||
now: Date;
|
now: Date;
|
||||||
|
|
@ -383,8 +392,18 @@ export class TeamTaskStallPolicy {
|
||||||
return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state');
|
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 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) {
|
if (elapsedMs < thresholdMs) {
|
||||||
return skip(
|
return skip(
|
||||||
task.id,
|
task.id,
|
||||||
|
|
@ -397,8 +416,11 @@ export class TeamTaskStallPolicy {
|
||||||
task,
|
task,
|
||||||
branch: 'work',
|
branch: 'work',
|
||||||
signal,
|
signal,
|
||||||
|
progressSignal: progressClassification.signal,
|
||||||
touch: workContext.lastMeaningfulTouch,
|
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 { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates';
|
||||||
import { TeamKanbanManager } from '../TeamKanbanManager';
|
import { TeamKanbanManager } from '../TeamKanbanManager';
|
||||||
import { TeamTaskReader } from '../TeamTaskReader';
|
import { TeamTaskReader } from '../TeamTaskReader';
|
||||||
|
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||||
|
|
||||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||||
import { buildResolvedReviewerIndex } from './reviewerResolution';
|
import { buildResolvedReviewerIndex } from './reviewerResolution';
|
||||||
import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader';
|
import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader';
|
||||||
import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader';
|
import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader';
|
||||||
|
import {
|
||||||
|
inferTeamProviderIdFromModel,
|
||||||
|
normalizeOptionalTeamProviderId,
|
||||||
|
} from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
||||||
import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes';
|
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 {
|
function resolveLeadNameFromConfig(config: TeamConfig): string {
|
||||||
const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead'));
|
const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead'));
|
||||||
return lead?.name ?? config.members?.[0]?.name ?? 'team-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 {
|
export class TeamTaskStallSnapshotSource {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||||
|
|
@ -27,7 +68,8 @@ export class TeamTaskStallSnapshotSource {
|
||||||
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
|
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
|
||||||
private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(),
|
private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(),
|
||||||
private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(),
|
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> {
|
async getSnapshot(teamName: string): Promise<TeamTaskStallSnapshot | null> {
|
||||||
|
|
@ -36,10 +78,11 @@ export class TeamTaskStallSnapshotSource {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [activeTasks, deletedTasks, kanbanState] = await Promise.all([
|
const [activeTasks, deletedTasks, kanbanState, metaMembers] = await Promise.all([
|
||||||
this.taskReader.getTasks(teamName),
|
this.taskReader.getTasks(teamName),
|
||||||
this.taskReader.getDeletedTasks(teamName),
|
this.taskReader.getDeletedTasks(teamName),
|
||||||
this.kanbanManager.getState(teamName),
|
this.kanbanManager.getState(teamName),
|
||||||
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
]);
|
]);
|
||||||
const allTasks = [...activeTasks, ...deletedTasks];
|
const allTasks = [...activeTasks, ...deletedTasks];
|
||||||
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
|
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 resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
|
||||||
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
||||||
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
||||||
|
const providerByMemberName = buildProviderByMemberName({
|
||||||
|
configMembers: transcriptContext.config.members ?? [],
|
||||||
|
metaMembers,
|
||||||
|
});
|
||||||
|
|
||||||
let recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
|
let recordsByTaskId = new Map<string, BoardTaskActivityRecord[]>();
|
||||||
if (
|
if (
|
||||||
|
|
@ -98,6 +145,7 @@ export class TeamTaskStallSnapshotSource {
|
||||||
recordsByTaskId,
|
recordsByTaskId,
|
||||||
freshnessByTaskId,
|
freshnessByTaskId,
|
||||||
exactRowsByFilePath,
|
exactRowsByFilePath,
|
||||||
|
providerByMemberName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
||||||
|
import type { TaskProgressSignal } from './TaskProgressSignalClassifier';
|
||||||
import type { ParsedMessage } from '@main/types';
|
import type { ParsedMessage } from '@main/types';
|
||||||
import type { TeamTask } from '@shared/types';
|
import type { TeamProviderId, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
export type TaskStallBranch = 'work' | 'review';
|
export type TaskStallBranch = 'work' | 'review';
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ export interface TaskStallEvaluation {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
branch?: TaskStallBranch;
|
branch?: TaskStallBranch;
|
||||||
signal?: TaskStallSignal;
|
signal?: TaskStallSignal;
|
||||||
|
progressSignal?: TaskProgressSignal;
|
||||||
epochKey?: string;
|
epochKey?: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
skipReason?: TaskStallSkipReason;
|
skipReason?: TaskStallSkipReason;
|
||||||
|
|
@ -91,6 +93,7 @@ export interface TeamTaskStallSnapshot {
|
||||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||||
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
|
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
|
||||||
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
|
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
|
||||||
|
providerByMemberName: Map<string, TeamProviderId>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkTaskContext {
|
export interface WorkTaskContext {
|
||||||
|
|
@ -114,8 +117,11 @@ export interface TaskStallAlert {
|
||||||
subject: string;
|
subject: string;
|
||||||
branch: TaskStallBranch;
|
branch: TaskStallBranch;
|
||||||
signal: TaskStallSignal;
|
signal: TaskStallSignal;
|
||||||
|
progressSignal?: TaskProgressSignal;
|
||||||
reason: string;
|
reason: string;
|
||||||
epochKey: string;
|
epochKey: string;
|
||||||
|
owner?: string;
|
||||||
|
ownerProviderId?: TeamProviderId;
|
||||||
taskRef: {
|
taskRef: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
displayId: string;
|
displayId: string;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ export function isTeamTaskStallMonitorEnabled(): boolean {
|
||||||
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false);
|
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 {
|
export function isTeamTaskStallAlertsEnabled(): boolean {
|
||||||
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false);
|
return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false);
|
||||||
}
|
}
|
||||||
|
|
@ -40,3 +48,7 @@ export function getTeamTaskStallStartupGraceMs(): number {
|
||||||
export function getTeamTaskStallActivationGraceMs(): number {
|
export function getTeamTaskStallActivationGraceMs(): number {
|
||||||
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000);
|
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 { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||||
|
import { TeamConfigReader } from '../../TeamConfigReader';
|
||||||
|
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
|
||||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
||||||
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||||
|
|
@ -57,6 +59,7 @@ interface TimeWindow {
|
||||||
interface StreamLayout {
|
interface StreamLayout {
|
||||||
participants: BoardTaskLogParticipant[];
|
participants: BoardTaskLogParticipant[];
|
||||||
visibleSlices: StreamSlice[];
|
visibleSlices: StreamSlice[];
|
||||||
|
shouldMergeOpenCodeRuntimeFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger('Service:BoardTaskLogStreamService');
|
const logger = createLogger('Service:BoardTaskLogStreamService');
|
||||||
|
|
@ -1421,6 +1424,64 @@ function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number {
|
||||||
return segmentCount;
|
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 {
|
export class BoardTaskLogStreamService {
|
||||||
private readonly layoutCache = new Map<
|
private readonly layoutCache = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -1440,7 +1501,9 @@ export class BoardTaskLogStreamService {
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
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 {
|
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||||
|
|
@ -1898,9 +1961,63 @@ export class BoardTaskLogStreamService {
|
||||||
return {
|
return {
|
||||||
participants: buildOrderedParticipants(visibleSlices),
|
participants: buildOrderedParticipants(visibleSlices),
|
||||||
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(
|
async getTaskLogStreamSummary(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string
|
taskId: string
|
||||||
|
|
@ -1926,16 +2043,7 @@ export class BoardTaskLogStreamService {
|
||||||
|
|
||||||
const layout = await this.getStreamLayout(teamName, taskId);
|
const layout = await this.getStreamLayout(teamName, taskId);
|
||||||
if (layout.visibleSlices.length === 0) {
|
if (layout.visibleSlices.length === 0) {
|
||||||
const startedAt = Date.now();
|
const fallback = await this.loadRuntimeFallback(teamName, taskId);
|
||||||
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 ?? emptyResponse();
|
return fallback ?? emptyResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1984,14 +2092,18 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
flushSegment();
|
flushSegment();
|
||||||
|
|
||||||
const namedParticipants = layout.participants.filter((participant) => !participant.isLead);
|
const primaryResponse: BoardTaskLogStreamResponse = {
|
||||||
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
|
|
||||||
|
|
||||||
return {
|
|
||||||
participants: layout.participants,
|
participants: layout.participants,
|
||||||
defaultFilter,
|
defaultFilter: chooseDefaultFilter(layout.participants),
|
||||||
segments,
|
segments,
|
||||||
source: 'transcript',
|
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 */}
|
{/* Markdown content with scroll */}
|
||||||
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
|
<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
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
||||||
|
|
|
||||||
|
|
@ -1161,8 +1161,8 @@ export const ActivityItem = memo(
|
||||||
tabIndex={isHeaderClickable ? 0 : undefined}
|
tabIndex={isHeaderClickable ? 0 : undefined}
|
||||||
className={[
|
className={[
|
||||||
useCompactCollapsedHeader
|
useCompactCollapsedHeader
|
||||||
? 'min-w-0 px-3 py-2'
|
? 'min-w-0 px-2.5 py-1.5'
|
||||||
: 'flex min-w-0 items-center gap-2 px-3 py-2',
|
: 'flex min-w-0 items-center gap-2 px-2.5 py-1.5',
|
||||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={handleHeaderToggle}
|
onClick={handleHeaderToggle}
|
||||||
|
|
@ -1396,7 +1396,7 @@ export const ActivityItem = memo(
|
||||||
|
|
||||||
{/* Content — collapsed for system messages, expanded for others */}
|
{/* Content — collapsed for system messages, expanded for others */}
|
||||||
{isExpanded ? (
|
{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 ? (
|
{structured ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{autoSummary && autoSummary !== messageType ? (
|
{autoSummary && autoSummary !== messageType ? (
|
||||||
|
|
@ -1547,6 +1547,7 @@ export const ActivityItem = memo(
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={displayText}
|
content={displayText}
|
||||||
maxHeight="max-h-none"
|
maxHeight="max-h-none"
|
||||||
|
className="[&_li]:text-[13px] [&_p]:text-[13px] [&_table]:text-[13px]"
|
||||||
bare
|
bare
|
||||||
teamColorByName={teamColorByName}
|
teamColorByName={teamColorByName}
|
||||||
onTeamClick={onTeamClick}
|
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.';
|
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.';
|
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);
|
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 () => {
|
it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => {
|
||||||
const tom = {
|
const tom = {
|
||||||
memberName: '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(firstReady).toEqual([]);
|
||||||
expect(secondReady).toEqual([evaluation]);
|
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)
|
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(),
|
recordsByTaskId: new Map(),
|
||||||
freshnessByTaskId: new Map(),
|
freshnessByTaskId: new Map(),
|
||||||
exactRowsByFilePath: new Map(),
|
exactRowsByFilePath: new Map(),
|
||||||
|
providerByMemberName: new Map(),
|
||||||
...overrides,
|
...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', () => {
|
it('fails closed on review branch when review has not started yet', () => {
|
||||||
const task: TeamTask = {
|
const task: TeamTask = {
|
||||||
id: 'task-b',
|
id: 'task-b',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
{} as never
|
{} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -42,7 +43,10 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
projectDir: '/tmp/project',
|
projectDir: '/tmp/project',
|
||||||
projectId: 'project-id',
|
projectId: 'project-id',
|
||||||
config: {
|
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,
|
} as never,
|
||||||
sessionIds: ['session-a'],
|
sessionIds: ['session-a'],
|
||||||
transcriptFiles: ['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl'],
|
transcriptFiles: ['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl'],
|
||||||
|
|
@ -109,6 +113,9 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
const exactRowReader = {
|
const exactRowReader = {
|
||||||
parseFiles: vi.fn(async () => exactRowsByFilePath),
|
parseFiles: vi.fn(async () => exactRowsByFilePath),
|
||||||
};
|
};
|
||||||
|
const membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'opencode' }]),
|
||||||
|
};
|
||||||
|
|
||||||
const source = new TeamTaskStallSnapshotSource(
|
const source = new TeamTaskStallSnapshotSource(
|
||||||
locator as never,
|
locator as never,
|
||||||
|
|
@ -117,7 +124,8 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
transcriptReader as never,
|
transcriptReader as never,
|
||||||
batchIndexer as never,
|
batchIndexer as never,
|
||||||
freshnessReader as never,
|
freshnessReader as never,
|
||||||
exactRowReader as never
|
exactRowReader as never,
|
||||||
|
membersMetaStore as never
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = await source.getSnapshot('demo');
|
const snapshot = await source.getSnapshot('demo');
|
||||||
|
|
@ -133,6 +141,12 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
|
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
|
||||||
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
|
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
|
||||||
expect(snapshot?.leadName).toBe('team-lead');
|
expect(snapshot?.leadName).toBe('team-lead');
|
||||||
|
expect(snapshot?.providerByMemberName).toEqual(
|
||||||
|
new Map([
|
||||||
|
['team-lead', 'codex'],
|
||||||
|
['alice', 'opencode'],
|
||||||
|
])
|
||||||
|
);
|
||||||
expect(snapshot?.resolvedReviewersByTaskId.get('task-b')).toEqual({
|
expect(snapshot?.resolvedReviewersByTaskId.get('task-b')).toEqual({
|
||||||
reviewer: 'alice',
|
reviewer: 'alice',
|
||||||
source: 'kanban_state',
|
source: 'kanban_state',
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTeamTaskStallActivationGraceMs,
|
getTeamTaskStallActivationGraceMs,
|
||||||
|
getOpenCodeWeakStartStallThresholdMs,
|
||||||
getTeamTaskStallScanIntervalMs,
|
getTeamTaskStallScanIntervalMs,
|
||||||
getTeamTaskStallStartupGraceMs,
|
getTeamTaskStallStartupGraceMs,
|
||||||
|
isOpenCodeTaskStallRemediationEnabled,
|
||||||
isTeamTaskStallAlertsEnabled,
|
isTeamTaskStallAlertsEnabled,
|
||||||
isTeamTaskStallMonitorEnabled,
|
isTeamTaskStallMonitorEnabled,
|
||||||
|
isTeamTaskStallScannerEnabled,
|
||||||
} from '../../../../../src/main/services/team/stallMonitor/featureGates';
|
} from '../../../../../src/main/services/team/stallMonitor/featureGates';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -15,10 +18,13 @@ afterEach(() => {
|
||||||
describe('stallMonitor feature gates', () => {
|
describe('stallMonitor feature gates', () => {
|
||||||
it('defaults both monitor and alerts to disabled', () => {
|
it('defaults both monitor and alerts to disabled', () => {
|
||||||
expect(isTeamTaskStallMonitorEnabled()).toBe(false);
|
expect(isTeamTaskStallMonitorEnabled()).toBe(false);
|
||||||
|
expect(isOpenCodeTaskStallRemediationEnabled()).toBe(false);
|
||||||
|
expect(isTeamTaskStallScannerEnabled()).toBe(false);
|
||||||
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
|
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
|
||||||
expect(getTeamTaskStallScanIntervalMs()).toBe(60_000);
|
expect(getTeamTaskStallScanIntervalMs()).toBe(60_000);
|
||||||
expect(getTeamTaskStallStartupGraceMs()).toBe(180_000);
|
expect(getTeamTaskStallStartupGraceMs()).toBe(180_000);
|
||||||
expect(getTeamTaskStallActivationGraceMs()).toBe(120_000);
|
expect(getTeamTaskStallActivationGraceMs()).toBe(120_000);
|
||||||
|
expect(getOpenCodeWeakStartStallThresholdMs()).toBe(360_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses truthy and falsy environment values', () => {
|
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_SCAN_INTERVAL_MS', '1500');
|
||||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '2000');
|
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '2000');
|
||||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '3000');
|
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(isTeamTaskStallMonitorEnabled()).toBe(true);
|
||||||
|
expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true);
|
||||||
|
expect(isTeamTaskStallScannerEnabled()).toBe(true);
|
||||||
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
|
expect(isTeamTaskStallAlertsEnabled()).toBe(false);
|
||||||
expect(getTeamTaskStallScanIntervalMs()).toBe(1500);
|
expect(getTeamTaskStallScanIntervalMs()).toBe(1500);
|
||||||
expect(getTeamTaskStallStartupGraceMs()).toBe(2000);
|
expect(getTeamTaskStallStartupGraceMs()).toBe(2000);
|
||||||
expect(getTeamTaskStallActivationGraceMs()).toBe(3000);
|
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