feat(team): harden runtime delivery and diagnostics

This commit is contained in:
777genius 2026-05-09 00:25:55 +03:00
parent 88b3ea2358
commit 80acc3b663
66 changed files with 5085 additions and 312 deletions

View file

@ -75,6 +75,10 @@ function closeTimestampForInterval(interval, timestamp) {
return timestamp;
}
function isOpenReviewInterval(interval) {
return interval && interval.completedAt === undefined;
}
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
if (!reviewerName) return false;
@ -83,7 +87,7 @@ function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()
let changed = false;
let hasOpenForReviewer = false;
const nextIntervals = intervals.map((interval) => {
if (interval.completedAt) return interval;
if (!isOpenReviewInterval(interval)) return interval;
if (normalizeActorKey(interval.reviewer) === reviewerKey) {
hasOpenForReviewer = true;
return interval;
@ -103,7 +107,7 @@ function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
if (!Array.isArray(task.reviewIntervals)) return false;
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (!isOpenReviewInterval(interval)) return interval;
changed = true;
return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) };
});

View file

@ -201,13 +201,23 @@ function appendHistoryEvent(events, event) {
return list;
}
function isOpenReviewInterval(interval) {
return interval && interval.completedAt === undefined;
}
function closeOpenReviewIntervals(task, timestamp) {
if (!Array.isArray(task.reviewIntervals)) return false;
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (!isOpenReviewInterval(interval)) return interval;
changed = true;
return { ...interval, completedAt: timestamp };
const startedAtMs = Date.parse(interval.startedAt);
const timestampMs = Date.parse(timestamp);
const completedAt =
Number.isFinite(startedAtMs) && Number.isFinite(timestampMs) && timestampMs < startedAtMs
? interval.startedAt
: timestamp;
return { ...interval, completedAt };
});
return changed;
}
@ -466,7 +476,7 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
if (task.status !== 'in_progress' && status === 'in_progress') {
if (!lastInterval || typeof lastInterval.completedAt === 'string') {
if (!lastInterval || lastInterval.completedAt !== undefined) {
workIntervals.push({ startedAt: timestamp });
}
} else if (task.status === 'in_progress' && status !== 'in_progress') {

View file

@ -722,6 +722,23 @@ describe('agent-teams-controller API', () => {
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
});
it('does not treat malformed empty completedAt work intervals as already open', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Malformed work interval' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.workIntervals = [{ startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' }];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.tasks.startTask(task.id, 'bob');
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.workIntervals).toHaveLength(2);
expect(reloaded.workIntervals[0].completedAt).toBe('');
expect(reloaded.workIntervals[1].completedAt).toBeUndefined();
});
it('tracks owner assignment history without duplicate same-owner events', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -851,6 +868,30 @@ describe('agent-teams-controller API', () => {
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
});
it('does not treat malformed empty completedAt review intervals as already open', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.reviewIntervals = [
{ reviewer: 'alice', startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' },
];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id, { from: 'alice' });
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.reviewIntervals).toHaveLength(2);
expect(reloaded.reviewIntervals[0].completedAt).toBe('');
expect(reloaded.reviewIntervals[1].reviewer).toBe('alice');
expect(reloaded.reviewIntervals[1].completedAt).toBeUndefined();
});
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -2212,13 +2253,22 @@ describe('agent-teams-controller API', () => {
to: 'review',
actor: 'carol',
});
rawTask.reviewIntervals = [{ reviewer: 'carol', startedAt: '2026-01-01T00:00:00.000Z' }];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id);
const startedEvents = controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started');
const repairedTask = controller.tasks.getTask(task.id);
const startedEvents = repairedTask.historyEvents.filter(
(event) => event.type === 'review_started'
);
expect(startedEvents.at(-1).actor).toBe('alice');
expect(repairedTask.reviewIntervals).toHaveLength(2);
expect(repairedTask.reviewIntervals[0]).toMatchObject({
reviewer: 'carol',
completedAt: expect.any(String),
});
expect(repairedTask.reviewIntervals[1].reviewer).toBe('alice');
expect(repairedTask.reviewIntervals[1].completedAt).toBeUndefined();
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const carolBriefing = await controller.tasks.taskBriefing('carol');

View file

@ -3,6 +3,8 @@ import type {
MemberLogPreviewResponse,
MemberLogStreamRequestOptions,
MemberLogStreamResponse,
MemberRuntimeLogTailOptions,
MemberRuntimeLogTailResponse,
} from './dto';
export interface MemberLogStreamApi {
@ -16,5 +18,10 @@ export interface MemberLogStreamApi {
memberNames: string[],
options?: MemberLogPreviewRequestOptions
): Promise<MemberLogPreviewResponse>;
getMemberRuntimeLogTail(
teamName: string,
memberName: string,
options: MemberRuntimeLogTailOptions
): Promise<MemberRuntimeLogTailResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}

View file

@ -1,3 +1,4 @@
export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream';
export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews';
export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking';
export const MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL = 'member-log-stream:getMemberRuntimeLogTail';

View file

@ -110,3 +110,21 @@ export interface MemberLogPreviewResponse {
members: MemberLogPreviewMember[];
generatedAt: string;
}
export type MemberRuntimeLogKind = 'stdout' | 'stderr' | 'events';
export interface MemberRuntimeLogTailOptions {
kind: MemberRuntimeLogKind;
maxBytes?: number;
forceRefresh?: boolean;
}
export interface MemberRuntimeLogTailResponse {
kind: MemberRuntimeLogKind;
content: string;
truncated: boolean;
bytesRead: number;
fileSizeBytes?: number;
updatedAt?: string;
missing: boolean;
}

View file

@ -2,6 +2,8 @@ import type {
MemberLogPreviewMember,
MemberLogPreviewResponse,
MemberLogStreamResponse,
MemberRuntimeLogKind,
MemberRuntimeLogTailResponse,
} from './dto';
export function createEmptyMemberLogStreamResponse(
@ -91,3 +93,52 @@ export function normalizeMemberLogPreviewResponse(
: new Date().toISOString(),
};
}
const MEMBER_RUNTIME_LOG_KINDS = new Set<MemberRuntimeLogKind>(['stdout', 'stderr', 'events']);
function normalizeMemberRuntimeLogKind(kind: unknown): MemberRuntimeLogKind {
return MEMBER_RUNTIME_LOG_KINDS.has(kind as MemberRuntimeLogKind)
? (kind as MemberRuntimeLogKind)
: 'stdout';
}
function normalizeOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
export function createEmptyMemberRuntimeLogTailResponse(
kind: MemberRuntimeLogKind = 'stdout'
): MemberRuntimeLogTailResponse {
return {
kind,
content: '',
truncated: false,
bytesRead: 0,
missing: true,
};
}
export function normalizeMemberRuntimeLogTailResponse(
response: MemberRuntimeLogTailResponse | null | undefined
): MemberRuntimeLogTailResponse {
if (!response) {
return createEmptyMemberRuntimeLogTailResponse();
}
const kind = normalizeMemberRuntimeLogKind(response.kind);
const fileSizeBytes = normalizeOptionalFiniteNumber(response.fileSizeBytes);
const updatedAt =
typeof response.updatedAt === 'string' && response.updatedAt.length > 0
? response.updatedAt
: undefined;
return {
kind,
content: typeof response.content === 'string' ? response.content : '',
truncated: response.truncated === true,
bytesRead: normalizeOptionalFiniteNumber(response.bytesRead) ?? 0,
...(fileSizeBytes !== undefined ? { fileSizeBytes } : {}),
...(updatedAt !== undefined ? { updatedAt } : {}),
missing: response.missing === true,
};
}

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_GET_PREVIEWS,
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
MEMBER_LOG_STREAM_SET_TRACKING,
} from '../../../../../contracts';
import {
@ -50,6 +51,16 @@ function emptyPreviewResponse(): MemberLogPreviewResponse {
};
}
function emptyRuntimeLogTailResponse() {
return {
kind: 'stdout' as const,
content: '',
truncated: false,
bytesRead: 0,
missing: true,
};
}
function createFakeIpcMain(): {
handlers: Map<string, (...args: unknown[]) => unknown>;
ipcMain: {
@ -78,6 +89,7 @@ describe('registerMemberLogStreamIpc', () => {
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking: vi.fn(),
};
@ -111,6 +123,7 @@ describe('registerMemberLogStreamIpc', () => {
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking: vi.fn(),
};
@ -138,6 +151,7 @@ describe('registerMemberLogStreamIpc', () => {
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking: vi.fn(),
};
@ -187,6 +201,7 @@ describe('registerMemberLogStreamIpc', () => {
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking,
};
@ -206,6 +221,7 @@ describe('registerMemberLogStreamIpc', () => {
expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false);
expect(handlers.has(MEMBER_LOG_STREAM_GET_PREVIEWS)).toBe(false);
expect(handlers.has(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL)).toBe(false);
expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false);
});
@ -215,6 +231,7 @@ describe('registerMemberLogStreamIpc', () => {
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
getMemberLogPreviews,
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking: vi.fn(),
};
@ -243,12 +260,66 @@ describe('registerMemberLogStreamIpc', () => {
});
});
it('validates runtime log tail requests before calling the feature facade', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberRuntimeLogTail = vi.fn().mockResolvedValue({
kind: 'stderr',
content: 'runtime error',
truncated: false,
bytesRead: 13,
missing: false,
});
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
getMemberRuntimeLogTail,
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const getRuntimeTail = handlers.get(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL)!;
await expect(
getRuntimeTail({} as IpcMainInvokeEvent, 'alpha-team', 'alice', {
kind: 'stderr',
maxBytes: 999999,
forceRefresh: true,
})
).resolves.toEqual({
success: true,
data: {
kind: 'stderr',
content: 'runtime error',
truncated: false,
bytesRead: 13,
missing: false,
},
});
expect(getMemberRuntimeLogTail).toHaveBeenCalledWith({
teamName: 'alpha-team',
memberName: 'alice',
options: {
kind: 'stderr',
maxBytes: 512 * 1024,
forceRefresh: true,
},
});
await expect(
getRuntimeTail({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { kind: 'bad' })
).resolves.toEqual({
success: false,
error: 'kind must be stdout, stderr, or events',
});
});
it('rejects unknown batch preview options and unsafe lane maps', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
getMemberLogPreviews,
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
setMemberLogStreamTracking: vi.fn(),
};

View file

@ -4,9 +4,11 @@ import { createLogger } from '@shared/utils/logger';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_GET_PREVIEWS,
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
MEMBER_LOG_STREAM_SET_TRACKING,
normalizeMemberLogPreviewResponse,
normalizeMemberLogStreamResponse,
normalizeMemberRuntimeLogTailResponse,
} from '../../../../contracts';
import type {
@ -14,6 +16,8 @@ import type {
MemberLogPreviewResponse,
MemberLogStreamRequestOptions,
MemberLogStreamResponse,
MemberRuntimeLogTailOptions,
MemberRuntimeLogTailResponse,
} from '../../../../contracts';
import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature';
import type { IpcResult } from '@shared/types';
@ -27,6 +31,8 @@ const ALLOWED_PREVIEW_OPTION_KEYS = new Set([
'laneIdsByMember',
'forceRefresh',
]);
const ALLOWED_RUNTIME_LOG_OPTION_KEYS = new Set(['kind', 'maxBytes', 'forceRefresh']);
const MEMBER_RUNTIME_LOG_KINDS = new Set(['stdout', 'stderr', 'events']);
interface ValidationResult<T> {
valid: boolean;
@ -217,6 +223,50 @@ function normalizePreviewOptions(options: unknown): ValidationResult<{
};
}
function normalizeRuntimeLogOptions(
options: unknown
): ValidationResult<MemberRuntimeLogTailOptions> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
return { valid: false, error: 'options must be an object' };
}
const record = options as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!ALLOWED_RUNTIME_LOG_OPTION_KEYS.has(key)) {
return { valid: false, error: `Unknown getMemberRuntimeLogTail option: ${key}` };
}
}
if (!MEMBER_RUNTIME_LOG_KINDS.has(record.kind as string)) {
return { valid: false, error: 'kind must be stdout, stderr, or events' };
}
let maxBytes: number | undefined;
if (record.maxBytes != null) {
if (typeof record.maxBytes !== 'number' || !Number.isFinite(record.maxBytes)) {
return { valid: false, error: 'maxBytes must be a finite number' };
}
maxBytes = Math.max(1024, Math.min(512 * 1024, Math.floor(record.maxBytes)));
}
let forceRefresh: boolean | undefined;
if (record.forceRefresh != null) {
if (typeof record.forceRefresh !== 'boolean') {
return { valid: false, error: 'forceRefresh must be a boolean' };
}
forceRefresh = record.forceRefresh;
}
return {
valid: true,
value: {
kind: record.kind as MemberRuntimeLogTailOptions['kind'],
...(maxBytes !== undefined ? { maxBytes } : {}),
...(forceRefresh !== undefined ? { forceRefresh } : {}),
},
};
}
export function registerMemberLogStreamIpc(
ipcMain: IpcMain,
feature: MemberLogStreamFeatureFacade
@ -324,10 +374,49 @@ export function registerMemberLogStreamIpc(
}
}
);
ipcMain.handle(
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
async (
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown,
options?: MemberRuntimeLogTailOptions
): Promise<IpcResult<MemberRuntimeLogTailResponse>> => {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vMember = validateMemberName(memberName);
if (!vMember.valid) {
return { success: false, error: vMember.error ?? 'Invalid memberName' };
}
const vOptions = normalizeRuntimeLogOptions(options);
if (!vOptions.valid) {
return { success: false, error: vOptions.error ?? 'Invalid options' };
}
try {
const response = await feature.getMemberRuntimeLogTail({
teamName: vTeam.value!,
memberName: vMember.value!,
options: vOptions.value!,
});
return { success: true, data: normalizeMemberRuntimeLogTailResponse(response) };
} catch (error) {
logger.error('Failed to load member runtime log tail', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to load member runtime log tail',
};
}
}
);
}
export function removeMemberLogStreamIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS);
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL);
ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING);
}

View file

@ -0,0 +1,176 @@
/* eslint-disable security/detect-non-literal-fs-filename -- Runtime log paths are derived from validated team/member names under the configured teams base path. */
import { promises as fs } from 'fs';
import path from 'path';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import type { MemberRuntimeLogKind, MemberRuntimeLogTailResponse } from '../../contracts';
const DEFAULT_RUNTIME_LOG_TAIL_BYTES = 128 * 1024;
const MAX_RUNTIME_LOG_TAIL_BYTES = 512 * 1024;
const MIN_RUNTIME_LOG_TAIL_BYTES = 1024;
const RUNTIME_LOG_FILES: Record<MemberRuntimeLogKind, string> = {
stdout: 'stdout.log',
stderr: 'stderr.log',
events: 'runtime.jsonl',
};
const WINDOWS_RESERVED_BASENAMES = new Set([
'con',
'prn',
'aux',
'nul',
'com1',
'com2',
'com3',
'com4',
'com5',
'com6',
'com7',
'com8',
'com9',
'lpt1',
'lpt2',
'lpt3',
'lpt4',
'lpt5',
'lpt6',
'lpt7',
'lpt8',
'lpt9',
]);
export interface GetMemberRuntimeLogTailInput {
teamName: string;
memberName: string;
kind: MemberRuntimeLogKind;
maxBytes?: number;
}
export interface MemberRuntimeLogTailReaderOptions {
teamsBasePath?: string;
}
function sanitizeRuntimeLogSegment(value: string): string {
const sanitized = value.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
const normalized = sanitized
.trim()
.replace(/[. ]+$/g, '')
.toLowerCase();
const stem = normalized.split('.')[0] ?? normalized;
return WINDOWS_RESERVED_BASENAMES.has(stem) ? `_${sanitized}` : sanitized;
}
function clampMaxBytes(maxBytes: number | undefined): number {
if (!Number.isFinite(maxBytes ?? NaN)) return DEFAULT_RUNTIME_LOG_TAIL_BYTES;
return Math.max(
MIN_RUNTIME_LOG_TAIL_BYTES,
Math.min(MAX_RUNTIME_LOG_TAIL_BYTES, Math.floor(maxBytes as number))
);
}
function isPathInside(parentPath: string, childPath: string): boolean {
const relative = path.relative(parentPath, childPath);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function redactRuntimeLogSecrets(content: string): string {
let redacted = content;
redacted = redacted.replace(/\b(Authorization\s*:\s*Bearer)\s+([^\s"',;]+)/gi, '$1 [redacted]');
redacted = redacted.replace(/\b(Bearer)\s+([A-Za-z0-9._~+/=-]{20,})/gi, '$1 [redacted]');
redacted = redacted.replace(
/\b((?:OPENAI|ANTHROPIC|CODEX|GEMINI|GOOGLE|OPENROUTER|CLAUDE)[A-Z0-9_]*_(?:API_)?KEY)\s*=\s*("[^"]+"|'[^']+'|[^\s"',;]+)/gi,
'$1=[redacted]'
);
redacted = redacted.replace(
/(--(?:api-key|token|auth-token|authorization|secret|password)(?:=|\s+))("[^"]+"|'[^']+'|[^\s"',;]+)/gi,
'$1[redacted]'
);
redacted = redacted.replace(
/\b(sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g,
'[redacted]'
);
return redacted;
}
export class MemberRuntimeLogTailReader {
private readonly teamsBasePath: string;
constructor(options: MemberRuntimeLogTailReaderOptions = {}) {
this.teamsBasePath = options.teamsBasePath ?? getTeamsBasePath();
}
async getTail(input: GetMemberRuntimeLogTailInput): Promise<MemberRuntimeLogTailResponse> {
const maxBytes = clampMaxBytes(input.maxBytes);
const runtimeDir = path.resolve(
this.teamsBasePath,
sanitizeRuntimeLogSegment(input.teamName),
'runtime'
);
const filePath = path.resolve(
runtimeDir,
`${sanitizeRuntimeLogSegment(input.memberName)}.${RUNTIME_LOG_FILES[input.kind]}`
);
if (!isPathInside(runtimeDir, filePath)) {
throw new Error('Invalid member runtime log path');
}
let stat;
try {
stat = await fs.stat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
kind: input.kind,
content: '',
truncated: false,
bytesRead: 0,
missing: true,
};
}
throw error;
}
if (!stat.isFile()) {
return {
kind: input.kind,
content: '',
truncated: false,
bytesRead: 0,
fileSizeBytes: stat.size,
updatedAt: stat.mtime.toISOString(),
missing: true,
};
}
const bytesToRead = Math.min(stat.size, maxBytes);
const start = Math.max(0, stat.size - bytesToRead);
const buffer = Buffer.alloc(bytesToRead);
let actualBytesRead = 0;
if (bytesToRead > 0) {
const handle = await fs.open(filePath, 'r');
try {
const result = await handle.read(buffer, 0, bytesToRead, start);
actualBytesRead = result.bytesRead;
} finally {
await handle.close();
}
}
const contentBuffer =
actualBytesRead === bytesToRead ? buffer : buffer.subarray(0, actualBytesRead);
return {
kind: input.kind,
content: redactRuntimeLogSecrets(contentBuffer.toString('utf8')),
truncated: stat.size > bytesToRead,
bytesRead: actualBytesRead,
fileSizeBytes: stat.size,
updatedAt: stat.mtime.toISOString(),
missing: false,
};
}
}

View file

@ -0,0 +1,104 @@
/* eslint-disable security/detect-non-literal-fs-filename -- Tests write isolated temp runtime log fixtures. */
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { MemberRuntimeLogTailReader } from '../MemberRuntimeLogTailReader';
const tempDirs: string[] = [];
async function createTempTeamsBase(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), 'member-runtime-log-tail-'));
tempDirs.push(dir);
return dir;
}
async function writeRuntimeLog(
teamsBasePath: string,
teamName: string,
memberName: string,
suffix: string,
content: string
): Promise<void> {
const runtimeDir = path.join(teamsBasePath, teamName, 'runtime');
await mkdir(runtimeDir, { recursive: true });
await writeFile(path.join(runtimeDir, `${memberName}.${suffix}`), content, 'utf8');
}
describe('MemberRuntimeLogTailReader', () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it('reads only the bounded tail of large process logs', async () => {
const teamsBasePath = await createTempTeamsBase();
const reader = new MemberRuntimeLogTailReader({ teamsBasePath });
await writeRuntimeLog(
teamsBasePath,
'alpha-team',
'alice',
'stdout.log',
`${'x'.repeat(4096)}\nvisible tail`
);
const result = await reader.getTail({
teamName: 'alpha-team',
memberName: 'alice',
kind: 'stdout',
maxBytes: 1024,
});
expect(result.missing).toBe(false);
expect(result.truncated).toBe(true);
expect(result.bytesRead).toBe(1024);
expect(result.content).toContain('visible tail');
});
it('returns missing without throwing when the runtime log file does not exist', async () => {
const teamsBasePath = await createTempTeamsBase();
const reader = new MemberRuntimeLogTailReader({ teamsBasePath });
await expect(
reader.getTail({
teamName: 'alpha-team',
memberName: 'alice',
kind: 'stderr',
})
).resolves.toMatchObject({
kind: 'stderr',
missing: true,
content: '',
bytesRead: 0,
});
});
it('redacts obvious secrets before returning process log content', async () => {
const teamsBasePath = await createTempTeamsBase();
const reader = new MemberRuntimeLogTailReader({ teamsBasePath });
await writeRuntimeLog(
teamsBasePath,
'alpha-team',
'alice',
'stderr.log',
[
'Authorization: Bearer secret-token-value-1234567890',
'OPENAI_API_KEY=sk-secret-key-value-1234567890',
'--api-key sk-ant-secret-value-1234567890',
].join('\n')
);
const result = await reader.getTail({
teamName: 'alpha-team',
memberName: 'alice',
kind: 'stderr',
});
expect(result.content).toContain('Authorization: Bearer [redacted]');
expect(result.content).toContain('OPENAI_API_KEY=[redacted]');
expect(result.content).not.toContain('secret-token-value');
expect(result.content).not.toContain('sk-secret-key-value');
expect(result.content).not.toContain('sk-ant-secret-value');
});
});

View file

@ -5,7 +5,9 @@ import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import {
createEmptyMemberLogPreviewResponse,
createEmptyMemberLogStreamResponse,
createEmptyMemberRuntimeLogTailResponse,
} from '../../contracts';
import { MemberRuntimeLogTailReader } from '../application/MemberRuntimeLogTailReader';
import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase';
@ -17,7 +19,12 @@ import { OpenCodeMemberRuntimePreviewSource } from '../adapters/output/sources/O
import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
import { isMemberLogStreamReadEnabled } from '../featureGates';
import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../contracts';
import type {
MemberLogPreviewResponse,
MemberLogStreamResponse,
MemberRuntimeLogTailOptions,
MemberRuntimeLogTailResponse,
} from '../../contracts';
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort';
import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
@ -29,6 +36,11 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin
export interface MemberLogStreamFeatureFacade {
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse>;
getMemberRuntimeLogTail(input: {
teamName: string;
memberName: string;
options: MemberRuntimeLogTailOptions;
}): Promise<MemberRuntimeLogTailResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}
@ -49,11 +61,13 @@ export function createMemberLogStreamFeature(deps: {
logSourceTracker: TeamLogSourceTracker;
runtimeBridge: ClaudeMultimodelBridgeService;
configReader?: TeamConfigReader;
runtimeLogTailReader?: MemberRuntimeLogTailReader;
logger: LoggerPort;
}): MemberLogStreamFeatureFacade {
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
const strictParser = new BoardTaskExactLogStrictParser();
const configReader = deps.configReader ?? new TeamConfigReader();
const runtimeLogTailReader = deps.runtimeLogTailReader ?? new MemberRuntimeLogTailReader();
const sources = [
new ClaudeMemberTranscriptStreamSource(
deps.logsFinder,
@ -96,6 +110,17 @@ export function createMemberLogStreamFeature(deps: {
}
return getPreviewsUseCase.execute(input);
},
getMemberRuntimeLogTail: async (input) => {
if (!isMemberLogStreamReadEnabled()) {
return createEmptyMemberRuntimeLogTailResponse(input.options.kind);
}
return runtimeLogTailReader.getTail({
teamName: input.teamName,
memberName: input.memberName,
kind: input.options.kind,
maxBytes: input.options.maxBytes,
});
},
setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled),
};
}

View file

@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_GET_PREVIEWS,
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
MEMBER_LOG_STREAM_SET_TRACKING,
} from '../../contracts';
import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge';
@ -122,4 +123,46 @@ describe('createMemberLogStreamBridge', () => {
}
);
});
it('forwards process runtime log tail IPC requests and normalizes response payloads', async () => {
mocks.ipcRenderer.invoke.mockResolvedValueOnce({
success: true,
data: {
kind: 'stderr',
content: 'OpenCode API error',
truncated: true,
bytesRead: 131072,
fileSizeBytes: 262144,
updatedAt: '2026-04-02T00:00:00.000Z',
missing: false,
},
});
const bridge = createMemberLogStreamBridge();
const response = await bridge.getMemberRuntimeLogTail('alpha-team', 'alice', {
kind: 'stderr',
maxBytes: 131072,
forceRefresh: true,
});
expect(response).toEqual({
kind: 'stderr',
content: 'OpenCode API error',
truncated: true,
bytesRead: 131072,
fileSizeBytes: 262144,
updatedAt: '2026-04-02T00:00:00.000Z',
missing: false,
});
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith(
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
'alpha-team',
'alice',
{
kind: 'stderr',
maxBytes: 131072,
forceRefresh: true,
}
);
});
});

View file

@ -3,9 +3,11 @@ import { ipcRenderer } from 'electron';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_GET_PREVIEWS,
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
MEMBER_LOG_STREAM_SET_TRACKING,
normalizeMemberLogPreviewResponse,
normalizeMemberLogStreamResponse,
normalizeMemberRuntimeLogTailResponse,
} from '../contracts';
import type {
@ -14,6 +16,8 @@ import type {
MemberLogStreamApi,
MemberLogStreamRequestOptions,
MemberLogStreamResponse,
MemberRuntimeLogTailOptions,
MemberRuntimeLogTailResponse,
} from '../contracts';
import type { IpcResult } from '@shared/types';
@ -53,6 +57,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi {
options
)
),
getMemberRuntimeLogTail: async (
teamName: string,
memberName: string,
options: MemberRuntimeLogTailOptions
): Promise<MemberRuntimeLogTailResponse> =>
normalizeMemberRuntimeLogTailResponse(
await invokeIpcWithResult<MemberRuntimeLogTailResponse>(
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
teamName,
memberName,
options
)
),
setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise<void> =>
invokeIpcWithResult<void>(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled),
};

View file

@ -1,10 +1,11 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { useMemberLogStream } from '../hooks/useMemberLogStream';
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
import { MemberRuntimeProcessLogsPanel } from '../ui/MemberRuntimeProcessLogsPanel';
import type { MemberLogStreamSegment } from '../../contracts';
import type { ResolvedTeamMember } from '@shared/types';
@ -41,6 +42,7 @@ export function MemberLogStreamSection({
enabled = true,
onInitialLoadErrorChange,
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
const hasInitialLoadError = Boolean(error && !stream && !loading);
@ -57,22 +59,57 @@ export function MemberLogStreamSection({
}, [hasInitialLoadError, onInitialLoadErrorChange]);
return (
<ExecutionLogStreamView
title="Logs"
description={describeMemberStream()}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText="Loading member log stream..."
emptyTitle="No log stream entries were found for this member yet."
emptyDescription="Member-scoped transcript or runtime logs will appear here when available."
selectionResetKey={`${teamName}:${member.name}`}
boundedHistoryNote={boundedHistoryNote}
forceSegmentHeaders
buildSegmentRenderKey={buildMemberSegmentRenderKey}
getSegmentMetaLabel={getSegmentMetaLabel}
/>
<div className="space-y-4">
<div className="inline-flex rounded-xl bg-[var(--color-surface-subtle)] p-1">
<button
type="button"
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
selectedLogView === 'execution'
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedLogView('execution')}
>
Execution
</button>
<button
type="button"
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
selectedLogView === 'process'
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedLogView('process')}
>
Process
</button>
</div>
{selectedLogView === 'execution' ? (
<ExecutionLogStreamView
title="Logs"
description={describeMemberStream()}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText="Loading member log stream..."
emptyTitle="No log stream entries were found for this member yet."
emptyDescription="Member-scoped transcript or runtime logs will appear here when available."
selectionResetKey={`${teamName}:${member.name}`}
boundedHistoryNote={boundedHistoryNote}
forceSegmentHeaders
buildSegmentRenderKey={buildMemberSegmentRenderKey}
getSegmentMetaLabel={getSegmentMetaLabel}
/>
) : (
<MemberRuntimeProcessLogsPanel
teamName={teamName}
memberName={member.name}
enabled={enabled && selectedLogView === 'process'}
/>
)}
</div>
);
}

View file

@ -0,0 +1,278 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Check, Clipboard, Loader2, RefreshCw } from 'lucide-react';
import {
createEmptyMemberRuntimeLogTailResponse,
normalizeMemberRuntimeLogTailResponse,
type MemberRuntimeLogKind,
type MemberRuntimeLogTailResponse,
} from '../../contracts';
const PROCESS_LOG_KINDS: MemberRuntimeLogKind[] = ['stdout', 'stderr', 'events'];
const PROCESS_LOG_AUTO_REFRESH_MS = 4000;
const PROCESS_LOG_TAIL_BYTES = 128 * 1024;
function formatBytes(bytes: number | undefined): string {
if (!Number.isFinite(bytes ?? NaN)) return '--';
const safeBytes = Math.max(0, bytes ?? 0);
if (safeBytes < 1024) return `${safeBytes} B`;
const kb = safeBytes / 1024;
if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;
}
function buildStatusText(log: MemberRuntimeLogTailResponse | null): string | null {
if (!log) return null;
if (log.missing) return 'No process log file captured for this member yet.';
if (!log.content) return 'Process log file is empty.';
if (log.truncated) return `Showing last ${formatBytes(log.bytesRead)}.`;
return `Showing ${formatBytes(log.bytesRead)}.`;
}
function ProcessLogKindTabs({
selected,
onSelect,
}: {
selected: MemberRuntimeLogKind;
onSelect: (kind: MemberRuntimeLogKind) => void;
}): React.JSX.Element {
return (
<div className="flex rounded-lg bg-[var(--color-surface-subtle)] p-1">
{PROCESS_LOG_KINDS.map((kind) => (
<button
key={kind}
type="button"
className={`rounded-md px-3 py-1.5 text-xs font-medium capitalize transition-colors ${
selected === kind
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => onSelect(kind)}
>
{kind}
</button>
))}
</div>
);
}
function ProcessLogVirtualList({
content,
wrapLines,
}: {
content: string;
wrapLines: boolean;
}): React.JSX.Element {
const parentRef = useRef<HTMLDivElement | null>(null);
const lines = useMemo(() => content.split(/\r?\n/), [content]);
const rowVirtualizer = useVirtualizer({
count: lines.length,
getScrollElement: () => parentRef.current,
estimateSize: () => (wrapLines ? 36 : 20),
overscan: 20,
});
return (
<div
ref={parentRef}
className="h-[360px] overflow-auto rounded-xl border border-[var(--color-border)] bg-black/40 font-mono text-xs text-[var(--color-text)]"
>
<div
className={wrapLines ? 'min-w-0' : 'min-w-max'}
style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute left-0 top-0 grid w-full grid-cols-[4rem_minmax(0,1fr)] gap-3 px-3 py-0.5 leading-5"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<span className="select-none text-right text-[var(--color-text-subtle)]">
{virtualRow.index + 1}
</span>
<span className={wrapLines ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'}>
{lines[virtualRow.index] || ' '}
</span>
</div>
))}
</div>
</div>
);
}
export function MemberRuntimeProcessLogsPanel({
teamName,
memberName,
enabled,
}: {
teamName: string;
memberName: string;
enabled: boolean;
}): React.JSX.Element {
const [kind, setKind] = useState<MemberRuntimeLogKind>('stdout');
const [log, setLog] = useState<MemberRuntimeLogTailResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
const [copied, setCopied] = useState(false);
const requestSeqRef = useRef(0);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadLog = useCallback(
async (options?: { background?: boolean; forceRefresh?: boolean }) => {
if (!enabled) return;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (!options?.background) {
setLoading(true);
setError(null);
}
try {
const response = normalizeMemberRuntimeLogTailResponse(
await api.memberLogStream.getMemberRuntimeLogTail(teamName, memberName, {
kind,
maxBytes: PROCESS_LOG_TAIL_BYTES,
...(options?.forceRefresh ? { forceRefresh: true } : {}),
})
);
if (requestSeqRef.current !== requestSeq) return;
setLog(response);
setError(null);
} catch (loadError) {
if (requestSeqRef.current !== requestSeq) return;
if (!options?.background) {
setLog(createEmptyMemberRuntimeLogTailResponse(kind));
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load process logs');
} finally {
if (requestSeqRef.current === requestSeq) {
setLoading(false);
}
}
},
[enabled, kind, memberName, teamName]
);
useEffect(() => {
requestSeqRef.current += 1;
setLog(null);
setError(null);
if (enabled) {
void loadLog({ forceRefresh: true });
}
}, [enabled, kind, loadLog]);
useEffect(() => {
if (!enabled || !autoRefresh) return undefined;
const interval = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
void loadLog({ background: true, forceRefresh: true });
}, PROCESS_LOG_AUTO_REFRESH_MS);
return () => clearInterval(interval);
}, [autoRefresh, enabled, loadLog]);
useEffect(() => {
return () => {
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
};
}, []);
const copyCurrentLog = useCallback(async () => {
const content = log?.content ?? '';
if (!content) return;
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 1600);
} catch (copyError) {
setError(copyError instanceof Error ? copyError.message : 'Failed to copy process logs');
}
}, [log?.content]);
const statusText = buildStatusText(log);
const hasContent = Boolean(log?.content);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ProcessLogKindTabs selected={kind} onSelect={setKind} />
<span className="rounded-full bg-[var(--color-surface-subtle)] px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--color-text-subtle)]">
{kind}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--color-border)] px-2.5 py-1.5 text-xs text-[var(--color-text-muted)]">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-[var(--color-accent)]"
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
Auto-refresh
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-[var(--color-border)] px-2.5 py-1.5 text-xs text-[var(--color-text-muted)]">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-[var(--color-accent)]"
checked={wrapLines}
onChange={(event) => setWrapLines(event.target.checked)}
/>
Wrap lines
</label>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-2.5 py-1.5 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void loadLog({ forceRefresh: true })}
disabled={loading}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
Refresh
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-2.5 py-1.5 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void copyCurrentLog()}
disabled={!hasContent}
>
{copied ? <Check size={13} /> : <Clipboard size={13} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{error ? (
<div className="rounded-xl border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
) : null}
{statusText ? (
<div className="text-xs text-[var(--color-text-muted)]">{statusText}</div>
) : null}
{loading && !log ? (
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] px-3 py-10 text-sm text-[var(--color-text-muted)]">
<Loader2 size={16} className="animate-spin" />
Loading process log tail...
</div>
) : hasContent ? (
<ProcessLogVirtualList content={log?.content ?? ''} wrapLines={wrapLines} />
) : (
<div className="rounded-xl border border-[var(--color-border)] px-3 py-10 text-sm text-[var(--color-text-muted)]">
{statusText ?? 'No process log file captured for this member yet.'}
</div>
)}
</div>
);
}

View file

@ -523,6 +523,9 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
});
}
await services.teamProvisioningService?.repairStaleTaskActivityIntervalsBeforeSnapshot?.(
teamName
);
return reply.send(await getTeamDataService(services).getTeamData(teamName));
} catch (error) {
if (shouldLogError(error)) {

View file

@ -1044,6 +1044,8 @@ async function handleGetData(
return { success: false, error: 'TEAM_DRAFT' };
}
await getTeamProvisioningService().repairStaleTaskActivityIntervalsBeforeSnapshot?.(tn);
if (workerAvailable) {
try {
data =

View file

@ -179,7 +179,12 @@ export class TaskChangeComputer {
if (!Number.isFinite(startMs)) continue;
const endMsRaw =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
const endMs =
interval.completedAt === undefined
? null
: Number.isFinite(endMsRaw)
? Math.max(endMsRaw, startMs)
: startMs;
normalized.push({
startMs,
endMs,
@ -192,7 +197,13 @@ export class TaskChangeComputer {
const startTimestamp = normalized[0]?.startedAt ?? '';
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
(acc, item) => {
if (item.endMs == null || typeof item.completedAt !== 'string') return acc;
if (
item.endMs == null ||
typeof item.completedAt !== 'string' ||
!Number.isFinite(Date.parse(item.completedAt))
) {
return acc;
}
if (!acc || item.endMs > acc.endMs) {
return { endMs: item.endMs, endTimestamp: item.completedAt };
}

View file

@ -7,7 +7,35 @@ import { atomicWriteAsync } from './atomicWrite';
import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock';
import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types';
import type { InboxMessage, SendMessageRequest, SendMessageResult, TaskRef } from '@shared/types';
export interface MergeRuntimeDeliveryTaskRefsRequest {
inboxName: string;
messageId: string;
relayOfMessageId: string;
from: string;
taskRefs: TaskRef[];
}
export interface MergeRuntimeDeliveryTaskRefsResult {
found: boolean;
updated: boolean;
message?: InboxMessage & { messageId: string };
}
export interface CorrelateRuntimeDeliveryReplyRequest {
inboxName: string;
messageId: string;
relayOfMessageId: string;
from: string;
taskRefs?: TaskRef[];
}
export interface CorrelateRuntimeDeliveryReplyResult {
found: boolean;
updated: boolean;
message?: InboxMessage & { messageId: string };
}
export class TeamInboxWriter {
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
@ -54,10 +82,34 @@ export class TeamInboxWriter {
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
const duplicate = this.findRuntimeDeliveryDuplicate(list, payload);
if (duplicate) {
const duplicateIndex = this.findRuntimeDeliveryDuplicateIndex(list, payload);
if (duplicateIndex >= 0) {
const duplicate = list[duplicateIndex];
const merged = this.mergeTaskRefs(duplicate.taskRefs, payload.taskRefs);
resultMessageId = duplicate.messageId ?? messageId;
resultDeduplicated = true;
if (merged.changed) {
list[duplicateIndex] = {
...duplicate,
taskRefs: merged.taskRefs,
};
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
const writtenDuplicateIndex = this.findRuntimeDeliveryDuplicateIndex(
written,
payload
);
const writtenDuplicate =
writtenDuplicateIndex >= 0 ? written[writtenDuplicateIndex] : null;
if (
writtenDuplicate &&
this.taskRefsIncludeAll(writtenDuplicate.taskRefs, payload.taskRefs ?? [])
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
continue;
}
return;
}
list.push(payload);
@ -79,16 +131,188 @@ export class TeamInboxWriter {
};
}
private findRuntimeDeliveryDuplicate(
async mergeRuntimeDeliveryTaskRefs(
teamName: string,
request: MergeRuntimeDeliveryTaskRefsRequest
): Promise<MergeRuntimeDeliveryTaskRefsResult> {
const inboxName = request.inboxName.trim();
const messageId = request.messageId.trim();
const relayOfMessageId = request.relayOfMessageId.trim();
const taskRefs = this.normalizeTaskRefs(request.taskRefs);
if (!inboxName || !messageId || !relayOfMessageId || taskRefs.length === 0) {
return { found: false, updated: false };
}
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`);
const expectedFrom = this.normalizeComparableParticipant(request.from);
if (!expectedFrom) {
return { found: false, updated: false };
}
let result: MergeRuntimeDeliveryTaskRefsResult = { found: false, updated: false };
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
const index = list.findIndex((message) => {
const rowMessageId =
typeof message.messageId === 'string' ? message.messageId.trim() : '';
const rowRelayOf =
typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : '';
const rowSource = message.source;
return (
rowMessageId === messageId &&
rowRelayOf === relayOfMessageId &&
this.normalizeComparableParticipant(message.from) === expectedFrom &&
(rowSource === undefined || rowSource === 'runtime_delivery')
);
});
if (index < 0) {
result = { found: false, updated: false };
return;
}
const existing = list[index];
const merged = this.mergeTaskRefs(existing.taskRefs, taskRefs);
if (!merged.changed) {
result = {
found: true,
updated: false,
message: { ...existing, messageId },
};
return;
}
list[index] = { ...existing, taskRefs: merged.taskRefs };
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
const verified = written.find((message) => {
const rowMessageId =
typeof message.messageId === 'string' ? message.messageId.trim() : '';
const rowRelayOf =
typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : '';
const rowSource = message.source;
return (
rowMessageId === messageId &&
rowRelayOf === relayOfMessageId &&
this.normalizeComparableParticipant(message.from) === expectedFrom &&
(rowSource === undefined || rowSource === 'runtime_delivery') &&
this.taskRefsIncludeAll(message.taskRefs, taskRefs)
);
});
if (verified) {
result = {
found: true,
updated: true,
message: { ...verified, messageId },
};
return;
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
}
throw new Error('Failed to verify inbox taskRefs merge');
});
});
return result;
}
async correlateRuntimeDeliveryReply(
teamName: string,
request: CorrelateRuntimeDeliveryReplyRequest
): Promise<CorrelateRuntimeDeliveryReplyResult> {
const inboxName = request.inboxName.trim();
const messageId = request.messageId.trim();
const relayOfMessageId = request.relayOfMessageId.trim();
const expectedFrom = this.normalizeComparableParticipant(request.from);
if (!inboxName || !messageId || !relayOfMessageId || !expectedFrom) {
return { found: false, updated: false };
}
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`);
const taskRefs = this.normalizeTaskRefs(request.taskRefs);
let result: CorrelateRuntimeDeliveryReplyResult = { found: false, updated: false };
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
const index = list.findIndex((message) => {
const rowMessageId =
typeof message.messageId === 'string' ? message.messageId.trim() : '';
const rowSource = message.source;
return (
rowMessageId === messageId &&
this.normalizeComparableParticipant(message.from) === expectedFrom &&
(rowSource === undefined || rowSource === 'runtime_delivery')
);
});
if (index < 0) {
result = { found: false, updated: false };
return;
}
const existing = list[index];
const merged = this.mergeTaskRefs(existing.taskRefs, taskRefs);
const currentRelayOf =
typeof existing.relayOfMessageId === 'string' ? existing.relayOfMessageId.trim() : '';
if (currentRelayOf === relayOfMessageId && !merged.changed) {
result = {
found: true,
updated: false,
message: { ...existing, messageId },
};
return;
}
const nextMessage: InboxMessage = {
...existing,
relayOfMessageId,
...(merged.taskRefs ? { taskRefs: merged.taskRefs } : {}),
};
list[index] = nextMessage;
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
const verified = written.find((message) => {
const rowMessageId =
typeof message.messageId === 'string' ? message.messageId.trim() : '';
const rowRelayOf =
typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : '';
const rowSource = message.source;
return (
rowMessageId === messageId &&
rowRelayOf === relayOfMessageId &&
this.normalizeComparableParticipant(message.from) === expectedFrom &&
(rowSource === undefined || rowSource === 'runtime_delivery') &&
this.taskRefsIncludeAll(message.taskRefs, taskRefs)
);
});
if (verified) {
result = {
found: true,
updated: true,
message: { ...verified, messageId },
};
return;
}
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
}
throw new Error('Failed to verify inbox runtime delivery correlation update');
});
});
return result;
}
private findRuntimeDeliveryDuplicateIndex(
messages: readonly InboxMessage[],
payload: InboxMessage
): InboxMessage | null {
): number {
if (
payload.source !== 'runtime_delivery' ||
typeof payload.relayOfMessageId !== 'string' ||
payload.relayOfMessageId.trim().length === 0
) {
return null;
return -1;
}
const relayOfMessageId = payload.relayOfMessageId.trim();
@ -96,21 +320,83 @@ export class TeamInboxWriter {
const to = this.normalizeComparableParticipant(payload.to);
const text = this.normalizeComparableText(payload.text);
if (!from || !to || !text) {
return null;
return -1;
}
return (
messages.find(
(candidate) =>
candidate.source === 'runtime_delivery' &&
(candidate.relayOfMessageId ?? '').trim() === relayOfMessageId &&
this.normalizeComparableParticipant(candidate.from) === from &&
this.normalizeComparableParticipant(candidate.to) === to &&
this.normalizeComparableText(candidate.text) === text
) ?? null
return messages.findIndex(
(candidate) =>
candidate.source === 'runtime_delivery' &&
(candidate.relayOfMessageId ?? '').trim() === relayOfMessageId &&
this.normalizeComparableParticipant(candidate.from) === from &&
this.normalizeComparableParticipant(candidate.to) === to &&
this.normalizeComparableText(candidate.text) === text
);
}
private mergeTaskRefs(
existing: readonly TaskRef[] | undefined,
incoming: readonly TaskRef[] | undefined
): { changed: boolean; taskRefs?: TaskRef[] } {
const normalizedExisting = this.normalizeTaskRefs(existing);
const normalizedIncoming = this.normalizeTaskRefs(incoming);
if (normalizedIncoming.length === 0) {
return {
changed: false,
taskRefs: normalizedExisting.length ? normalizedExisting : undefined,
};
}
const seen = new Set(normalizedExisting.map((taskRef) => this.taskRefKey(taskRef)));
const merged = [...normalizedExisting];
let changed = false;
for (const taskRef of normalizedIncoming) {
const key = this.taskRefKey(taskRef);
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(taskRef);
changed = true;
}
return { changed, taskRefs: merged.length ? merged : undefined };
}
private taskRefsIncludeAll(
actual: readonly TaskRef[] | undefined,
expected: readonly TaskRef[]
): boolean {
const actualKeys = new Set(
this.normalizeTaskRefs(actual).map((taskRef) => this.taskRefKey(taskRef))
);
return this.normalizeTaskRefs(expected).every((taskRef) =>
actualKeys.has(this.taskRefKey(taskRef))
);
}
private normalizeTaskRefs(taskRefs: readonly TaskRef[] | undefined): TaskRef[] {
if (!Array.isArray(taskRefs)) {
return [];
}
const normalized: TaskRef[] = [];
for (const rawTaskRef of taskRefs as readonly unknown[]) {
if (!rawTaskRef || typeof rawTaskRef !== 'object') {
continue;
}
const taskRef = rawTaskRef as Record<string, unknown>;
const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : '';
const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : '';
const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : '';
if (teamName && taskId && displayId) {
normalized.push({ teamName, taskId, displayId });
}
}
return normalized;
}
private taskRefKey(taskRef: TaskRef): string {
return `${taskRef.teamName.trim()}\u0000${taskRef.taskId.trim()}\u0000${taskRef.displayId.trim()}`;
}
private normalizeComparableParticipant(value: unknown): string {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}

View file

@ -499,7 +499,12 @@ export class TeamMemberLogsFinder {
const startMs = Date.parse(i.startedAt);
const endMsRaw =
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
const endMs =
i.completedAt === undefined
? null
: Number.isFinite(endMsRaw)
? Math.max(endMsRaw, startMs)
: startMs;
return Number.isFinite(startMs) ? { startMs, endMs } : null;
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
@ -516,12 +521,12 @@ export class TeamMemberLogsFinder {
: [];
const filteredOwnerLogs = ownerLogs.filter((log) => {
if (log.isOngoing) return true;
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) return false;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const endMs = startMs + durationMs;
const rawEndMs = startMs + durationMs;
const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs;
if (effectiveIntervals.length > 0) {
return this.logOverlapsIntervals(
@ -533,6 +538,7 @@ export class TeamMemberLogsFinder {
);
}
if (log.isOngoing) return true;
return startMs >= now - fallbackRecentMs;
});
const seen = new Set<string>();
@ -740,7 +746,12 @@ export class TeamMemberLogsFinder {
const startMs = Date.parse(i.startedAt);
const endMsRaw =
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
const endMs =
i.completedAt === undefined
? null
: Number.isFinite(endMsRaw)
? Math.max(endMsRaw, startMs)
: startMs;
return Number.isFinite(startMs) ? { startMs, endMs } : null;
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
@ -757,28 +768,27 @@ export class TeamMemberLogsFinder {
for (const log of ownerLogs) {
if (!log.filePath) continue;
if (!log.isOngoing) {
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) continue;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const endMs = startMs + durationMs;
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) continue;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const rawEndMs = startMs + durationMs;
const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs;
if (effectiveIntervals.length > 0) {
if (
!this.logOverlapsIntervals(
startMs,
endMs,
effectiveIntervals,
now,
TASK_LOG_INTERVAL_GRACE_MS
)
) {
continue;
}
} else if (startMs < now - fallbackRecentMs) {
if (effectiveIntervals.length > 0) {
if (
!this.logOverlapsIntervals(
startMs,
endMs,
effectiveIntervals,
now,
TASK_LOG_INTERVAL_GRACE_MS
)
) {
continue;
}
} else if (!log.isOngoing && startMs < now - fallbackRecentMs) {
continue;
}
pushRef(

View file

@ -98,10 +98,14 @@ const PROTOCOL_PROOF_MISSING_TOKENS = [
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
];
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ import type {
interface ActivityIntervalResult {
changedTasks: number;
failed?: boolean;
}
type MutableTeamTask = TeamTask & {
@ -38,6 +39,14 @@ function toIso(ms: number): string {
return new Date(ms).toISOString();
}
function isClosedInterval(interval: { completedAt?: unknown } | null | undefined): boolean {
return typeof interval?.completedAt === 'string' && parseIsoMs(interval.completedAt) > 0;
}
function hasValidStartedAt(interval: { startedAt?: unknown } | null | undefined): boolean {
return typeof interval?.startedAt === 'string' && parseIsoMs(interval.startedAt) > 0;
}
function ensureCloseIso(startedAt: string, at: string): string {
const startedAtMs = parseIsoMs(startedAt);
const atMs = parseIsoMs(at);
@ -46,6 +55,32 @@ function ensureCloseIso(startedAt: string, at: string): string {
return toIso(atMs);
}
function resumeStartIso(activeStartedAt: string | null | undefined, at: string): string {
const activeStartedAtMs = parseIsoMs(activeStartedAt ?? undefined);
const atMs = parseIsoMs(at);
if (activeStartedAtMs > 0 && activeStartedAtMs > atMs) {
return toIso(activeStartedAtMs);
}
return atMs > 0 ? toIso(atMs) : toIso(Date.now());
}
function getStartedAtString(interval: { startedAt?: unknown } | null | undefined): string {
return typeof interval?.startedAt === 'string' ? interval.startedAt : '';
}
function hasUsableCompletedAt(interval: { completedAt?: unknown } | null | undefined): boolean {
return interval?.completedAt === undefined || isClosedInterval(interval);
}
function pauseCloseIso(
interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined,
at: string
): string {
const startedAt = getStartedAtString(interval);
const closeAt = interval?.completedAt === undefined ? at : startedAt || at;
return ensureCloseIso(startedAt, closeAt);
}
function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string {
const startedAtMs = parseIsoMs(startedAt);
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
@ -62,10 +97,23 @@ function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemb
return toIso(boundedCloseMs);
}
function crashRepairIntervalCloseIso(
interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined,
member?: PersistedTeamLaunchMemberState
): string {
const startedAt = getStartedAtString(interval);
if (interval?.completedAt === undefined) {
return crashRepairCloseIso(startedAt, member);
}
return ensureCloseIso(startedAt, startedAt || crashRepairCloseIso(startedAt, member));
}
function hasOpenWorkInterval(task: MutableTeamTask): boolean {
return (
Array.isArray(task.workIntervals) &&
task.workIntervals.some((interval) => !interval.completedAt)
task.workIntervals.some(
(interval) => hasValidStartedAt(interval) && interval.completedAt === undefined
)
);
}
@ -74,7 +122,10 @@ function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean
return (
Array.isArray(task.reviewIntervals) &&
task.reviewIntervals.some(
(interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey
(interval) =>
hasValidStartedAt(interval) &&
interval.completedAt === undefined &&
normalizeMemberName(interval.reviewer) === reviewerKey
)
);
}
@ -85,9 +136,9 @@ function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: strin
let changed = false;
task.workIntervals = task.workIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (isClosedInterval(interval)) return interval;
changed = true;
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
return { ...interval, completedAt: pauseCloseIso(interval, at) };
});
return changed;
}
@ -98,20 +149,45 @@ function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?:
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (isClosedInterval(interval)) return interval;
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
changed = true;
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
return { ...interval, completedAt: pauseCloseIso(interval, at) };
});
return changed;
}
function getActiveReviewActor(task: MutableTeamTask): string | null {
function getActiveWorkStartedAt(task: MutableTeamTask): string | null {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'status_changed') {
if (event.to === 'in_progress') {
return parseIsoMs(event.timestamp) > 0 ? event.timestamp : null;
}
return null;
}
if (event.type === 'task_created') {
return event.status === 'in_progress' && parseIsoMs(event.timestamp) > 0
? event.timestamp
: null;
}
}
return null;
}
function getActiveReviewStart(
task: MutableTeamTask
): { reviewer: string; startedAt: string } | null {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null;
const reviewer =
typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : '';
return reviewer && parseIsoMs(event.timestamp) > 0
? { reviewer, startedAt: event.timestamp }
: null;
}
if (
event.type === 'review_approved' ||
@ -126,6 +202,110 @@ function getActiveReviewActor(task: MutableTeamTask): string | null {
return null;
}
function hasWorkIntervalForStart(task: MutableTeamTask, startedAt: string): boolean {
const startedAtMs = parseIsoMs(startedAt);
return (
startedAtMs > 0 &&
Array.isArray(task.workIntervals) &&
task.workIntervals.some((interval) => parseIsoMs(interval.startedAt) === startedAtMs)
);
}
function hasPersistedWorkIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean {
const startedAtMs = parseIsoMs(startedAt);
return (
startedAtMs > 0 &&
Array.isArray(task.workIntervals) &&
task.workIntervals.some(
(interval) =>
hasValidStartedAt(interval) &&
hasUsableCompletedAt(interval) &&
parseIsoMs(interval.startedAt) >= startedAtMs
)
);
}
function hasReviewIntervalForStart(
task: MutableTeamTask,
reviewer: string,
startedAt: string
): boolean {
const reviewerKey = normalizeMemberName(reviewer);
const startedAtMs = parseIsoMs(startedAt);
return (
reviewerKey.length > 0 &&
startedAtMs > 0 &&
Array.isArray(task.reviewIntervals) &&
task.reviewIntervals.some(
(interval) =>
normalizeMemberName(interval.reviewer) === reviewerKey &&
parseIsoMs(interval.startedAt) === startedAtMs
)
);
}
function hasPersistedReviewIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean {
const startedAtMs = parseIsoMs(startedAt);
return (
startedAtMs > 0 &&
Array.isArray(task.reviewIntervals) &&
task.reviewIntervals.some(
(interval) =>
normalizeMemberName(interval?.reviewer).length > 0 &&
hasValidStartedAt(interval) &&
hasUsableCompletedAt(interval) &&
parseIsoMs(interval.startedAt) >= startedAtMs
)
);
}
function materializePausedWorkInterval(task: MutableTeamTask, at: string, owner?: string): boolean {
if (task.status !== 'in_progress') return false;
if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false;
const startedAt = getActiveWorkStartedAt(task);
if (
!startedAt ||
hasPersistedWorkIntervalAtOrAfter(task, startedAt) ||
hasWorkIntervalForStart(task, startedAt)
) {
return false;
}
task.workIntervals = [
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
{ startedAt, completedAt: ensureCloseIso(startedAt, at) },
];
return true;
}
function materializePausedReviewInterval(
task: MutableTeamTask,
at: string,
reviewer?: string
): boolean {
if (task.status !== 'completed') return false;
const activeReview = getActiveReviewStart(task);
if (!activeReview) return false;
if (reviewer && normalizeMemberName(activeReview.reviewer) !== normalizeMemberName(reviewer)) {
return false;
}
if (
hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) ||
hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt)
) {
return false;
}
task.reviewIntervals = [
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
{
reviewer: activeReview.reviewer,
startedAt: activeReview.startedAt,
completedAt: ensureCloseIso(activeReview.startedAt, at),
},
];
return true;
}
function readTaskFile(filePath: string): MutableTeamTask | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
@ -155,7 +335,7 @@ export class TeamTaskActivityIntervalService {
error instanceof Error ? error.message : String(error)
}`
);
return { changedTasks: 0 };
return { changedTasks: 0, failed: true };
}
}
@ -167,8 +347,11 @@ export class TeamTaskActivityIntervalService {
let entries: string[];
try {
entries = fs.readdirSync(tasksDir);
} catch {
return { changedTasks: 0 };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { changedTasks: 0 };
}
throw error;
}
let changedTasks = 0;
@ -195,7 +378,9 @@ export class TeamTaskActivityIntervalService {
return this.mutateTeamTasks(teamName, (task) => {
const changedWork = closeOpenWorkIntervals(task, at);
const changedReview = closeOpenReviewIntervals(task, at);
return changedWork || changedReview;
const materializedWork = materializePausedWorkInterval(task, at);
const materializedReview = materializePausedReviewInterval(task, at);
return changedWork || changedReview || materializedWork || materializedReview;
});
}
@ -207,7 +392,9 @@ export class TeamTaskActivityIntervalService {
return this.mutateTeamTasks(teamName, (task) => {
const changedWork = closeOpenWorkIntervals(task, at, memberName);
const changedReview = closeOpenReviewIntervals(task, at, memberName);
return changedWork || changedReview;
const materializedWork = materializePausedWorkInterval(task, at, memberName);
const materializedReview = materializePausedReviewInterval(task, at, memberName);
return changedWork || changedReview || materializedWork || materializedReview;
});
}
@ -227,23 +414,27 @@ export class TeamTaskActivityIntervalService {
normalizeMemberName(task.owner) === memberKey &&
!hasOpenWorkInterval(task)
) {
const activeStartedAt = getActiveWorkStartedAt(task);
task.workIntervals = [
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
{ startedAt: at },
{ startedAt: resumeStartIso(activeStartedAt, at) },
];
changed = true;
}
const activeReviewer = getActiveReviewActor(task);
const activeReview = getActiveReviewStart(task);
if (
task.status === 'completed' &&
activeReviewer &&
normalizeMemberName(activeReviewer) === memberKey &&
!hasOpenReviewInterval(task, activeReviewer)
activeReview &&
normalizeMemberName(activeReview.reviewer) === memberKey &&
!hasOpenReviewInterval(task, activeReview.reviewer)
) {
task.reviewIntervals = [
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
{ reviewer: activeReviewer, startedAt: at },
{
reviewer: activeReview.reviewer,
startedAt: resumeStartIso(activeReview.startedAt, at),
},
];
changed = true;
}
@ -266,23 +457,57 @@ export class TeamTaskActivityIntervalService {
if (Array.isArray(task.workIntervals)) {
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
task.workIntervals = task.workIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (isClosedInterval(interval)) return interval;
changed = true;
return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) };
return { ...interval, completedAt: crashRepairIntervalCloseIso(interval, ownerMember) };
});
}
if (task.status === 'in_progress') {
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
const startedAt = getActiveWorkStartedAt(task);
if (
startedAt &&
!hasPersistedWorkIntervalAtOrAfter(task, startedAt) &&
!hasWorkIntervalForStart(task, startedAt)
) {
task.workIntervals = [
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
{ startedAt, completedAt: crashRepairCloseIso(startedAt, ownerMember) },
];
changed = true;
}
}
if (Array.isArray(task.reviewIntervals)) {
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (isClosedInterval(interval)) return interval;
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
changed = true;
return {
...interval,
completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember),
completedAt: crashRepairIntervalCloseIso(interval, reviewerMember),
};
});
}
if (task.status === 'completed') {
const activeReview = getActiveReviewStart(task);
if (
activeReview &&
!hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) &&
!hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt)
) {
const reviewerMember = memberByName.get(normalizeMemberName(activeReview.reviewer));
task.reviewIntervals = [
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
{
reviewer: activeReview.reviewer,
startedAt: activeReview.startedAt,
completedAt: crashRepairCloseIso(activeReview.startedAt, reviewerMember),
},
];
changed = true;
}
}
return changed;
});

View file

@ -332,7 +332,7 @@ export class TeamTaskWriter {
if (!wasInProgress && isInProgress) {
// Entering in_progress: open a new interval if none is open.
if (!last || typeof last.completedAt === 'string') {
if (!last || last.completedAt !== undefined) {
intervals.push({ startedAt: nowIso });
}
} else if (wasInProgress && !isInProgress) {

View file

@ -68,7 +68,7 @@ const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
// Longer than the renderer-facing UI timeout: late OpenCode turns should still
// finish bridge-side observation and emit member-work-sync signals.
const DEFAULT_SEND_TIMEOUT_MS = 45_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;

View file

@ -389,7 +389,7 @@ export class OpenCodePromptDeliveryLedgerStore {
visibleReplyCorrelation: input.visibleReplyCorrelation,
lastReason: input.semanticallySufficient
? record.lastReason
: 'visible_reply_ack_only_still_requires_answer',
: selectOpenCodeDestinationProofInsufficientReason(input.diagnostics),
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
updatedAt: input.observedAt,
}));
@ -874,6 +874,22 @@ function shouldPruneOpenCodePromptDeliveryRecord(
return false;
}
function selectOpenCodeDestinationProofInsufficientReason(
diagnostics: readonly string[] | undefined
): string {
const normalizedDiagnostics = (diagnostics ?? []).map((diagnostic) =>
diagnostic.trim().toLowerCase()
);
if (
normalizedDiagnostics.includes('visible_reply_missing_task_refs') ||
normalizedDiagnostics.includes('visible_reply_missing_task_refs_after_merge') ||
normalizedDiagnostics.includes('visible_reply_task_refs_merge_failed')
) {
return 'visible_reply_missing_task_refs';
}
return 'visible_reply_ack_only_still_requires_answer';
}
function mergeDiagnostics(existing: string[], next: string[]): string[] {
return [...new Set([...existing, ...next].filter((item) => item.trim()))];
}

View file

@ -1,8 +1,5 @@
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
import type {
OpenCodePromptDeliveryLedgerRecord,
OpenCodePromptDeliveryStatus,
} from './OpenCodePromptDeliveryLedger';
import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger';
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
export type OpenCodePromptDeliveryRepairKind =
@ -128,12 +125,14 @@ function taskIdList(taskRefs: TaskRef[]): string | null {
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const replyRecipient = input.replyRecipient.trim() || 'user';
const taskRefsJson = input.taskRefs.length > 0 ? JSON.stringify(input.taskRefs) : null;
return [
'The app still has no correlated visible reply proof for this message.',
`Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`,
taskRefsJson ? `Include taskRefs exactly as this JSON array: ${taskRefsJson}.` : null,
'Use a concrete answer in text and summary. Do not reply only with acknowledgement.',
'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.',
];
].filter((line): line is string => line !== null);
}
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
@ -169,7 +168,9 @@ function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): stri
];
}
function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) {
function toolErrorControl(
input: OpenCodePromptDeliveryRepairInput
): OpenCodePromptDeliveryRepairDecision {
const tools = normalizedToolNames(input);
if (hasTool(tools, 'message_send')) {
return control(
@ -264,6 +265,7 @@ export function decideOpenCodePromptDeliveryRepair(
if (
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
input.pendingReason === 'visible_reply_missing_task_refs' ||
input.pendingReason === 'visible_reply_still_required' ||
(input.responseState === 'responded_visible_message' && !input.visibleReplyFound)
) {

View file

@ -22,6 +22,10 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
'visible_reply_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'opencode_runtime_delivery_task_refs_inherited_from_relay',
'non_visible_tool_without_task_progress',
] as const;
@ -101,9 +105,20 @@ function getOpenCodeRuntimeDeliveryStateFallback(
): string | null {
const state = record.responseState?.trim();
const reason = record.lastReason?.trim();
const diagnostics = record.diagnostics.map((diagnostic) => diagnostic.trim().toLowerCase());
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (
reason === 'visible_reply_missing_task_refs' ||
diagnostics.includes('visible_reply_missing_task_refs') ||
diagnostics.includes('visible_reply_missing_task_refs_after_merge')
) {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (diagnostics.includes('visible_reply_task_refs_merge_failed')) {
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (
reason === 'visible_reply_still_required' ||
reason === 'visible_reply_ack_only_still_requires_answer' ||

View file

@ -929,6 +929,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
input.messageId
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
: null,
input.taskRefs?.length
? `If taskRefs are present in <opencode_delivery_context>, include taskRefs exactly as provided in that message_send call: ${JSON.stringify(input.taskRefs)}.`
: null,
'If message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.',
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
'You must not end this turn empty.',
'Do not answer only with plain assistant text when agent-teams_message_send is available.',

View file

@ -49,7 +49,7 @@ function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null {
const intervals = task.workIntervals ?? [];
for (let i = intervals.length - 1; i >= 0; i -= 1) {
const interval = intervals[i];
if (!interval.completedAt) {
if (interval.completedAt === undefined) {
return interval;
}
}

View file

@ -99,11 +99,12 @@ function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]):
if (!Number.isFinite(startedAt) || time < startedAt) {
return false;
}
if (!interval.completedAt) {
if (interval.completedAt === undefined) {
return true;
}
const completedAt = Date.parse(interval.completedAt);
return !Number.isFinite(completedAt) || time <= completedAt;
const endMs = Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt;
return time <= endMs;
});
}

View file

@ -1285,9 +1285,14 @@ function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeW
}
const completedAt =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
const endMs =
interval.completedAt === undefined
? null
: (Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt) +
INFERRED_WINDOW_GRACE_AFTER_MS;
return {
startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS,
endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null,
endMs,
};
})
.filter((window): window is TimeWindow => window !== null);

View file

@ -831,9 +831,14 @@ function buildTaskTimeWindows(task: TeamTask): TimeWindow[] {
}
const completedAt =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
const endMs =
interval.completedAt === undefined
? null
: (Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt) +
WINDOW_GRACE_AFTER_MS;
return {
startMs: startedAt - WINDOW_GRACE_BEFORE_MS,
endMs: Number.isFinite(completedAt) ? completedAt + WINDOW_GRACE_AFTER_MS : null,
endMs,
};
})
.filter((window): window is TimeWindow => window !== null);

View file

@ -21,7 +21,7 @@ function getActiveWorkStartedAt(task: TeamTaskWithKanban): number {
const workIntervals = task.workIntervals ?? [];
for (let index = workIntervals.length - 1; index >= 0; index--) {
const interval = workIntervals[index];
if (interval && !interval.completedAt) {
if (interval && interval.completedAt === undefined) {
const startedAt = parseIsoTime(interval.startedAt);
if (startedAt > 0) {
return startedAt;

View file

@ -9,6 +9,7 @@
import {
createEmptyMemberLogPreviewResponse,
createEmptyMemberLogStreamResponse,
createEmptyMemberRuntimeLogTailResponse,
} from '@features/member-log-stream/contracts';
import type {
@ -271,6 +272,10 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode');
return createEmptyMemberLogPreviewResponse();
},
getMemberRuntimeLogTail: async (_teamName, _memberName, options) => {
console.warn('[HttpAPIClient] getMemberRuntimeLogTail is not available in browser mode');
return createEmptyMemberRuntimeLogTailResponse(options.kind);
},
setMemberLogStreamTracking: async () => {
// Not available in browser mode - no-op.
},

View file

@ -36,25 +36,47 @@ import type { MemberLogSummary } from '@shared/types';
const CHUNK_GRACE_BEFORE_MS = 30_000; // 30s before startedAt
const CHUNK_GRACE_AFTER_MS = 10_000; // 10s after completedAt
function filterChunksByWorkIntervals(
function getWorkIntervalWindow(
interval: { startedAt: string; completedAt?: string },
options: {
graceBeforeMs: number;
graceAfterMs: number;
nowMs: number;
}
): { startMs: number; endMs: number } | null {
const startMs = Date.parse(interval.startedAt);
if (!Number.isFinite(startMs)) return null;
if (interval.completedAt === undefined) {
return {
startMs: startMs - options.graceBeforeMs,
endMs: options.nowMs + options.graceAfterMs,
};
}
const completedAtMs = Date.parse(interval.completedAt);
const endMs = Number.isFinite(completedAtMs) ? Math.max(completedAtMs, startMs) : startMs;
return {
startMs: startMs - options.graceBeforeMs,
endMs: endMs + options.graceAfterMs,
};
}
export function filterChunksByWorkIntervals(
chunks: EnhancedChunk[] | null,
intervals: { startedAt: string; completedAt?: string }[] | undefined
): EnhancedChunk[] | null {
if (!chunks) return null;
if (!intervals || intervals.length === 0) return chunks;
const now = Date.now();
const nowMs = Date.now();
const parsed = intervals
.map((i) => {
const s = Date.parse(i.startedAt);
if (!Number.isFinite(s)) return null;
const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null;
return {
startMs: s - CHUNK_GRACE_BEFORE_MS,
endMs: e != null && Number.isFinite(e) ? e + CHUNK_GRACE_AFTER_MS : null,
};
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null);
.map((interval) =>
getWorkIntervalWindow(interval, {
graceBeforeMs: CHUNK_GRACE_BEFORE_MS,
graceAfterMs: CHUNK_GRACE_AFTER_MS,
nowMs,
})
)
.filter((v): v is { startMs: number; endMs: number } => v !== null);
if (parsed.length === 0) return chunks;
@ -62,10 +84,7 @@ function filterChunksByWorkIntervals(
const cs = chunk.startTime.getTime();
const ce = chunk.endTime.getTime();
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
return parsed.some((i) => {
const end = i.endMs ?? now;
return cs <= end && ce >= i.startMs;
});
return parsed.some((i) => cs <= i.endMs && ce >= i.startMs);
});
return filtered;
}
@ -215,13 +234,14 @@ export const MemberLogsTab = ({
let totalOverlap = 0;
for (const interval of taskWorkIntervals) {
const intStart = Date.parse(interval.startedAt);
if (!Number.isFinite(intStart)) continue;
const intEnd =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : nowMs;
if (!Number.isFinite(intEnd)) continue;
const overlapStart = Math.max(logStartMs, intStart);
const overlapEnd = Math.min(logEndMs, intEnd);
const window = getWorkIntervalWindow(interval, {
graceBeforeMs: 0,
graceAfterMs: 0,
nowMs,
});
if (!window) continue;
const overlapStart = Math.max(logStartMs, window.startMs);
const overlapEnd = Math.min(logEndMs, window.endMs);
if (overlapEnd > overlapStart) totalOverlap += overlapEnd - overlapStart;
}
return totalOverlap;
@ -294,17 +314,15 @@ export const MemberLogsTab = ({
) {
const GRACE_BEFORE = 30_000;
const GRACE_AFTER = 15_000;
const now = Date.now();
const nowMs = Date.now();
const intervals = taskWorkIntervals
.map((i) => {
const s = Date.parse(i.startedAt);
if (!Number.isFinite(s)) return null;
const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null;
return {
startMs: s - GRACE_BEFORE,
endMs: e != null && Number.isFinite(e) ? e + GRACE_AFTER : now + GRACE_AFTER,
};
})
.map((interval) =>
getWorkIntervalWindow(interval, {
graceBeforeMs: GRACE_BEFORE,
graceAfterMs: GRACE_AFTER,
nowMs,
})
)
.filter((v): v is { startMs: number; endMs: number } => v !== null);
if (intervals.length > 0) {

View file

@ -664,7 +664,7 @@ export const MessageComposer = ({
className={cn(
'mr-[15px] inline-flex items-center border text-xs transition-colors',
shouldDockRecipientSelector
? 'relative z-10 -mb-px overflow-hidden rounded-b-none rounded-t-[1.35rem] border-b-0 bg-[var(--color-surface-raised)]'
? 'relative z-[1] -mb-px overflow-hidden rounded-b-none rounded-t-[1.35rem] border-b-0 bg-[var(--color-surface-raised)]'
: 'rounded-full',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
@ -948,7 +948,7 @@ export const MessageComposer = ({
) : null}
</div>
<div className="relative">
<div className={cn('relative', shouldDockRecipientSelector && 'z-[2]')}>
<DropZoneOverlay
active={isDragOver}
rejected={!canAttach}

View file

@ -255,7 +255,7 @@ export function deriveWorkActivityTimerAnchor(
for (let index = intervals.length - 1; index >= 0; index -= 1) {
const interval = intervals[index];
const startedAtMs = parseIsoMs(interval?.startedAt);
if (startedAtMs > 0 && !interval?.completedAt) {
if (startedAtMs > 0 && interval?.completedAt === undefined) {
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
const previous = intervals[previousIndex];
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
@ -335,7 +335,10 @@ export function deriveReviewActivityTimerAnchor(
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
const interval = reviewIntervals[index];
if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) {
if (
normalizeMemberName(interval?.reviewer) !== memberKey ||
interval?.completedAt !== undefined
) {
continue;
}
const startedAtMs = parseIsoMs(interval.startedAt);

View file

@ -362,6 +362,15 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (trimmed === 'visible_reply_missing_task_refs') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (trimmed === 'visible_reply_missing_task_refs_after_merge') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (trimmed === 'visible_reply_task_refs_merge_failed') {
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (trimmed === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}

View file

@ -46,6 +46,15 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (normalized === 'visible_reply_missing_task_refs') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (normalized === 'visible_reply_missing_task_refs_after_merge') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (normalized === 'visible_reply_task_refs_merge_failed') {
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (normalized === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}

View file

@ -63,7 +63,7 @@ export function calculateTaskImplementationDuration<TInterval extends TaskWorkDu
continue;
}
if (!interval?.completedAt && task.status === 'in_progress' && nowMs > startMs) {
if (interval?.completedAt === undefined && task.status === 'in_progress' && nowMs > startMs) {
windows.push({ startMs, endMs: nowMs });
hasRunningInterval = true;
}
@ -130,7 +130,12 @@ export function calculateTaskImplementationEventDuration<
for (const interval of task.workIntervals) {
const startMs = parseIsoMs(interval?.startedAt);
if (startMs > 0 && !interval?.completedAt && nowMs > startMs && isNearTime(startMs, eventMs)) {
if (
startMs > 0 &&
interval?.completedAt === undefined &&
nowMs > startMs &&
isNearTime(startMs, eventMs)
) {
return { elapsedMs: nowMs - startMs, running: true };
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# OpenCode Model Gauntlet Results
Generated: 2026-05-08T18:34:37.950Z
Generated: 2026-05-08T21:13:58.089Z
Runs per model: 1
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
@ -13,25 +13,25 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms |
| `opencode/big-pickle` | Infra blocked | blocked | 0 | 0 | 0 | n/a | 70 | 0/1 | 0/1 | concurrentReplies 0/1 (0%) | concurrentBob 0/1 (0%) | provider-infra | overall average 70 < 80; successful runs 0 < 1; consistency score 0 < 85; provider-infra failures 1; highest weighted stage loss concurrentReplies=15; weakest taskRefs concurrentBob=0/1 (0%); protocol violations in 1 runs | 1 | 0 | 0 | 1 | 281016ms | 281016ms |
## opencode/big-pickle
Readiness score: 73.
Readiness score: 0.
Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1.
Score stability: n/a.
Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%).
Recommendation blockers: overall average 70 < 80; successful runs 0 < 1; consistency score 0 < 85; provider-infra failures 1; highest weighted stage loss concurrentReplies=15; weakest taskRefs concurrentBob=0/1 (0%); protocol violations in 1 runs.
Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%).
Weighted stage impact: concurrentReplies:loss=15, failed=1, pass=0/1 (0%); taskRefs:loss=10, failed=1, pass=0/1 (0%); noDuplicateTokens:loss=5, failed=1, pass=0/1 (0%).
Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%).
Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:0/1 (0%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:0/1 (0%), latencyStable:1/1 (100%).
TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%).
Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
Protocol totals: badMessages=0, duplicateOrMissingTokens=2, affectedRuns=1.
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 |
| 1 | provider-infra-blocked | provider-infra | 70 | no | 281016ms | concurrentReplies, taskRefs, noDuplicateTokens | concurrentReplies:189928ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | token=GAUNTLET_CONCURRENT_BOB_OK_1+GAUNTLET_CONCURRENT_TOM_OK_1 | concurrentBob: Timed out waiting for OpenCode reply in /var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/opencode-semantic-gauntlet-ZwZPyq/.claude/teams/opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1/inboxes/user.js |

View file

@ -312,6 +312,7 @@ describe('ipc teams handlers', () => {
getAliveTeams: vi.fn(() => ['my-team']),
getLeadActivityState: vi.fn(() => 'idle'),
stopTeam: vi.fn(() => Promise.resolve()),
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
};
@ -369,6 +370,8 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
initializeTeamHandlers(
service as never,
@ -1377,6 +1380,32 @@ describe('ipc teams handlers', () => {
expect(service.getTeamData).not.toHaveBeenCalled();
});
it('repairs stale task activity before reading TEAM_GET_DATA through the worker', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team')) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(true);
expect(provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot).toHaveBeenCalledWith(
'my-team'
);
expect(
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mock.invocationCallOrder[0]
).toBeLessThan(mockTeamDataWorkerClient.getTeamData.mock.invocationCallOrder[0]);
});
it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({

View file

@ -126,6 +126,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
expect(directCommand?.text).toContain('Action mode for this message: ask.');
expect(directCommand?.text).toContain('You must not end this turn empty.');
expect(directCommand?.text).toContain('include taskRefs exactly as provided');
expect(directCommand?.text).toContain('"displayId":"59560c95"');
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
expect(directCommand?.text).toContain('never use #00000000');
@ -143,12 +144,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
if (process.env.OPENCODE_E2E_DUMP_PROMPTS === '1') {
await dumpOpenCodePromptArtifacts({
outputDir: path.join(
process.cwd(),
'test-results',
'opencode-semantic-prompts',
teamName
),
outputDir: path.join(process.cwd(), 'test-results', 'opencode-semantic-prompts', teamName),
launchInput: launchInput!,
launchCommand: launchCommand!,
messageCommands: bridgeCapture.messageCommands,

View file

@ -267,6 +267,36 @@ describe('OpenCodePromptDeliveryLedger', () => {
).toBe(true);
});
it('preserves missing taskRefs as the pending reason for insufficient destination proof', async () => {
const store = createStore();
const record = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-taskrefs',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'user',
payloadHash: 'sha256:taskrefs',
now: '2026-04-25T10:00:00.000Z',
});
const missingTaskRefs = await store.applyDestinationProof({
id: record.id,
visibleReplyInbox: 'user',
visibleReplyMessageId: 'reply-taskrefs',
visibleReplyCorrelation: 'relayOfMessageId',
semanticallySufficient: false,
diagnostics: ['visible_reply_missing_task_refs_after_merge'],
observedAt: '2026-04-25T10:00:01.000Z',
});
expect(missingTaskRefs.status).toBe('pending');
expect(missingTaskRefs.responseState).toBe('responded_visible_message');
expect(missingTaskRefs.lastReason).toBe('visible_reply_missing_task_refs');
expect(missingTaskRefs.diagnostics).toContain('visible_reply_missing_task_refs_after_merge');
});
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
const store = createStore();
const unanswered = await store.ensurePending({

View file

@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
expect(decision.controlText).not.toContain('reportToken=');
});
it('repairs visible replies that missed required taskRefs with exact metadata', () => {
const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' };
const decision = decideOpenCodePromptDeliveryRepair(
base({
taskRefs: [taskRef],
responseState: 'responded_visible_message',
pendingReason: 'visible_reply_missing_task_refs',
visibleReplyFound: true,
})
);
expect(decision.kind).toBe('missing_visible_reply_correlation');
expect(decision.retryable).toBe(true);
expect(decision.controlText).toContain('relayOfMessageId="msg-1"');
expect(decision.controlText).toContain(
`Include taskRefs exactly as this JSON array: ${JSON.stringify([taskRef])}.`
);
});
it('does not repair terminal, permission, or session failures', () => {
expect(
decideOpenCodePromptDeliveryRepair(

View file

@ -137,6 +137,70 @@ describe('OpenCodeReadinessBridge', () => {
);
});
it('gives observeMessageDelivery enough time for OpenCode plain-text fallback reconciliation', async () => {
const executor = fakeExecutor(
bridgeCommandSuccess({
command: 'opencode.observeMessageDelivery',
requestId: 'observe-req-1',
data: {
observed: true,
memberName: 'tom',
sessionId: 'session-tom',
diagnostics: [],
responseObservation: {
state: 'responded_plain_text',
deliveredUserMessageId: 'user-message-1',
assistantMessageId: 'assistant-message-1',
toolCallNames: ['message_send'],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: 'plain_assistant_text',
latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1',
reason: 'assistant_replied_with_plain_text',
},
},
})
);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.observeOpenCodeTeamMessageDelivery({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'primary',
runId: 'run-1',
projectPath: '/repo',
memberName: 'tom',
messageId: 'gauntlet-concurrent-tom-1',
prePromptCursor: 'cursor-before',
})
).resolves.toMatchObject({
observed: true,
responseObservation: {
state: 'responded_plain_text',
latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1',
},
});
expect(executor.execute).toHaveBeenCalledWith(
'opencode.observeMessageDelivery',
{
teamId: 'team-a',
teamName: 'team-a',
laneId: 'primary',
runId: 'run-1',
projectPath: '/repo',
memberName: 'tom',
messageId: 'gauntlet-concurrent-tom-1',
prePromptCursor: 'cursor-before',
},
{
cwd: '/repo',
timeoutMs: 20_000,
}
);
});
it('executes OpenCode task ledger backfill through a direct read-only bridge command', async () => {
const executor = fakeExecutor(
bridgeCommandSuccess({

View file

@ -44,4 +44,30 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
'OpenCode used tools, but did not create a visible reply or task progress proof.'
);
});
it('formats visible replies missing taskRefs without exposing the internal reason code', () => {
const record = {
diagnostics: ['visible_reply_missing_task_refs'],
lastReason: 'visible_reply_missing_task_refs',
responseState: 'responded_visible_message',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode created a reply without the required taskRefs metadata.'
);
});
it('formats taskRefs merge verification failures without exposing internal diagnostics', () => {
const record = {
diagnostics: ['visible_reply_missing_task_refs_after_merge'],
lastReason: 'visible_reply_ack_only_still_requires_answer',
responseState: 'responded_visible_message',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode created a reply without the required taskRefs metadata.'
);
});
});

View file

@ -536,7 +536,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('<opencode_delivery_context>');
expect(sentText).toContain('"kind":"opencode-delivery-context"');
expect(sentText).toContain('"inboundMessageId":"msg-1"');
expect(sentText).not.toContain('include taskRefs exactly');
expect(sentText).toContain('include taskRefs exactly as provided');
expect(sentText).not.toContain('The inbound app messageId is');
expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message');
expect(sentText).toContain('never use #00000000');

View file

@ -48,7 +48,7 @@ import {
RuntimeStoreBatchWriter,
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types';
const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000;
@ -10539,6 +10539,126 @@ describe('Team agent launch matrix safe e2e', () => {
});
});
it('inherits OpenCode runtime delivery taskRefs end-to-end when the visible reply omits them', async () => {
const teamName = 'pure-opencode-runtime-delivery-taskrefs-inherit-safe-e2e';
const taskRef: TaskRef = {
teamName,
taskId: 'task-runtime-delivery-1',
displayId: 'abcd1234',
};
const adapter = new VisibleReplyOpenCodeRuntimeAdapter({
replySource: 'runtime_delivery',
});
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const launch = await svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
model: 'opencode/big-pickle',
skipPermissions: true,
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
},
() => undefined
);
await expect(
svc.deliverOpenCodeMemberMessage(teamName, {
memberName: 'alice',
text: 'reply about #abcd1234 without manually carrying metadata',
messageId: 'msg-taskrefs-inherit-e2e',
replyRecipient: 'user',
actionMode: 'ask',
source: 'manual',
taskRefs: [taskRef],
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_visible_message',
ledgerStatus: 'responded',
visibleReplyMessageId: 'reply-msg-taskrefs-inherit-e2e',
visibleReplyCorrelation: 'relayOfMessageId',
});
expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({
runId: launch.runId,
teamName,
laneId: 'primary',
memberName: 'alice',
messageId: 'msg-taskrefs-inherit-e2e',
taskRefs: [taskRef],
});
const userInbox = await readInboxRows(teamName, 'user');
expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({
from: 'alice',
to: 'user',
source: 'runtime_delivery',
messageId: 'reply-msg-taskrefs-inherit-e2e',
relayOfMessageId: 'msg-taskrefs-inherit-e2e',
taskRefs: [taskRef],
});
});
it('does not attach taskRefs end-to-end to explicit non-runtime visible replies', async () => {
const teamName = 'pure-opencode-runtime-delivery-taskrefs-non-runtime-safe-e2e';
const taskRef: TaskRef = {
teamName,
taskId: 'task-runtime-delivery-2',
displayId: 'dcba4321',
};
const adapter = new VisibleReplyOpenCodeRuntimeAdapter({
replySource: 'lead_process',
});
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
await svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
model: 'opencode/big-pickle',
skipPermissions: true,
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
},
() => undefined
);
await expect(
svc.deliverOpenCodeMemberMessage(teamName, {
memberName: 'alice',
text: 'this reply has a misleading non-runtime source',
messageId: 'msg-taskrefs-non-runtime-e2e',
replyRecipient: 'user',
actionMode: 'ask',
source: 'manual',
taskRefs: [taskRef],
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'responded_visible_message',
reason: 'visible_reply_missing_task_refs',
});
const userInbox = await readInboxRows(teamName, 'user');
expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({
from: 'alice',
to: 'user',
source: 'lead_process',
messageId: 'reply-msg-taskrefs-non-runtime-e2e',
relayOfMessageId: 'msg-taskrefs-non-runtime-e2e',
});
expect(userInbox[0]?.taskRefs).toBeUndefined();
});
it('delivers direct OpenCode member messages to recovered pure OpenCode lanes after service restart', async () => {
const teamName = 'pure-opencode-direct-message-recovered-lane-safe-e2e';
const launchAdapter = new FakeOpenCodeRuntimeAdapter();
@ -17111,6 +17231,58 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}
}
class VisibleReplyOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
constructor(private readonly options: { replySource: InboxMessage['source'] }) {
super();
}
override async sendMessageToMember(
input: OpenCodeTeamRuntimeMessageInput
): Promise<OpenCodeTeamRuntimeMessageResult> {
const result = await super.sendMessageToMember(input);
const relayOfMessageId = input.messageId?.trim() || `message-${this.messageInputs.length}`;
const replyRecipient = input.replyRecipient?.trim() || 'user';
const replyMessageId = `reply-${relayOfMessageId}`;
const inboxPath = path.join(
getTeamsBasePath(),
input.teamName,
'inboxes',
`${replyRecipient}.json`
);
const rows: InboxMessage[] = await readInboxRows(input.teamName, replyRecipient).catch(
() => []
);
rows.push({
from: input.memberName,
to: replyRecipient,
text: `Visible reply for ${relayOfMessageId}`,
summary: 'visible reply',
timestamp: '2026-05-08T10:00:00.000Z',
read: false,
messageId: replyMessageId,
relayOfMessageId,
source: this.options.replySource,
});
await fs.mkdir(path.dirname(inboxPath), { recursive: true });
await fs.writeFile(inboxPath, `${JSON.stringify(rows, null, 2)}\n`, 'utf8');
return {
...result,
responseObservation: {
state: 'responded_visible_message',
deliveredUserMessageId: `delivered-${relayOfMessageId}`,
assistantMessageId: `assistant-${relayOfMessageId}`,
toolCallNames: ['message_send'],
visibleMessageToolCallId: `call-${relayOfMessageId}`,
visibleReplyMessageId: replyMessageId,
visibleReplyCorrelation: 'relayOfMessageId',
latestAssistantPreview: null,
reason: 'visible_message_sent',
},
};
}
}
class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = [];
@ -18380,6 +18552,13 @@ function getMixedPrimaryFixture(providerId: MixedPrimaryProviderId = 'codex'): {
};
}
async function readInboxRows(teamName: string, inboxName: string): Promise<InboxMessage[]> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`);
const raw = await fs.readFile(inboxPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as InboxMessage[]) : [];
}
async function writeTeamMeta(
teamName: string,
projectPath: string,

View file

@ -221,6 +221,147 @@ describe('TeamInboxWriter', () => {
});
});
it('merges taskRefs when deduplicating repeated runtime delivery replies', async () => {
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' };
const first = await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: 'Готово по задаче.',
source: 'runtime_delivery',
relayOfMessageId: 'inbound-task-1',
});
const second = await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: ' Готово по задаче. ',
source: 'runtime_delivery',
relayOfMessageId: 'inbound-task-1',
taskRefs: [taskRef],
});
const userInboxPath = '/mock/teams/my-team/inboxes/user.json';
const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record<
string,
unknown
>[];
expect(second).toMatchObject({
deliveredToInbox: true,
deduplicated: true,
messageId: first.messageId,
});
expect(persisted).toHaveLength(1);
expect(persisted[0].taskRefs).toEqual([taskRef]);
});
it('merges taskRefs into an exact runtime delivery reply row', async () => {
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' };
const written = await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: 'Готово по задаче.',
source: 'runtime_delivery',
relayOfMessageId: 'inbound-task-1',
messageId: 'reply-1',
});
const result = await writer.mergeRuntimeDeliveryTaskRefs('my-team', {
inboxName: 'user',
messageId: written.messageId,
relayOfMessageId: 'inbound-task-1',
from: 'alice',
taskRefs: [taskRef],
});
const userInboxPath = '/mock/teams/my-team/inboxes/user.json';
const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record<
string,
unknown
>[];
expect(result).toMatchObject({
found: true,
updated: true,
message: {
messageId: 'reply-1',
taskRefs: [taskRef],
},
});
expect(persisted).toHaveLength(1);
expect(persisted[0].taskRefs).toEqual([taskRef]);
});
it('does not merge taskRefs into explicit non-runtime reply rows', async () => {
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' };
await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: 'Lead process reply.',
source: 'lead_process',
relayOfMessageId: 'inbound-task-1',
messageId: 'reply-1',
});
const result = await writer.mergeRuntimeDeliveryTaskRefs('my-team', {
inboxName: 'user',
messageId: 'reply-1',
relayOfMessageId: 'inbound-task-1',
from: 'alice',
taskRefs: [taskRef],
});
const userInboxPath = '/mock/teams/my-team/inboxes/user.json';
const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record<
string,
unknown
>[];
expect(result).toEqual({ found: false, updated: false });
expect(persisted[0]).not.toHaveProperty('taskRefs');
});
it('repairs relayOfMessageId on a runtime delivery reply matched by messageId', async () => {
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' };
await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: 'Visible answer.',
source: 'runtime_delivery',
relayOfMessageId: 'hallucinated-inbound-id',
messageId: 'reply-1',
});
const result = await writer.correlateRuntimeDeliveryReply('my-team', {
inboxName: 'user',
messageId: 'reply-1',
relayOfMessageId: 'real-inbound-id',
from: 'alice',
taskRefs: [taskRef],
});
const userInboxPath = '/mock/teams/my-team/inboxes/user.json';
const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record<
string,
unknown
>[];
expect(result).toMatchObject({
found: true,
updated: true,
message: {
messageId: 'reply-1',
relayOfMessageId: 'real-inbound-id',
taskRefs: [taskRef],
},
});
expect(persisted).toHaveLength(1);
expect(persisted[0]).toMatchObject({
relayOfMessageId: 'real-inbound-id',
taskRefs: [taskRef],
});
});
it('omits source field from payload when not provided in request', async () => {
await writer.sendMessage('my-team', {
member: 'alice',

View file

@ -1080,6 +1080,152 @@ describe('TeamMemberLogsFinder', () => {
);
});
it('findLogsForTask does not treat malformed empty completedAt intervals as open', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-malformed-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 't5-malformed';
const projectPath = '/Users/test/proj5-malformed';
const projectId = '-Users-test-proj5-malformed';
const leadSessionId = 's5-malformed';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'bob', agentType: 'general-purpose' },
{ name: 'alice', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: {
role: 'user',
content: `You are alice, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'TaskUpdate',
input: { team_name: teamName, taskId: '10', status: 'pending' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-near.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T10:00:01.000Z',
type: 'user',
message: {
role: 'user',
content: `You are bob, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: '2026-01-01T10:00:02.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Near malformed interval' }],
},
}),
].join('\n') + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-late.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T12:00:00.000Z',
type: 'user',
message: {
role: 'user',
content: `You are bob, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: '2026-01-01T12:00:01.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Late malformed interval' }],
},
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const options = {
owner: 'bob',
status: 'in_progress',
intervals: [{ startedAt: '2026-01-01T10:00:00.000Z', completedAt: '' }],
};
const logs = await finder.findLogsForTask(teamName, '10', options);
const bobFilePaths = logs
.filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
.map((l) => l.filePath ?? '');
expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe(true);
expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false);
const reversedIntervalLogs = await finder.findLogsForTask(teamName, '10', {
...options,
intervals: [
{
startedAt: '2026-01-01T10:00:00.000Z',
completedAt: '2026-01-01T09:59:00.000Z',
},
],
});
const reversedBobFilePaths = reversedIntervalLogs
.filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
.map((l) => l.filePath ?? '');
expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe(
true
);
expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(
false
);
const refs = await finder.findLogFileRefsForTask(teamName, '10', options);
const bobRefPaths = refs
.filter((ref) => ref.memberName.toLowerCase() === 'bob')
.map((ref) => ref.filePath);
expect(bobRefPaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false);
});
it('findLogsForTask does not auto-include owner sessions when owner is team-lead', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-lead-owner-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -170,6 +170,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
['network_error', 'Fetch failed because the network connection timed out.'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
[
'protocol_proof_missing',
'OpenCode created a reply without the required taskRefs metadata.',
],
['backend_error', 'Unexpected backend blew up during request processing.'],
] as const)('classifies %s retry causes from api_error messages', async (expected, message) => {
const service = new TeamMemberRuntimeAdvisoryService({} as never);

View file

@ -126,6 +126,7 @@ import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService';
import {
clearAutoResumeService,
getAutoResumeService,
@ -1524,6 +1525,218 @@ describe('TeamProvisioningService', () => {
expect(refreshLeadInbox).toHaveBeenCalledTimes(2);
});
it('pauses member task intervals at last runtime evidence plus grace when runtime goes offline', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const pauseSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'pauseActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 0 });
const svc = new TeamProvisioningService();
const teamName = 'spawn-runtime-offline-team';
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
{
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-05-02T10:00:02.000Z',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
livenessSource: 'heartbeat',
lastHeartbeatAt: '2026-05-02T10:00:00.000Z',
livenessLastCheckedAt: '2026-05-02T10:00:01.000Z',
},
],
]),
});
(svc as any).setMemberSpawnStatus(run, 'alice', 'offline');
expect(pauseSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:06.000Z');
});
it('pauses member task intervals when snapshot sync observes runtime loss', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const pauseSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'pauseActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 0 });
const svc = new TeamProvisioningService();
const teamName = 'spawn-runtime-snapshot-offline-team';
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
{
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-05-02T10:00:02.000Z',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
livenessSource: 'heartbeat',
lastHeartbeatAt: '2026-05-02T10:00:00.000Z',
livenessLastCheckedAt: '2026-05-02T10:00:01.000Z',
},
],
]),
});
const snapshot = createPersistedLaunchSnapshot({
teamName,
expectedMembers: ['alice'],
launchPhase: 'finished',
members: {
alice: {
name: 'alice',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'Runtime disappeared before finalization.',
lastEvaluatedAt: '2026-05-02T10:04:00.000Z',
},
},
});
(svc as any).syncRunMemberSpawnStatusesFromSnapshot(run, snapshot);
expect(pauseSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:06.000Z');
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'error',
runtimeAlive: false,
launchState: 'failed_to_start',
});
});
it('resumes member task intervals at the heartbeat evidence time when runtime comes online', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const resumeSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 0 });
const svc = new TeamProvisioningService();
const teamName = 'spawn-runtime-online-team';
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
{
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-05-02T09:59:00.000Z',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
},
],
]),
});
(svc as any).setMemberSpawnStatus(
run,
'alice',
'online',
undefined,
'heartbeat',
'2026-05-02T10:00:00.000Z'
);
expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:00.000Z');
});
it('does not resume member task intervals from a stale heartbeat older than offline status', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const resumeSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 0 });
const svc = new TeamProvisioningService();
const teamName = 'spawn-runtime-stale-heartbeat-team';
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['alice'],
memberSpawnStatuses: new Map([
[
'alice',
{
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-05-02T10:04:00.000Z',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
lastHeartbeatAt: '2026-05-02T10:00:00.000Z',
},
],
]),
});
(svc as any).setMemberSpawnStatus(
run,
'alice',
'online',
undefined,
'heartbeat',
'2026-05-02T10:00:30.000Z'
);
expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:05:00.000Z');
});
it('does not resume member task intervals from stale direct runtime evidence', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z'));
const resumeSpy = vi
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember')
.mockReturnValue({ changedTasks: 0 });
const svc = new TeamProvisioningService();
const teamName = 'spawn-runtime-stale-direct-evidence-team';
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['alice'],
});
const previous = {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-05-02T10:04:00.000Z',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
};
const next = {
...previous,
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-05-02T10:03:00.000Z',
runtimeAlive: true,
bootstrapConfirmed: true,
};
(svc as any).syncMemberTaskActivityForRuntimeTransition(
run,
'alice',
previous,
next,
'2026-05-02T10:00:30.000Z'
);
expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:05:00.000Z');
});
it('retries the owner status request when a member-spawn change lands while it is building', async () => {
const svc = new TeamProvisioningService();
const teamName = 'spawn-cache-owner-retry-team';
@ -3079,6 +3292,179 @@ describe('TeamProvisioningService', () => {
);
});
it('projects a pending restart as bootstrap-pending in finished launch snapshots without mutating live state', () => {
const requestedAt = new Date().toISOString();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
firstSpawnAcceptedAt: requestedAt,
runtimeDiagnostic: undefined,
runtimeDiagnosticSeverity: undefined,
}),
],
]),
});
run.isLaunch = true;
run.provisioningComplete = true;
run.pendingMemberRestarts.set('bob', {
requestedAt,
desired: {
name: 'bob',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
},
});
const svc = new TeamProvisioningService();
const projected = (svc as any).buildRuntimeSpawnStatusRecord(run);
expect(projected.bob).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
runtimeDiagnostic: 'Manual restart is already in progress; waiting for teammate bootstrap.',
runtimeDiagnosticSeverity: 'info',
});
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
hardFailure: false,
});
});
it('does not sync a stale never-spawned launch snapshot over a pending restart', () => {
const requestedAt = new Date().toISOString();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
firstSpawnAcceptedAt: requestedAt,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
}),
],
]),
});
run.pendingMemberRestarts.set('bob', {
requestedAt,
desired: {
name: 'bob',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
},
});
const snapshot = createPersistedLaunchSnapshot({
teamName: run.teamName,
expectedMembers: ['bob'],
launchPhase: 'finished',
members: {
bob: {
name: 'bob',
launchState: 'failed_to_start',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'Teammate was never spawned during launch.',
lastEvaluatedAt: new Date().toISOString(),
},
},
});
const svc = new TeamProvisioningService();
(svc as any).syncRunMemberSpawnStatusesFromSnapshot(run, snapshot);
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
});
});
it('does not mark a pending restart as failed during bootstrap cleanup projection', () => {
const requestedAt = new Date().toISOString();
const run = createMemberSpawnRun({
teamName: 'codex-team',
expectedMembers: ['alice', 'bob'],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
}),
],
[
'bob',
createMemberSpawnStatusEntry({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
firstSpawnAcceptedAt: requestedAt,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
}),
],
]),
});
run.pendingMemberRestarts.set('bob', {
requestedAt,
desired: {
name: 'bob',
providerId: 'codex',
model: 'gpt-5.2',
effort: 'medium',
},
});
const svc = new TeamProvisioningService();
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup requested', {
cleanupRequested: true,
});
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
hardFailureReason: 'launch cleanup requested',
});
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'spawning',
launchState: 'starting',
agentToolAccepted: false,
hardFailure: false,
hardFailureReason: undefined,
error: undefined,
});
});
it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => {
const teamName = 'forge-labs-10';
const teamDir = path.join(tempTeamsBase, teamName);
@ -5582,6 +5968,294 @@ describe('TeamProvisioningService', () => {
expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy();
});
it('materializes plain-text fallback after OpenCode message_send tool errors', async () => {
const svc = new TeamProvisioningService();
const taskRef = {
taskId: 'task-tool-error-fallback',
displayId: 'toolerr1',
teamName: 'team-a',
};
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'tool_error',
deliveredUserMessageId: 'oc-user-tool-error',
assistantMessageId: 'oc-assistant-tool-error',
toolCallNames: ['agent-teams_message_send'],
visibleMessageToolCallId: 'call-message-send',
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1',
reason: 'message_send_tool_error_without_visible_reply_proof',
},
diagnostics: ['OpenCode tool failed without output'],
}));
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await writeDefaultBobOpenCodeBootstrapEvidence();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Concurrent check. Reply to user with GAUNTLET_CONCURRENT_TOM_OK_1.',
messageId: 'msg-tool-error-fallback',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [taskRef],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_plain_text',
visibleReplyCorrelation: 'plain_assistant_text',
diagnostics: expect.arrayContaining([
'opencode_message_send_tool_error_plain_text_reply_materialized',
'opencode_plain_text_reply_materialized_to_user_inbox',
]),
});
const userInbox = JSON.parse(
await fsPromises.readFile(
path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'),
'utf8'
)
) as Array<Record<string, unknown>>;
expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({
from: 'bob',
to: 'user',
text: 'GAUNTLET_CONCURRENT_TOM_OK_1',
relayOfMessageId: 'msg-tool-error-fallback',
source: 'runtime_delivery',
taskRefs: [taskRef],
});
});
it('observes OpenCode message_send tool errors quickly before retrying duplicate prompts', async () => {
const svc = new TeamProvisioningService();
const taskRef = {
taskId: 'task-tool-error-observe-first',
displayId: 'obsfirst',
teamName: 'team-a',
};
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before-tool-error',
responseObservation: {
state: 'tool_error',
deliveredUserMessageId: 'oc-user-tool-error-observe',
assistantMessageId: 'oc-assistant-tool-error-observe',
toolCallNames: ['agent-teams_message_send'],
visibleMessageToolCallId: 'call-message-send-observe',
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'message_send_tool_error_without_visible_reply_proof',
},
diagnostics: ['OpenCode tool failed without output'],
}));
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
responseObservation: {
state: 'responded_plain_text',
deliveredUserMessageId: 'oc-user-tool-error-observe',
assistantMessageId: 'oc-assistant-plain-fallback',
toolCallNames: ['agent-teams_message_send'],
visibleMessageToolCallId: 'call-message-send-observe',
visibleReplyMessageId: null,
visibleReplyCorrelation: 'plain_assistant_text',
latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1',
reason: 'assistant_replied_with_plain_text',
},
diagnostics: ['Observed OpenCode plain-text fallback after message_send tool error'],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
observeMessageDelivery,
} as any,
])
);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await writeDefaultBobOpenCodeBootstrapEvidence();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.',
messageId: 'msg-tool-error-observe-first',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [taskRef],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'tool_error',
reason: 'tool_error_without_required_delivery_proof',
});
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: tempTeamsBase,
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
fileName: 'opencode-prompt-delivery-ledger.json',
});
const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
data: Array<{ nextAttemptAt: string | null }>;
};
const nextAttemptAt = ledgerEnvelope.data[0]?.nextAttemptAt;
expect(nextAttemptAt).toBeTruthy();
const delayMs = Date.parse(nextAttemptAt!) - Date.now();
expect(delayMs).toBeGreaterThanOrEqual(0);
expect(delayMs).toBeLessThanOrEqual(5_000);
ledgerEnvelope.data[0]!.nextAttemptAt = '2000-01-01T00:00:00.000Z';
await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8');
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.',
messageId: 'msg-tool-error-observe-first',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [taskRef],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
responsePending: false,
responseState: 'responded_plain_text',
visibleReplyCorrelation: 'plain_assistant_text',
});
const userInbox = JSON.parse(
await fsPromises.readFile(
path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'),
'utf8'
)
) as Array<Record<string, unknown>>;
expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({
from: 'bob',
to: 'user',
text: 'GAUNTLET_OBSERVE_FIRST_OK_1',
relayOfMessageId: 'msg-tool-error-observe-first',
source: 'runtime_delivery',
taskRefs: [taskRef],
});
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
expect(observeMessageDelivery).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 'msg-tool-error-observe-first',
prePromptCursor: 'cursor-before-tool-error',
})
);
});
it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -5876,6 +6550,242 @@ describe('TeamProvisioningService', () => {
expect(sendMessageToMember).not.toHaveBeenCalled();
});
it('inherits taskRefs from the OpenCode delivery ledger for exact visible replies', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn();
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await writeDefaultBobOpenCodeBootstrapEvidence();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' };
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
await fsPromises.mkdir(inboxDir, { recursive: true });
await fsPromises.writeFile(
path.join(inboxDir, 'user.json'),
`${JSON.stringify(
[
{
from: 'bob',
to: 'user',
text: 'Here is the concrete answer for #abcd1234.',
timestamp: '2026-04-25T10:00:03.000Z',
read: false,
messageId: 'reply-user-task-1',
relayOfMessageId: 'msg-task-refs-1',
source: 'runtime_delivery',
},
],
null,
2
)}\n`,
'utf8'
);
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Please answer for #abcd1234.',
messageId: 'msg-task-refs-1',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [taskRef],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_visible_message',
visibleReplyMessageId: 'reply-user-task-1',
visibleReplyCorrelation: 'relayOfMessageId',
});
const userInbox = JSON.parse(
await fsPromises.readFile(path.join(inboxDir, 'user.json'), 'utf8')
) as Array<Record<string, unknown>>;
expect(userInbox[0]).toMatchObject({
messageId: 'reply-user-task-1',
taskRefs: [taskRef],
});
expect(sendMessageToMember).not.toHaveBeenCalled();
});
it('repairs OpenCode visible replies that used a wrong relayOfMessageId but returned a messageId', async () => {
const svc = new TeamProvisioningService();
const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' };
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => {
await fsPromises.mkdir(inboxDir, { recursive: true });
await fsPromises.writeFile(
path.join(inboxDir, 'user.json'),
`${JSON.stringify(
[
{
from: 'bob',
to: 'user',
text: 'Here is the concrete answer for #abcd1234.',
timestamp: '2026-04-25T10:00:03.000Z',
read: false,
messageId: 'reply-wrong-relay-1',
relayOfMessageId: 'hallucinated-inbound-id',
source: 'runtime_delivery',
taskRefs: [taskRef],
},
],
null,
2
)}\n`,
'utf8'
);
return {
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'responded_visible_message',
deliveredUserMessageId: 'oc-user-1',
assistantMessageId: 'oc-assistant-1',
toolCallNames: ['message_send'],
visibleMessageToolCallId: 'call-1',
visibleReplyMessageId: 'reply-wrong-relay-1',
visibleReplyCorrelation: 'direct_child_message_send',
visibleReplyMissingRelayOfMessageId: true,
latestAssistantPreview: null,
reason: 'visible_reply_missing_relayOfMessageId',
},
diagnostics: [],
};
});
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
]);
svc.setRuntimeAdapterRegistry(registry);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await writeDefaultBobOpenCodeBootstrapEvidence();
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Please answer for #abcd1234.',
messageId: 'msg-wrong-relay-1',
replyRecipient: 'user',
actionMode: 'ask',
taskRefs: [taskRef],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_visible_message',
visibleReplyMessageId: 'reply-wrong-relay-1',
visibleReplyCorrelation: 'relayOfMessageId',
diagnostics: expect.arrayContaining([
'opencode_visible_reply_recovered_by_observed_message_id',
'opencode_visible_reply_relayOfMessageId_repaired',
]),
});
const userInbox = JSON.parse(
await fsPromises.readFile(path.join(inboxDir, 'user.json'), 'utf8')
) as Array<Record<string, unknown>>;
expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({
messageId: 'reply-wrong-relay-1',
relayOfMessageId: 'msg-wrong-relay-1',
taskRefs: [taskRef],
});
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
});
it('accepts observed visible OpenCode user replies for lead-delegated inbox messages', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -18736,14 +19646,20 @@ describe('TeamProvisioningService', () => {
);
await expect(
(svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () =>
'bob-ok'
(svc as any).runMemberLifecycleOperation(
'same-team',
'bob',
'manual_restart',
async () => 'bob-ok'
)
).resolves.toBe('bob-ok');
await expect(
(svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () =>
'other-ok'
(svc as any).runMemberLifecycleOperation(
'other-team',
'alice',
'manual_restart',
async () => 'other-ok'
)
).resolves.toBe('other-ok');

View file

@ -30,6 +30,7 @@ describe('TeamTaskActivityIntervalService', () => {
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
setClaudeBasePathOverride(null);
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
@ -112,15 +113,319 @@ describe('TeamTaskActivityIntervalService', () => {
]);
});
it('resumes active work and current review intervals for the selected member', async () => {
it('materializes closed intervals for legacy active history timers on pause', async () => {
await writeTask('alpha', {
id: 'task-1',
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:10:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:05:00.000Z',
completedAt: '2026-05-08T10:10:00.000Z',
},
]);
});
it('does not backfill legacy history time once persisted intervals exist', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:20:00.000Z' }],
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:25:00.000Z' }],
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:30:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:20:00.000Z', completedAt: '2026-05-08T10:30:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:25:00.000Z',
completedAt: '2026-05-08T10:30:00.000Z',
},
]);
});
it('backfills the active legacy cycle when only older persisted intervals exist', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
],
historyEvents: [
{
id: 'event-work-started-old',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
{
id: 'event-work-paused-old',
type: 'status_changed',
from: 'in_progress',
to: 'completed',
timestamp: '2026-05-08T10:05:00.000Z',
},
{
id: 'event-work-started-current',
type: 'status_changed',
from: 'completed',
to: 'in_progress',
timestamp: '2026-05-08T10:20:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
reviewIntervals: [
{
reviewer: 'alice',
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T10:05:00.000Z',
},
],
historyEvents: [
{
id: 'event-review-started-old',
type: 'review_started',
timestamp: '2026-05-08T10:00:00.000Z',
actor: 'alice',
},
{
id: 'event-review-approved-old',
type: 'review_approved',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'alice',
},
{
id: 'event-review-started-current',
type: 'review_started',
timestamp: '2026-05-08T10:20:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:30:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z', completedAt: '2026-05-08T10:30:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T10:05:00.000Z',
},
{
reviewer: 'alice',
startedAt: '2026-05-08T10:20:00.000Z',
completedAt: '2026-05-08T10:30:00.000Z',
},
]);
});
it('ignores malformed persisted intervals when materializing legacy history timers', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ completedAt: '2026-05-08T10:01:00.000Z' }],
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
reviewIntervals: [{ startedAt: '2026-05-08T10:04:00.000Z' }],
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:10:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ completedAt: '2026-05-08T10:01:00.000Z' },
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{ startedAt: '2026-05-08T10:04:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
{
reviewer: 'alice',
startedAt: '2026-05-08T10:05:00.000Z',
completedAt: '2026-05-08T10:10:00.000Z',
},
]);
});
it('normalizes invalid completedAt values before renderer filtering can fall back to history', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:02:00.000Z', completedAt: 'bad-date' }],
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
reviewIntervals: [
{ reviewer: 'alice', startedAt: '2026-05-08T10:06:00.000Z', completedAt: 456 },
],
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:10:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:02:00.000Z', completedAt: '2026-05-08T10:02:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:06:00.000Z',
completedAt: '2026-05-08T10:06:00.000Z',
},
]);
});
it('resumes active work and current review intervals for the selected member', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
],
historyEvents: [],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'alice',
status: 'completed',
reviewIntervals: [
{
reviewer: 'bob',
@ -143,14 +448,15 @@ describe('TeamTaskActivityIntervalService', () => {
'bob',
'2026-05-08T10:20:00.000Z'
);
const task = await readTask('alpha', 'task-1');
const workTask = await readTask('alpha', 'work-task');
const reviewTask = await readTask('alpha', 'review-task');
expect(result.changedTasks).toBe(1);
expect(task.workIntervals).toEqual([
expect(result.changedTasks).toBe(2);
expect(workTask.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z' },
]);
expect(task.reviewIntervals).toEqual([
expect(reviewTask.reviewIntervals).toEqual([
{
reviewer: 'bob',
startedAt: '2026-05-08T10:06:00.000Z',
@ -160,6 +466,131 @@ describe('TeamTaskActivityIntervalService', () => {
]);
});
it('does not resume intervals before the active work or review start', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:05:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'alice',
status: 'completed',
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:06:00.000Z',
actor: 'bob',
},
],
});
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:00:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:05:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{ reviewer: 'bob', startedAt: '2026-05-08T10:06:00.000Z' },
]);
});
it('resumes active intervals when existing open-like persisted intervals are malformed', async () => {
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:10:00.000Z', completedAt: '' }],
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'alice',
status: 'completed',
reviewIntervals: [
{ reviewer: 'bob', startedAt: '2026-05-08T10:11:00.000Z', completedAt: 123 },
],
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:05:00.000Z',
actor: 'bob',
},
],
});
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:20:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:10:00.000Z', completedAt: '' },
{ startedAt: '2026-05-08T10:20:00.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{ reviewer: 'bob', startedAt: '2026-05-08T10:11:00.000Z', completedAt: 123 },
{ reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' },
]);
});
it('does not resume review intervals for non-completed tasks with stale review history', async () => {
await writeTask('alpha', {
id: 'task-1',
subject: 'Build',
owner: 'bob',
status: 'pending',
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:06:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
'alpha',
'alice',
'2026-05-08T10:20:00.000Z'
);
const task = await readTask('alpha', 'task-1');
expect(result.changedTasks).toBe(0);
expect(task.reviewIntervals).toBeUndefined();
});
it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
@ -219,6 +650,84 @@ describe('TeamTaskActivityIntervalService', () => {
]);
});
it('repairs legacy active history timers into closed intervals after a crash', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
await writeTask('alpha', {
id: 'work-task',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
historyEvents: [
{
id: 'event-work-started',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-08T10:00:00.000Z',
},
],
});
await writeTask('alpha', {
id: 'review-task',
subject: 'Review',
owner: 'bob',
status: 'completed',
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:10:00.000Z',
actor: 'alice',
},
],
});
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', {
version: 2,
teamName: 'alpha',
updatedAt: '2026-05-08T10:31:00.000Z',
launchPhase: 'active',
expectedMembers: ['bob', 'alice'],
members: {
bob: {
name: 'bob',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
runtimeLastSeenAt: '2026-05-08T10:30:00.000Z',
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
},
alice: {
name: 'alice',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
lastHeartbeatAt: '2026-05-08T10:20:00.000Z',
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
},
},
summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 },
teamLaunchState: 'clean_success',
});
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' },
]);
expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:10:00.000Z',
completedAt: '2026-05-08T10:20:05.000Z',
},
]);
});
it('repairs stale open intervals near their start time when no runtime evidence exists', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
@ -247,4 +756,16 @@ describe('TeamTaskActivityIntervalService', () => {
},
]);
});
it('reports failure when task files cannot be scanned', async () => {
await fs.mkdir(path.join(tempDir, 'tasks'), { recursive: true });
await fs.writeFile(path.join(tempDir, 'tasks', 'alpha'), 'not a directory', 'utf8');
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:10:00.000Z'
);
expect(result).toEqual({ changedTasks: 0, failed: true });
});
});

View file

@ -134,6 +134,28 @@ describe('TeamTaskWriter', () => {
});
});
it('opens a new work interval when the previous completedAt is malformed', async () => {
hoisted.files.set(
taskPath,
JSON.stringify({
id: '12',
subject: 'task',
owner: 'alice',
status: 'pending',
workIntervals: [{ startedAt: '2026-05-02T10:00:00.000Z', completedAt: '' }],
})
);
await writer.updateStatus('my-team', '12', 'in_progress');
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}') as {
workIntervals?: { startedAt?: string; completedAt?: string }[];
};
expect(persisted.workIntervals).toHaveLength(2);
expect(persisted.workIntervals?.[0]?.completedAt).toBe('');
expect(persisted.workIntervals?.[1]?.completedAt).toBeUndefined();
});
it('throws when verify detects conflicting status', async () => {
hoisted.files.set(
taskPath,
@ -217,7 +239,13 @@ describe('TeamTaskWriter', () => {
subject: 'task',
status: 'pending',
historyEvents: [
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' },
{
type: 'task_created',
id: 'ev1',
status: 'pending',
timestamp: '2024-01-01T00:00:00.000Z',
actor: 'user',
},
],
})
);
@ -264,8 +292,19 @@ describe('TeamTaskWriter', () => {
subject: 'task',
status: 'in_progress',
historyEvents: [
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
{ type: 'status_changed', id: 'ev2', from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z' },
{
type: 'task_created',
id: 'ev1',
status: 'pending',
timestamp: '2024-01-01T00:00:00.000Z',
},
{
type: 'status_changed',
id: 'ev2',
from: 'pending',
to: 'in_progress',
timestamp: '2024-01-01T00:01:00.000Z',
},
],
})
);
@ -291,8 +330,19 @@ describe('TeamTaskWriter', () => {
status: 'deleted',
deletedAt: '2024-01-01T00:02:00.000Z',
historyEvents: [
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
{ type: 'status_changed', id: 'ev2', from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z' },
{
type: 'task_created',
id: 'ev1',
status: 'pending',
timestamp: '2024-01-01T00:00:00.000Z',
},
{
type: 'status_changed',
id: 'ev2',
from: 'pending',
to: 'deleted',
timestamp: '2024-01-01T00:02:00.000Z',
},
],
})
);
@ -342,7 +392,12 @@ describe('TeamTaskWriter', () => {
owner: 'alice',
status: 'pending',
historyEvents: [
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
{
type: 'task_created',
id: 'ev1',
status: 'pending',
timestamp: '2024-01-01T00:00:00.000Z',
},
],
})
);

View file

@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import { TeamTaskStallPolicy } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallPolicy';
import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes';
import type {
TeamTaskStallExactRow,
TeamTaskStallSnapshot,
} from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes';
import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
import type { ParsedMessage } from '../../../../../src/main/types';
import type { TeamTask } from '../../../../../src/shared/types';
@ -105,6 +108,33 @@ function createSnapshot(overrides: Partial<TeamTaskStallSnapshot>): TeamTaskStal
describe('TeamTaskStallPolicy', () => {
const policy = new TeamTaskStallPolicy();
it('does not treat malformed empty completedAt as an open work interval', () => {
const task: TeamTask = {
id: 'task-closed-empty',
displayId: 'feed0000',
subject: 'Malformed closed interval',
owner: 'alice',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z', completedAt: '' }],
};
const evaluation = policy.evaluateWork({
now: new Date('2026-04-19T12:30:00.000Z'),
task,
snapshot: createSnapshot({
activeTasks: [task],
allTasksById: new Map([[task.id, task]]),
inProgressTasks: [task],
}),
});
expect(evaluation).toMatchObject({
status: 'skip',
taskId: 'task-closed-empty',
skipReason: 'no_open_work_interval',
});
});
it('alerts for work stall after turn ended and threshold elapsed', () => {
const task: TeamTask = {
id: 'task-a',

View file

@ -326,4 +326,36 @@ describe('selectCurrentActiveTeamTask', () => {
expect(selected?.id).toBe('task-a');
});
it('does not treat malformed empty completedAt as an open work interval', () => {
const tasks: TeamTaskWithKanban[] = [
{
id: 'task-a',
displayId: '1',
subject: 'Malformed closed interval',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-06T13:00:00.000Z', completedAt: '' }],
historyEvents: [
{
id: 'event-a',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-05-06T10:00:00.000Z',
},
],
},
{
id: 'task-b',
displayId: '2',
subject: 'Real active task',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }],
},
];
const selected = selectCurrentActiveTeamTask(tasks);
expect(selected?.id).toBe('task-b');
});
});

View file

@ -10,6 +10,8 @@ import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-lo
import {
type MemberLogStreamRequestOptions,
type MemberLogStreamResponse,
type MemberRuntimeLogTailOptions,
type MemberRuntimeLogTailResponse,
} from '../../../../../src/features/member-log-stream/contracts';
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
@ -42,6 +44,14 @@ const apiState = {
) => Promise<MemberLogStreamResponse>
>(),
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
getMemberRuntimeLogTail:
vi.fn<
(
teamName: string,
memberName: string,
options: MemberRuntimeLogTailOptions
) => Promise<MemberRuntimeLogTailResponse>
>(),
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
};
@ -53,6 +63,9 @@ vi.mock('@renderer/api', () => ({
setMemberLogStreamTracking: (
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
) => apiState.setMemberLogStreamTracking(...args),
getMemberRuntimeLogTail: (
...args: Parameters<typeof apiState.getMemberRuntimeLogTail>
) => apiState.getMemberRuntimeLogTail(...args),
},
teams: {
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
@ -266,6 +279,7 @@ describe('MemberLogStreamSection real fixture e2e', () => {
document.body.innerHTML = '';
apiState.getMemberLogStream.mockReset();
apiState.setMemberLogStreamTracking.mockReset();
apiState.getMemberRuntimeLogTail.mockReset();
apiState.onTeamChange.mockReset();
vi.unstubAllGlobals();
await Promise.all(
@ -280,6 +294,13 @@ describe('MemberLogStreamSection real fixture e2e', () => {
stubMatchMedia();
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
apiState.getMemberRuntimeLogTail.mockResolvedValue({
kind: 'stdout',
content: 'process stdout line',
truncated: false,
bytesRead: 19,
missing: false,
});
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
await createFixtureUseCase();
@ -375,4 +396,75 @@ describe('MemberLogStreamSection real fixture e2e', () => {
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
});
it('loads bounded process runtime logs after switching the Logs UI to Process', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
stubMatchMedia();
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
apiState.getMemberLogStream.mockResolvedValue({
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt: GENERATED_AT,
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
apiState.getMemberRuntimeLogTail.mockResolvedValue({
kind: 'stdout',
content: 'process stdout line',
truncated: false,
bytesRead: 19,
missing: false,
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(MemberLogStreamSection, {
teamName: TEAM_NAME,
member: createMember(),
})
)
);
await flushMicrotasks();
});
const processButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Process'
) as HTMLButtonElement | undefined;
expect(processButton).toBeTruthy();
await act(async () => {
processButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flushAsyncWork();
});
await waitForText(host, (content) => content.includes('process stdout line'));
expect(apiState.getMemberRuntimeLogTail).toHaveBeenCalledWith(TEAM_NAME, MEMBER_NAME, {
kind: 'stdout',
maxBytes: 128 * 1024,
forceRefresh: true,
});
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { filterChunksByWorkIntervals } from '@renderer/components/team/members/MemberLogsTab';
function makeChunk(id: string, start: string, end: string) {
return {
id,
startTime: new Date(start),
endTime: new Date(end),
} as never;
}
describe('MemberLogsTab work interval filtering', () => {
afterEach(() => {
vi.useRealTimers();
});
it('does not treat malformed empty completedAt as an open interval', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z'));
const chunks = [
makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'),
makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'),
];
const filtered = filterChunksByWorkIntervals(chunks, [
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' },
]);
expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start']);
});
it('clamps completedAt before startedAt to a closed start window', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z'));
const chunks = [
makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'),
makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'),
];
const filtered = filterChunksByWorkIntervals(chunks, [
{
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T09:59:00.000Z',
},
]);
expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start']);
});
it('keeps undefined completedAt as the only open interval shape', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z'));
const chunks = [
makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'),
makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'),
];
const filtered = filterChunksByWorkIntervals(chunks, [
{ startedAt: '2026-05-08T10:00:00.000Z' },
]);
expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start', 'late']);
});
});

View file

@ -116,6 +116,36 @@ describe('memberActivityTimer', () => {
).toBeNull();
});
it('does not treat invalid empty completedAt values as active work or review intervals', () => {
const workTask: TeamTaskWithKanban = {
...baseTask,
workIntervals: [{ startedAt: '2026-05-07T09:10:00.000Z', completedAt: '' }],
};
expect(
deriveWorkActivityTimerAnchor(workTask, {
teamName: 'alpha',
memberName: 'bob',
})
).toBeNull();
const reviewTask: TeamTaskWithKanban = {
...baseTask,
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'alice',
reviewIntervals: [
{ reviewer: 'alice', startedAt: '2026-05-07T09:30:00.000Z', completedAt: '' },
],
};
expect(
deriveReviewActivityTimerAnchor(reviewTask, {
teamName: 'alpha',
memberName: 'alice',
})
).toBeNull();
});
it('anchors review timers only after the reviewer actually starts review', () => {
const assignedOnly: TeamTaskWithKanban = {
...baseTask,

View file

@ -864,6 +864,23 @@ describe('memberHelpers spawn-aware presence', () => {
expect(title).not.toContain('non_visible_tool_without_task_progress');
});
it('formats missing taskRefs advisory reasons before showing them in titles', () => {
const title = getMemberRuntimeAdvisoryTitle(
{
kind: 'api_error',
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'protocol_proof_missing',
message: 'visible_reply_missing_task_refs',
},
'opencode'
);
expect(title).toContain(
'OpenCode created a reply without the required taskRefs metadata.'
);
expect(title).not.toContain('visible_reply_missing_task_refs');
});
it('renders Codex native timeout separately from network errors', () => {
const advisory = {
kind: 'api_error' as const,

View file

@ -98,4 +98,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode used tools, but did not create a visible reply or task progress proof.'
);
});
it('surfaces missing taskRefs proof as a readable failure', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-taskrefs-required',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'responded_visible_message',
ledgerStatus: 'failed_terminal',
reason: 'visible_reply_missing_task_refs',
diagnostics: ['visible_reply_missing_task_refs'],
},
});
expect(diagnostics.warning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode created a reply without the required taskRefs metadata.'
);
});
});

View file

@ -53,6 +53,39 @@ describe('taskWorkDuration', () => {
});
});
it('does not treat empty completedAt strings as running implementation time', () => {
const duration = calculateTaskImplementationDuration(
{
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' }],
},
Date.parse('2026-05-08T10:30:00.000Z')
);
expect(duration).toEqual({
elapsedMs: 0,
hasRunningInterval: false,
countedIntervalCount: 0,
});
expect(
calculateTaskImplementationEventDuration(
{
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' }],
},
{
id: 'event-started',
timestamp: '2026-05-08T10:00:00.000Z',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
},
Date.parse('2026-05-08T10:30:00.000Z')
)
).toBeNull();
});
it('merges overlapping intervals to avoid double counting malformed data', () => {
const duration = calculateTaskImplementationDuration(
{