feat(team): harden runtime delivery and diagnostics
This commit is contained in:
parent
88b3ea2358
commit
80acc3b663
66 changed files with 5085 additions and 312 deletions
|
|
@ -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) };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -1044,6 +1044,8 @@ async function handleGetData(
|
|||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
|
||||
await getTeamProvisioningService().repairStaleTaskActivityIntervalsBeforeSnapshot?.(tn);
|
||||
|
||||
if (workerAvailable) {
|
||||
try {
|
||||
data =
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() : '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()))];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
65
test/renderer/components/team/members/MemberLogsTab.test.ts
Normal file
65
test/renderer/components/team/members/MemberLogsTab.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue