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;
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenReviewInterval(interval) {
|
||||||
|
return interval && interval.completedAt === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
|
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
|
||||||
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
|
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
|
||||||
if (!reviewerName) return false;
|
if (!reviewerName) return false;
|
||||||
|
|
@ -83,7 +87,7 @@ function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()
|
||||||
let changed = false;
|
let changed = false;
|
||||||
let hasOpenForReviewer = false;
|
let hasOpenForReviewer = false;
|
||||||
const nextIntervals = intervals.map((interval) => {
|
const nextIntervals = intervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (!isOpenReviewInterval(interval)) return interval;
|
||||||
if (normalizeActorKey(interval.reviewer) === reviewerKey) {
|
if (normalizeActorKey(interval.reviewer) === reviewerKey) {
|
||||||
hasOpenForReviewer = true;
|
hasOpenForReviewer = true;
|
||||||
return interval;
|
return interval;
|
||||||
|
|
@ -103,7 +107,7 @@ function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
|
||||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (!isOpenReviewInterval(interval)) return interval;
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) };
|
return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -201,13 +201,23 @@ function appendHistoryEvent(events, event) {
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOpenReviewInterval(interval) {
|
||||||
|
return interval && interval.completedAt === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function closeOpenReviewIntervals(task, timestamp) {
|
function closeOpenReviewIntervals(task, timestamp) {
|
||||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (!isOpenReviewInterval(interval)) return interval;
|
||||||
changed = true;
|
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;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +476,7 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
||||||
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
||||||
|
|
||||||
if (task.status !== 'in_progress' && status === 'in_progress') {
|
if (task.status !== 'in_progress' && status === 'in_progress') {
|
||||||
if (!lastInterval || typeof lastInterval.completedAt === 'string') {
|
if (!lastInterval || lastInterval.completedAt !== undefined) {
|
||||||
workIntervals.push({ startedAt: timestamp });
|
workIntervals.push({ startedAt: timestamp });
|
||||||
}
|
}
|
||||||
} else if (task.status === 'in_progress' && status !== 'in_progress') {
|
} 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']);
|
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', () => {
|
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -851,6 +868,30 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
|
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 () => {
|
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -2212,13 +2253,22 @@ describe('agent-teams-controller API', () => {
|
||||||
to: 'review',
|
to: 'review',
|
||||||
actor: 'carol',
|
actor: 'carol',
|
||||||
});
|
});
|
||||||
|
rawTask.reviewIntervals = [{ reviewer: 'carol', startedAt: '2026-01-01T00:00:00.000Z' }];
|
||||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||||
|
|
||||||
controller.review.startReview(task.id);
|
controller.review.startReview(task.id);
|
||||||
const startedEvents = controller.tasks
|
const repairedTask = controller.tasks.getTask(task.id);
|
||||||
.getTask(task.id)
|
const startedEvents = repairedTask.historyEvents.filter(
|
||||||
.historyEvents.filter((event) => event.type === 'review_started');
|
(event) => event.type === 'review_started'
|
||||||
|
);
|
||||||
expect(startedEvents.at(-1).actor).toBe('alice');
|
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 aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||||
const carolBriefing = await controller.tasks.taskBriefing('carol');
|
const carolBriefing = await controller.tasks.taskBriefing('carol');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type {
|
||||||
MemberLogPreviewResponse,
|
MemberLogPreviewResponse,
|
||||||
MemberLogStreamRequestOptions,
|
MemberLogStreamRequestOptions,
|
||||||
MemberLogStreamResponse,
|
MemberLogStreamResponse,
|
||||||
|
MemberRuntimeLogTailOptions,
|
||||||
|
MemberRuntimeLogTailResponse,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
export interface MemberLogStreamApi {
|
export interface MemberLogStreamApi {
|
||||||
|
|
@ -16,5 +18,10 @@ export interface MemberLogStreamApi {
|
||||||
memberNames: string[],
|
memberNames: string[],
|
||||||
options?: MemberLogPreviewRequestOptions
|
options?: MemberLogPreviewRequestOptions
|
||||||
): Promise<MemberLogPreviewResponse>;
|
): Promise<MemberLogPreviewResponse>;
|
||||||
|
getMemberRuntimeLogTail(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
options: MemberRuntimeLogTailOptions
|
||||||
|
): Promise<MemberRuntimeLogTailResponse>;
|
||||||
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
|
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 = 'member-log-stream:getMemberLogStream';
|
||||||
export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews';
|
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_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[];
|
members: MemberLogPreviewMember[];
|
||||||
generatedAt: string;
|
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,
|
MemberLogPreviewMember,
|
||||||
MemberLogPreviewResponse,
|
MemberLogPreviewResponse,
|
||||||
MemberLogStreamResponse,
|
MemberLogStreamResponse,
|
||||||
|
MemberRuntimeLogKind,
|
||||||
|
MemberRuntimeLogTailResponse,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
export function createEmptyMemberLogStreamResponse(
|
export function createEmptyMemberLogStreamResponse(
|
||||||
|
|
@ -91,3 +93,52 @@ export function normalizeMemberLogPreviewResponse(
|
||||||
: new Date().toISOString(),
|
: 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 {
|
import {
|
||||||
MEMBER_LOG_STREAM_GET,
|
MEMBER_LOG_STREAM_GET,
|
||||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||||
|
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
|
||||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||||
} from '../../../../../contracts';
|
} from '../../../../../contracts';
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,6 +51,16 @@ function emptyPreviewResponse(): MemberLogPreviewResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyRuntimeLogTailResponse() {
|
||||||
|
return {
|
||||||
|
kind: 'stdout' as const,
|
||||||
|
content: '',
|
||||||
|
truncated: false,
|
||||||
|
bytesRead: 0,
|
||||||
|
missing: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createFakeIpcMain(): {
|
function createFakeIpcMain(): {
|
||||||
handlers: Map<string, (...args: unknown[]) => unknown>;
|
handlers: Map<string, (...args: unknown[]) => unknown>;
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
|
|
@ -78,6 +89,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream,
|
getMemberLogStream,
|
||||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking: vi.fn(),
|
setMemberLogStreamTracking: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,6 +123,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream,
|
getMemberLogStream,
|
||||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking: vi.fn(),
|
setMemberLogStreamTracking: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -138,6 +151,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream,
|
getMemberLogStream,
|
||||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking: vi.fn(),
|
setMemberLogStreamTracking: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -187,6 +201,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking,
|
setMemberLogStreamTracking,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,6 +221,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
|
|
||||||
expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false);
|
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_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);
|
expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -215,6 +231,7 @@ describe('registerMemberLogStreamIpc', () => {
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||||
getMemberLogPreviews,
|
getMemberLogPreviews,
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking: vi.fn(),
|
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 () => {
|
it('rejects unknown batch preview options and unsafe lane maps', async () => {
|
||||||
const { handlers, ipcMain } = createFakeIpcMain();
|
const { handlers, ipcMain } = createFakeIpcMain();
|
||||||
const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse());
|
const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse());
|
||||||
const feature: MemberLogStreamFeatureFacade = {
|
const feature: MemberLogStreamFeatureFacade = {
|
||||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||||
getMemberLogPreviews,
|
getMemberLogPreviews,
|
||||||
|
getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()),
|
||||||
setMemberLogStreamTracking: vi.fn(),
|
setMemberLogStreamTracking: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import {
|
import {
|
||||||
MEMBER_LOG_STREAM_GET,
|
MEMBER_LOG_STREAM_GET,
|
||||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||||
|
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
|
||||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||||
normalizeMemberLogPreviewResponse,
|
normalizeMemberLogPreviewResponse,
|
||||||
normalizeMemberLogStreamResponse,
|
normalizeMemberLogStreamResponse,
|
||||||
|
normalizeMemberRuntimeLogTailResponse,
|
||||||
} from '../../../../contracts';
|
} from '../../../../contracts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,6 +16,8 @@ import type {
|
||||||
MemberLogPreviewResponse,
|
MemberLogPreviewResponse,
|
||||||
MemberLogStreamRequestOptions,
|
MemberLogStreamRequestOptions,
|
||||||
MemberLogStreamResponse,
|
MemberLogStreamResponse,
|
||||||
|
MemberRuntimeLogTailOptions,
|
||||||
|
MemberRuntimeLogTailResponse,
|
||||||
} from '../../../../contracts';
|
} from '../../../../contracts';
|
||||||
import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature';
|
import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature';
|
||||||
import type { IpcResult } from '@shared/types';
|
import type { IpcResult } from '@shared/types';
|
||||||
|
|
@ -27,6 +31,8 @@ const ALLOWED_PREVIEW_OPTION_KEYS = new Set([
|
||||||
'laneIdsByMember',
|
'laneIdsByMember',
|
||||||
'forceRefresh',
|
'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> {
|
interface ValidationResult<T> {
|
||||||
valid: boolean;
|
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(
|
export function registerMemberLogStreamIpc(
|
||||||
ipcMain: IpcMain,
|
ipcMain: IpcMain,
|
||||||
feature: MemberLogStreamFeatureFacade
|
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 {
|
export function removeMemberLogStreamIpc(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
|
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
|
||||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS);
|
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS);
|
||||||
|
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL);
|
||||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING);
|
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 {
|
import {
|
||||||
createEmptyMemberLogPreviewResponse,
|
createEmptyMemberLogPreviewResponse,
|
||||||
createEmptyMemberLogStreamResponse,
|
createEmptyMemberLogStreamResponse,
|
||||||
|
createEmptyMemberRuntimeLogTailResponse,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
|
import { MemberRuntimeLogTailReader } from '../application/MemberRuntimeLogTailReader';
|
||||||
import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
||||||
import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
|
import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
|
||||||
import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase';
|
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 { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
|
||||||
import { isMemberLogStreamReadEnabled } from '../featureGates';
|
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 { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||||
import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort';
|
import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort';
|
||||||
import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
||||||
|
|
@ -29,6 +36,11 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin
|
||||||
export interface MemberLogStreamFeatureFacade {
|
export interface MemberLogStreamFeatureFacade {
|
||||||
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
|
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
|
||||||
getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse>;
|
getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse>;
|
||||||
|
getMemberRuntimeLogTail(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
options: MemberRuntimeLogTailOptions;
|
||||||
|
}): Promise<MemberRuntimeLogTailResponse>;
|
||||||
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
|
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,11 +61,13 @@ export function createMemberLogStreamFeature(deps: {
|
||||||
logSourceTracker: TeamLogSourceTracker;
|
logSourceTracker: TeamLogSourceTracker;
|
||||||
runtimeBridge: ClaudeMultimodelBridgeService;
|
runtimeBridge: ClaudeMultimodelBridgeService;
|
||||||
configReader?: TeamConfigReader;
|
configReader?: TeamConfigReader;
|
||||||
|
runtimeLogTailReader?: MemberRuntimeLogTailReader;
|
||||||
logger: LoggerPort;
|
logger: LoggerPort;
|
||||||
}): MemberLogStreamFeatureFacade {
|
}): MemberLogStreamFeatureFacade {
|
||||||
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
|
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
|
||||||
const strictParser = new BoardTaskExactLogStrictParser();
|
const strictParser = new BoardTaskExactLogStrictParser();
|
||||||
const configReader = deps.configReader ?? new TeamConfigReader();
|
const configReader = deps.configReader ?? new TeamConfigReader();
|
||||||
|
const runtimeLogTailReader = deps.runtimeLogTailReader ?? new MemberRuntimeLogTailReader();
|
||||||
const sources = [
|
const sources = [
|
||||||
new ClaudeMemberTranscriptStreamSource(
|
new ClaudeMemberTranscriptStreamSource(
|
||||||
deps.logsFinder,
|
deps.logsFinder,
|
||||||
|
|
@ -96,6 +110,17 @@ export function createMemberLogStreamFeature(deps: {
|
||||||
}
|
}
|
||||||
return getPreviewsUseCase.execute(input);
|
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),
|
setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
MEMBER_LOG_STREAM_GET,
|
MEMBER_LOG_STREAM_GET,
|
||||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||||
|
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
|
||||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge';
|
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 {
|
import {
|
||||||
MEMBER_LOG_STREAM_GET,
|
MEMBER_LOG_STREAM_GET,
|
||||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||||
|
MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL,
|
||||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||||
normalizeMemberLogPreviewResponse,
|
normalizeMemberLogPreviewResponse,
|
||||||
normalizeMemberLogStreamResponse,
|
normalizeMemberLogStreamResponse,
|
||||||
|
normalizeMemberRuntimeLogTailResponse,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,6 +16,8 @@ import type {
|
||||||
MemberLogStreamApi,
|
MemberLogStreamApi,
|
||||||
MemberLogStreamRequestOptions,
|
MemberLogStreamRequestOptions,
|
||||||
MemberLogStreamResponse,
|
MemberLogStreamResponse,
|
||||||
|
MemberRuntimeLogTailOptions,
|
||||||
|
MemberRuntimeLogTailResponse,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
import type { IpcResult } from '@shared/types';
|
import type { IpcResult } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -53,6 +57,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi {
|
||||||
options
|
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> =>
|
setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise<void> =>
|
||||||
invokeIpcWithResult<void>(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled),
|
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 { useStore } from '@renderer/store';
|
||||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
|
|
||||||
import { useMemberLogStream } from '../hooks/useMemberLogStream';
|
import { useMemberLogStream } from '../hooks/useMemberLogStream';
|
||||||
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
|
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
|
||||||
|
import { MemberRuntimeProcessLogsPanel } from '../ui/MemberRuntimeProcessLogsPanel';
|
||||||
|
|
||||||
import type { MemberLogStreamSegment } from '../../contracts';
|
import type { MemberLogStreamSegment } from '../../contracts';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -41,6 +42,7 @@ export function MemberLogStreamSection({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onInitialLoadErrorChange,
|
onInitialLoadErrorChange,
|
||||||
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
|
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
|
||||||
|
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
|
||||||
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||||
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
|
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
|
||||||
const hasInitialLoadError = Boolean(error && !stream && !loading);
|
const hasInitialLoadError = Boolean(error && !stream && !loading);
|
||||||
|
|
@ -57,22 +59,57 @@ export function MemberLogStreamSection({
|
||||||
}, [hasInitialLoadError, onInitialLoadErrorChange]);
|
}, [hasInitialLoadError, onInitialLoadErrorChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExecutionLogStreamView
|
<div className="space-y-4">
|
||||||
title="Logs"
|
<div className="inline-flex rounded-xl bg-[var(--color-surface-subtle)] p-1">
|
||||||
description={describeMemberStream()}
|
<button
|
||||||
stream={stream}
|
type="button"
|
||||||
loading={loading}
|
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
error={error}
|
selectedLogView === 'execution'
|
||||||
teamName={teamName}
|
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
|
||||||
teamMembers={teamMembers}
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||||
loadingText="Loading member log stream..."
|
}`}
|
||||||
emptyTitle="No log stream entries were found for this member yet."
|
onClick={() => setSelectedLogView('execution')}
|
||||||
emptyDescription="Member-scoped transcript or runtime logs will appear here when available."
|
>
|
||||||
selectionResetKey={`${teamName}:${member.name}`}
|
Execution
|
||||||
boundedHistoryNote={boundedHistoryNote}
|
</button>
|
||||||
forceSegmentHeaders
|
<button
|
||||||
buildSegmentRenderKey={buildMemberSegmentRenderKey}
|
type="button"
|
||||||
getSegmentMetaLabel={getSegmentMetaLabel}
|
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));
|
return reply.send(await getTeamDataService(services).getTeamData(teamName));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (shouldLogError(error)) {
|
if (shouldLogError(error)) {
|
||||||
|
|
|
||||||
|
|
@ -1044,6 +1044,8 @@ async function handleGetData(
|
||||||
return { success: false, error: 'TEAM_DRAFT' };
|
return { success: false, error: 'TEAM_DRAFT' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await getTeamProvisioningService().repairStaleTaskActivityIntervalsBeforeSnapshot?.(tn);
|
||||||
|
|
||||||
if (workerAvailable) {
|
if (workerAvailable) {
|
||||||
try {
|
try {
|
||||||
data =
|
data =
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,12 @@ export class TaskChangeComputer {
|
||||||
if (!Number.isFinite(startMs)) continue;
|
if (!Number.isFinite(startMs)) continue;
|
||||||
const endMsRaw =
|
const endMsRaw =
|
||||||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
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({
|
normalized.push({
|
||||||
startMs,
|
startMs,
|
||||||
endMs,
|
endMs,
|
||||||
|
|
@ -192,7 +197,13 @@ export class TaskChangeComputer {
|
||||||
const startTimestamp = normalized[0]?.startedAt ?? '';
|
const startTimestamp = normalized[0]?.startedAt ?? '';
|
||||||
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
|
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
|
||||||
(acc, item) => {
|
(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) {
|
if (!acc || item.endMs > acc.endMs) {
|
||||||
return { endMs: item.endMs, endTimestamp: item.completedAt };
|
return { endMs: item.endMs, endTimestamp: item.completedAt };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,35 @@ import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { withFileLock } from './fileLock';
|
import { withFileLock } from './fileLock';
|
||||||
import { withInboxLock } from './inboxLock';
|
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 {
|
export class TeamInboxWriter {
|
||||||
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
||||||
|
|
@ -54,10 +82,34 @@ export class TeamInboxWriter {
|
||||||
await withInboxLock(inboxPath, async () => {
|
await withInboxLock(inboxPath, async () => {
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const list = await this.readInbox(inboxPath);
|
const list = await this.readInbox(inboxPath);
|
||||||
const duplicate = this.findRuntimeDeliveryDuplicate(list, payload);
|
const duplicateIndex = this.findRuntimeDeliveryDuplicateIndex(list, payload);
|
||||||
if (duplicate) {
|
if (duplicateIndex >= 0) {
|
||||||
|
const duplicate = list[duplicateIndex];
|
||||||
|
const merged = this.mergeTaskRefs(duplicate.taskRefs, payload.taskRefs);
|
||||||
resultMessageId = duplicate.messageId ?? messageId;
|
resultMessageId = duplicate.messageId ?? messageId;
|
||||||
resultDeduplicated = true;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
list.push(payload);
|
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[],
|
messages: readonly InboxMessage[],
|
||||||
payload: InboxMessage
|
payload: InboxMessage
|
||||||
): InboxMessage | null {
|
): number {
|
||||||
if (
|
if (
|
||||||
payload.source !== 'runtime_delivery' ||
|
payload.source !== 'runtime_delivery' ||
|
||||||
typeof payload.relayOfMessageId !== 'string' ||
|
typeof payload.relayOfMessageId !== 'string' ||
|
||||||
payload.relayOfMessageId.trim().length === 0
|
payload.relayOfMessageId.trim().length === 0
|
||||||
) {
|
) {
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayOfMessageId = payload.relayOfMessageId.trim();
|
const relayOfMessageId = payload.relayOfMessageId.trim();
|
||||||
|
|
@ -96,21 +320,83 @@ export class TeamInboxWriter {
|
||||||
const to = this.normalizeComparableParticipant(payload.to);
|
const to = this.normalizeComparableParticipant(payload.to);
|
||||||
const text = this.normalizeComparableText(payload.text);
|
const text = this.normalizeComparableText(payload.text);
|
||||||
if (!from || !to || !text) {
|
if (!from || !to || !text) {
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return messages.findIndex(
|
||||||
messages.find(
|
(candidate) =>
|
||||||
(candidate) =>
|
candidate.source === 'runtime_delivery' &&
|
||||||
candidate.source === 'runtime_delivery' &&
|
(candidate.relayOfMessageId ?? '').trim() === relayOfMessageId &&
|
||||||
(candidate.relayOfMessageId ?? '').trim() === relayOfMessageId &&
|
this.normalizeComparableParticipant(candidate.from) === from &&
|
||||||
this.normalizeComparableParticipant(candidate.from) === from &&
|
this.normalizeComparableParticipant(candidate.to) === to &&
|
||||||
this.normalizeComparableParticipant(candidate.to) === to &&
|
this.normalizeComparableText(candidate.text) === text
|
||||||
this.normalizeComparableText(candidate.text) === text
|
|
||||||
) ?? null
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private normalizeComparableParticipant(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -499,7 +499,12 @@ export class TeamMemberLogsFinder {
|
||||||
const startMs = Date.parse(i.startedAt);
|
const startMs = Date.parse(i.startedAt);
|
||||||
const endMsRaw =
|
const endMsRaw =
|
||||||
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
|
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;
|
return Number.isFinite(startMs) ? { startMs, endMs } : null;
|
||||||
})
|
})
|
||||||
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
||||||
|
|
@ -516,12 +521,12 @@ export class TeamMemberLogsFinder {
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const filteredOwnerLogs = ownerLogs.filter((log) => {
|
const filteredOwnerLogs = ownerLogs.filter((log) => {
|
||||||
if (log.isOngoing) return true;
|
|
||||||
const startMs = new Date(log.startTime).getTime();
|
const startMs = new Date(log.startTime).getTime();
|
||||||
if (!Number.isFinite(startMs)) return false;
|
if (!Number.isFinite(startMs)) return false;
|
||||||
const durationMs =
|
const durationMs =
|
||||||
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
|
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) {
|
if (effectiveIntervals.length > 0) {
|
||||||
return this.logOverlapsIntervals(
|
return this.logOverlapsIntervals(
|
||||||
|
|
@ -533,6 +538,7 @@ export class TeamMemberLogsFinder {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (log.isOngoing) return true;
|
||||||
return startMs >= now - fallbackRecentMs;
|
return startMs >= now - fallbackRecentMs;
|
||||||
});
|
});
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
@ -740,7 +746,12 @@ export class TeamMemberLogsFinder {
|
||||||
const startMs = Date.parse(i.startedAt);
|
const startMs = Date.parse(i.startedAt);
|
||||||
const endMsRaw =
|
const endMsRaw =
|
||||||
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
|
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;
|
return Number.isFinite(startMs) ? { startMs, endMs } : null;
|
||||||
})
|
})
|
||||||
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
||||||
|
|
@ -757,28 +768,27 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
for (const log of ownerLogs) {
|
for (const log of ownerLogs) {
|
||||||
if (!log.filePath) continue;
|
if (!log.filePath) continue;
|
||||||
if (!log.isOngoing) {
|
const startMs = new Date(log.startTime).getTime();
|
||||||
const startMs = new Date(log.startTime).getTime();
|
if (!Number.isFinite(startMs)) continue;
|
||||||
if (!Number.isFinite(startMs)) continue;
|
const durationMs =
|
||||||
const durationMs =
|
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
|
||||||
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
|
const rawEndMs = startMs + durationMs;
|
||||||
const endMs = startMs + durationMs;
|
const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs;
|
||||||
|
|
||||||
if (effectiveIntervals.length > 0) {
|
if (effectiveIntervals.length > 0) {
|
||||||
if (
|
if (
|
||||||
!this.logOverlapsIntervals(
|
!this.logOverlapsIntervals(
|
||||||
startMs,
|
startMs,
|
||||||
endMs,
|
endMs,
|
||||||
effectiveIntervals,
|
effectiveIntervals,
|
||||||
now,
|
now,
|
||||||
TASK_LOG_INTERVAL_GRACE_MS
|
TASK_LOG_INTERVAL_GRACE_MS
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (startMs < now - fallbackRecentMs) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
} else if (!log.isOngoing && startMs < now - fallbackRecentMs) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushRef(
|
pushRef(
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,14 @@ const PROTOCOL_PROOF_MISSING_TOKENS = [
|
||||||
'plain_text_ack_only_still_requires_answer',
|
'plain_text_ack_only_still_requires_answer',
|
||||||
'visible_reply_destination_not_found_yet',
|
'visible_reply_destination_not_found_yet',
|
||||||
'visible_reply_missing_relayofmessageid',
|
'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 reply',
|
||||||
'did not create a visible message_send reply',
|
'did not create a visible message_send reply',
|
||||||
'did not create a visible reply or task progress proof',
|
'did not create a visible reply or task progress proof',
|
||||||
'without the required relayofmessageid correlation',
|
'without the required relayofmessageid correlation',
|
||||||
|
'without the required taskrefs metadata',
|
||||||
];
|
];
|
||||||
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
|
|
||||||
interface ActivityIntervalResult {
|
interface ActivityIntervalResult {
|
||||||
changedTasks: number;
|
changedTasks: number;
|
||||||
|
failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MutableTeamTask = TeamTask & {
|
type MutableTeamTask = TeamTask & {
|
||||||
|
|
@ -38,6 +39,14 @@ function toIso(ms: number): string {
|
||||||
return new Date(ms).toISOString();
|
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 {
|
function ensureCloseIso(startedAt: string, at: string): string {
|
||||||
const startedAtMs = parseIsoMs(startedAt);
|
const startedAtMs = parseIsoMs(startedAt);
|
||||||
const atMs = parseIsoMs(at);
|
const atMs = parseIsoMs(at);
|
||||||
|
|
@ -46,6 +55,32 @@ function ensureCloseIso(startedAt: string, at: string): string {
|
||||||
return toIso(atMs);
|
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 {
|
function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string {
|
||||||
const startedAtMs = parseIsoMs(startedAt);
|
const startedAtMs = parseIsoMs(startedAt);
|
||||||
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
|
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
|
||||||
|
|
@ -62,10 +97,23 @@ function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemb
|
||||||
return toIso(boundedCloseMs);
|
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 {
|
function hasOpenWorkInterval(task: MutableTeamTask): boolean {
|
||||||
return (
|
return (
|
||||||
Array.isArray(task.workIntervals) &&
|
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 (
|
return (
|
||||||
Array.isArray(task.reviewIntervals) &&
|
Array.isArray(task.reviewIntervals) &&
|
||||||
task.reviewIntervals.some(
|
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;
|
let changed = false;
|
||||||
task.workIntervals = task.workIntervals.map((interval) => {
|
task.workIntervals = task.workIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (isClosedInterval(interval)) return interval;
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
return { ...interval, completedAt: pauseCloseIso(interval, at) };
|
||||||
});
|
});
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
@ -98,20 +149,45 @@ function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?:
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (isClosedInterval(interval)) return interval;
|
||||||
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
|
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
return { ...interval, completedAt: pauseCloseIso(interval, at) };
|
||||||
});
|
});
|
||||||
return changed;
|
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 : [];
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||||
const event = events[index];
|
const event = events[index];
|
||||||
if (event.type === 'review_started') {
|
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 (
|
if (
|
||||||
event.type === 'review_approved' ||
|
event.type === 'review_approved' ||
|
||||||
|
|
@ -126,6 +202,110 @@ function getActiveReviewActor(task: MutableTeamTask): string | null {
|
||||||
return 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 {
|
function readTaskFile(filePath: string): MutableTeamTask | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
||||||
|
|
@ -155,7 +335,7 @@ export class TeamTaskActivityIntervalService {
|
||||||
error instanceof Error ? error.message : String(error)
|
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[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = fs.readdirSync(tasksDir);
|
entries = fs.readdirSync(tasksDir);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return { changedTasks: 0 };
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return { changedTasks: 0 };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let changedTasks = 0;
|
let changedTasks = 0;
|
||||||
|
|
@ -195,7 +378,9 @@ export class TeamTaskActivityIntervalService {
|
||||||
return this.mutateTeamTasks(teamName, (task) => {
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
const changedWork = closeOpenWorkIntervals(task, at);
|
const changedWork = closeOpenWorkIntervals(task, at);
|
||||||
const changedReview = closeOpenReviewIntervals(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) => {
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
||||||
const changedReview = closeOpenReviewIntervals(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 &&
|
normalizeMemberName(task.owner) === memberKey &&
|
||||||
!hasOpenWorkInterval(task)
|
!hasOpenWorkInterval(task)
|
||||||
) {
|
) {
|
||||||
|
const activeStartedAt = getActiveWorkStartedAt(task);
|
||||||
task.workIntervals = [
|
task.workIntervals = [
|
||||||
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||||
{ startedAt: at },
|
{ startedAt: resumeStartIso(activeStartedAt, at) },
|
||||||
];
|
];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeReviewer = getActiveReviewActor(task);
|
const activeReview = getActiveReviewStart(task);
|
||||||
if (
|
if (
|
||||||
task.status === 'completed' &&
|
task.status === 'completed' &&
|
||||||
activeReviewer &&
|
activeReview &&
|
||||||
normalizeMemberName(activeReviewer) === memberKey &&
|
normalizeMemberName(activeReview.reviewer) === memberKey &&
|
||||||
!hasOpenReviewInterval(task, activeReviewer)
|
!hasOpenReviewInterval(task, activeReview.reviewer)
|
||||||
) {
|
) {
|
||||||
task.reviewIntervals = [
|
task.reviewIntervals = [
|
||||||
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||||
{ reviewer: activeReviewer, startedAt: at },
|
{
|
||||||
|
reviewer: activeReview.reviewer,
|
||||||
|
startedAt: resumeStartIso(activeReview.startedAt, at),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
@ -266,23 +457,57 @@ export class TeamTaskActivityIntervalService {
|
||||||
if (Array.isArray(task.workIntervals)) {
|
if (Array.isArray(task.workIntervals)) {
|
||||||
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
|
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
|
||||||
task.workIntervals = task.workIntervals.map((interval) => {
|
task.workIntervals = task.workIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (isClosedInterval(interval)) return interval;
|
||||||
changed = true;
|
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)) {
|
if (Array.isArray(task.reviewIntervals)) {
|
||||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
if (interval.completedAt) return interval;
|
if (isClosedInterval(interval)) return interval;
|
||||||
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
|
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
|
||||||
changed = true;
|
changed = true;
|
||||||
return {
|
return {
|
||||||
...interval,
|
...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;
|
return changed;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
if (!wasInProgress && isInProgress) {
|
if (!wasInProgress && isInProgress) {
|
||||||
// Entering in_progress: open a new interval if none is open.
|
// 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 });
|
intervals.push({ startedAt: nowIso });
|
||||||
}
|
}
|
||||||
} else if (wasInProgress && !isInProgress) {
|
} 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
|
// Longer than the renderer-facing UI timeout: late OpenCode turns should still
|
||||||
// finish bridge-side observation and emit member-work-sync signals.
|
// finish bridge-side observation and emit member-work-sync signals.
|
||||||
const DEFAULT_SEND_TIMEOUT_MS = 45_000;
|
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_STOP_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
||||||
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
|
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,7 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
visibleReplyCorrelation: input.visibleReplyCorrelation,
|
visibleReplyCorrelation: input.visibleReplyCorrelation,
|
||||||
lastReason: input.semanticallySufficient
|
lastReason: input.semanticallySufficient
|
||||||
? record.lastReason
|
? record.lastReason
|
||||||
: 'visible_reply_ack_only_still_requires_answer',
|
: selectOpenCodeDestinationProofInsufficientReason(input.diagnostics),
|
||||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||||
updatedAt: input.observedAt,
|
updatedAt: input.observedAt,
|
||||||
}));
|
}));
|
||||||
|
|
@ -874,6 +874,22 @@ function shouldPruneOpenCodePromptDeliveryRecord(
|
||||||
return false;
|
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[] {
|
function mergeDiagnostics(existing: string[], next: string[]): string[] {
|
||||||
return [...new Set([...existing, ...next].filter((item) => item.trim()))];
|
return [...new Set([...existing, ...next].filter((item) => item.trim()))];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
|
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
|
||||||
import type {
|
import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger';
|
||||||
OpenCodePromptDeliveryLedgerRecord,
|
|
||||||
OpenCodePromptDeliveryStatus,
|
|
||||||
} from './OpenCodePromptDeliveryLedger';
|
|
||||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||||
|
|
||||||
export type OpenCodePromptDeliveryRepairKind =
|
export type OpenCodePromptDeliveryRepairKind =
|
||||||
|
|
@ -128,12 +125,14 @@ function taskIdList(taskRefs: TaskRef[]): string | null {
|
||||||
|
|
||||||
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||||
const replyRecipient = input.replyRecipient.trim() || 'user';
|
const replyRecipient = input.replyRecipient.trim() || 'user';
|
||||||
|
const taskRefsJson = input.taskRefs.length > 0 ? JSON.stringify(input.taskRefs) : null;
|
||||||
return [
|
return [
|
||||||
'The app still has no correlated visible reply proof for this message.',
|
'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}".`,
|
`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.',
|
'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.',
|
'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[] {
|
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);
|
const tools = normalizedToolNames(input);
|
||||||
if (hasTool(tools, 'message_send')) {
|
if (hasTool(tools, 'message_send')) {
|
||||||
return control(
|
return control(
|
||||||
|
|
@ -264,6 +265,7 @@ export function decideOpenCodePromptDeliveryRepair(
|
||||||
if (
|
if (
|
||||||
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
|
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
|
||||||
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
|
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
|
||||||
|
input.pendingReason === 'visible_reply_missing_task_refs' ||
|
||||||
input.pendingReason === 'visible_reply_still_required' ||
|
input.pendingReason === 'visible_reply_still_required' ||
|
||||||
(input.responseState === 'responded_visible_message' && !input.visibleReplyFound)
|
(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_ack_only_still_requires_answer',
|
||||||
'visible_reply_destination_not_found_yet',
|
'visible_reply_destination_not_found_yet',
|
||||||
'visible_reply_missing_relayofmessageid',
|
'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',
|
'non_visible_tool_without_task_progress',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -101,9 +105,20 @@ function getOpenCodeRuntimeDeliveryStateFallback(
|
||||||
): string | null {
|
): string | null {
|
||||||
const state = record.responseState?.trim();
|
const state = record.responseState?.trim();
|
||||||
const reason = record.lastReason?.trim();
|
const reason = record.lastReason?.trim();
|
||||||
|
const diagnostics = record.diagnostics.map((diagnostic) => diagnostic.trim().toLowerCase());
|
||||||
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
|
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
|
||||||
return 'OpenCode returned an 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 (
|
if (
|
||||||
reason === 'visible_reply_still_required' ||
|
reason === 'visible_reply_still_required' ||
|
||||||
reason === 'visible_reply_ack_only_still_requires_answer' ||
|
reason === 'visible_reply_ack_only_still_requires_answer' ||
|
||||||
|
|
|
||||||
|
|
@ -929,6 +929,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
input.messageId
|
input.messageId
|
||||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||||
: null,
|
: 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.',
|
'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.',
|
'You must not end this turn empty.',
|
||||||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
'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 ?? [];
|
const intervals = task.workIntervals ?? [];
|
||||||
for (let i = intervals.length - 1; i >= 0; i -= 1) {
|
for (let i = intervals.length - 1; i >= 0; i -= 1) {
|
||||||
const interval = intervals[i];
|
const interval = intervals[i];
|
||||||
if (!interval.completedAt) {
|
if (interval.completedAt === undefined) {
|
||||||
return interval;
|
return interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,12 @@ function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]):
|
||||||
if (!Number.isFinite(startedAt) || time < startedAt) {
|
if (!Number.isFinite(startedAt) || time < startedAt) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!interval.completedAt) {
|
if (interval.completedAt === undefined) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const completedAt = Date.parse(interval.completedAt);
|
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 =
|
const completedAt =
|
||||||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
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 {
|
return {
|
||||||
startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS,
|
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);
|
.filter((window): window is TimeWindow => window !== null);
|
||||||
|
|
|
||||||
|
|
@ -831,9 +831,14 @@ function buildTaskTimeWindows(task: TeamTask): TimeWindow[] {
|
||||||
}
|
}
|
||||||
const completedAt =
|
const completedAt =
|
||||||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
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 {
|
return {
|
||||||
startMs: startedAt - WINDOW_GRACE_BEFORE_MS,
|
startMs: startedAt - WINDOW_GRACE_BEFORE_MS,
|
||||||
endMs: Number.isFinite(completedAt) ? completedAt + WINDOW_GRACE_AFTER_MS : null,
|
endMs,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((window): window is TimeWindow => window !== null);
|
.filter((window): window is TimeWindow => window !== null);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function getActiveWorkStartedAt(task: TeamTaskWithKanban): number {
|
||||||
const workIntervals = task.workIntervals ?? [];
|
const workIntervals = task.workIntervals ?? [];
|
||||||
for (let index = workIntervals.length - 1; index >= 0; index--) {
|
for (let index = workIntervals.length - 1; index >= 0; index--) {
|
||||||
const interval = workIntervals[index];
|
const interval = workIntervals[index];
|
||||||
if (interval && !interval.completedAt) {
|
if (interval && interval.completedAt === undefined) {
|
||||||
const startedAt = parseIsoTime(interval.startedAt);
|
const startedAt = parseIsoTime(interval.startedAt);
|
||||||
if (startedAt > 0) {
|
if (startedAt > 0) {
|
||||||
return startedAt;
|
return startedAt;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import {
|
import {
|
||||||
createEmptyMemberLogPreviewResponse,
|
createEmptyMemberLogPreviewResponse,
|
||||||
createEmptyMemberLogStreamResponse,
|
createEmptyMemberLogStreamResponse,
|
||||||
|
createEmptyMemberRuntimeLogTailResponse,
|
||||||
} from '@features/member-log-stream/contracts';
|
} from '@features/member-log-stream/contracts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -271,6 +272,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode');
|
console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode');
|
||||||
return createEmptyMemberLogPreviewResponse();
|
return createEmptyMemberLogPreviewResponse();
|
||||||
},
|
},
|
||||||
|
getMemberRuntimeLogTail: async (_teamName, _memberName, options) => {
|
||||||
|
console.warn('[HttpAPIClient] getMemberRuntimeLogTail is not available in browser mode');
|
||||||
|
return createEmptyMemberRuntimeLogTailResponse(options.kind);
|
||||||
|
},
|
||||||
setMemberLogStreamTracking: async () => {
|
setMemberLogStreamTracking: async () => {
|
||||||
// Not available in browser mode - no-op.
|
// 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_BEFORE_MS = 30_000; // 30s before startedAt
|
||||||
const CHUNK_GRACE_AFTER_MS = 10_000; // 10s after completedAt
|
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,
|
chunks: EnhancedChunk[] | null,
|
||||||
intervals: { startedAt: string; completedAt?: string }[] | undefined
|
intervals: { startedAt: string; completedAt?: string }[] | undefined
|
||||||
): EnhancedChunk[] | null {
|
): EnhancedChunk[] | null {
|
||||||
if (!chunks) return null;
|
if (!chunks) return null;
|
||||||
if (!intervals || intervals.length === 0) return chunks;
|
if (!intervals || intervals.length === 0) return chunks;
|
||||||
|
|
||||||
const now = Date.now();
|
const nowMs = Date.now();
|
||||||
const parsed = intervals
|
const parsed = intervals
|
||||||
.map((i) => {
|
.map((interval) =>
|
||||||
const s = Date.parse(i.startedAt);
|
getWorkIntervalWindow(interval, {
|
||||||
if (!Number.isFinite(s)) return null;
|
graceBeforeMs: CHUNK_GRACE_BEFORE_MS,
|
||||||
const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null;
|
graceAfterMs: CHUNK_GRACE_AFTER_MS,
|
||||||
return {
|
nowMs,
|
||||||
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 } => v !== null);
|
||||||
})
|
|
||||||
.filter((v): v is { startMs: number; endMs: number | null } => v !== null);
|
|
||||||
|
|
||||||
if (parsed.length === 0) return chunks;
|
if (parsed.length === 0) return chunks;
|
||||||
|
|
||||||
|
|
@ -62,10 +84,7 @@ function filterChunksByWorkIntervals(
|
||||||
const cs = chunk.startTime.getTime();
|
const cs = chunk.startTime.getTime();
|
||||||
const ce = chunk.endTime.getTime();
|
const ce = chunk.endTime.getTime();
|
||||||
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
|
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
|
||||||
return parsed.some((i) => {
|
return parsed.some((i) => cs <= i.endMs && ce >= i.startMs);
|
||||||
const end = i.endMs ?? now;
|
|
||||||
return cs <= end && ce >= i.startMs;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
@ -215,13 +234,14 @@ export const MemberLogsTab = ({
|
||||||
|
|
||||||
let totalOverlap = 0;
|
let totalOverlap = 0;
|
||||||
for (const interval of taskWorkIntervals) {
|
for (const interval of taskWorkIntervals) {
|
||||||
const intStart = Date.parse(interval.startedAt);
|
const window = getWorkIntervalWindow(interval, {
|
||||||
if (!Number.isFinite(intStart)) continue;
|
graceBeforeMs: 0,
|
||||||
const intEnd =
|
graceAfterMs: 0,
|
||||||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : nowMs;
|
nowMs,
|
||||||
if (!Number.isFinite(intEnd)) continue;
|
});
|
||||||
const overlapStart = Math.max(logStartMs, intStart);
|
if (!window) continue;
|
||||||
const overlapEnd = Math.min(logEndMs, intEnd);
|
const overlapStart = Math.max(logStartMs, window.startMs);
|
||||||
|
const overlapEnd = Math.min(logEndMs, window.endMs);
|
||||||
if (overlapEnd > overlapStart) totalOverlap += overlapEnd - overlapStart;
|
if (overlapEnd > overlapStart) totalOverlap += overlapEnd - overlapStart;
|
||||||
}
|
}
|
||||||
return totalOverlap;
|
return totalOverlap;
|
||||||
|
|
@ -294,17 +314,15 @@ export const MemberLogsTab = ({
|
||||||
) {
|
) {
|
||||||
const GRACE_BEFORE = 30_000;
|
const GRACE_BEFORE = 30_000;
|
||||||
const GRACE_AFTER = 15_000;
|
const GRACE_AFTER = 15_000;
|
||||||
const now = Date.now();
|
const nowMs = Date.now();
|
||||||
const intervals = taskWorkIntervals
|
const intervals = taskWorkIntervals
|
||||||
.map((i) => {
|
.map((interval) =>
|
||||||
const s = Date.parse(i.startedAt);
|
getWorkIntervalWindow(interval, {
|
||||||
if (!Number.isFinite(s)) return null;
|
graceBeforeMs: GRACE_BEFORE,
|
||||||
const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null;
|
graceAfterMs: GRACE_AFTER,
|
||||||
return {
|
nowMs,
|
||||||
startMs: s - GRACE_BEFORE,
|
})
|
||||||
endMs: e != null && Number.isFinite(e) ? e + GRACE_AFTER : now + GRACE_AFTER,
|
)
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((v): v is { startMs: number; endMs: number } => v !== null);
|
.filter((v): v is { startMs: number; endMs: number } => v !== null);
|
||||||
|
|
||||||
if (intervals.length > 0) {
|
if (intervals.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,7 @@ export const MessageComposer = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
'mr-[15px] inline-flex items-center border text-xs transition-colors',
|
'mr-[15px] inline-flex items-center border text-xs transition-colors',
|
||||||
shouldDockRecipientSelector
|
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',
|
: 'rounded-full',
|
||||||
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
|
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
|
||||||
)}
|
)}
|
||||||
|
|
@ -948,7 +948,7 @@ export const MessageComposer = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className={cn('relative', shouldDockRecipientSelector && 'z-[2]')}>
|
||||||
<DropZoneOverlay
|
<DropZoneOverlay
|
||||||
active={isDragOver}
|
active={isDragOver}
|
||||||
rejected={!canAttach}
|
rejected={!canAttach}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export function deriveWorkActivityTimerAnchor(
|
||||||
for (let index = intervals.length - 1; index >= 0; index -= 1) {
|
for (let index = intervals.length - 1; index >= 0; index -= 1) {
|
||||||
const interval = intervals[index];
|
const interval = intervals[index];
|
||||||
const startedAtMs = parseIsoMs(interval?.startedAt);
|
const startedAtMs = parseIsoMs(interval?.startedAt);
|
||||||
if (startedAtMs > 0 && !interval?.completedAt) {
|
if (startedAtMs > 0 && interval?.completedAt === undefined) {
|
||||||
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
||||||
const previous = intervals[previousIndex];
|
const previous = intervals[previousIndex];
|
||||||
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
|
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
|
||||||
|
|
@ -335,7 +335,10 @@ export function deriveReviewActivityTimerAnchor(
|
||||||
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
|
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
|
||||||
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
|
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
|
||||||
const interval = reviewIntervals[index];
|
const interval = reviewIntervals[index];
|
||||||
if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) {
|
if (
|
||||||
|
normalizeMemberName(interval?.reviewer) !== memberKey ||
|
||||||
|
interval?.completedAt !== undefined
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const startedAtMs = parseIsoMs(interval.startedAt);
|
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.';
|
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') {
|
if (trimmed === 'non_visible_tool_without_task_progress') {
|
||||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
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.';
|
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') {
|
if (normalized === 'non_visible_tool_without_task_progress') {
|
||||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
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;
|
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 });
|
windows.push({ startMs, endMs: nowMs });
|
||||||
hasRunningInterval = true;
|
hasRunningInterval = true;
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +130,12 @@ export function calculateTaskImplementationEventDuration<
|
||||||
|
|
||||||
for (const interval of task.workIntervals) {
|
for (const interval of task.workIntervals) {
|
||||||
const startMs = parseIsoMs(interval?.startedAt);
|
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 };
|
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
|
# OpenCode Model Gauntlet Results
|
||||||
|
|
||||||
Generated: 2026-05-08T18:34:37.950Z
|
Generated: 2026-05-08T21:13:58.089Z
|
||||||
|
|
||||||
Runs per model: 1
|
Runs per model: 1
|
||||||
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
|
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 |
|
| 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
|
## 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%).
|
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 |
|
| 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']),
|
getAliveTeams: vi.fn(() => ['my-team']),
|
||||||
getLeadActivityState: vi.fn(() => 'idle'),
|
getLeadActivityState: vi.fn(() => 'idle'),
|
||||||
stopTeam: vi.fn(() => Promise.resolve()),
|
stopTeam: vi.fn(() => Promise.resolve()),
|
||||||
|
repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)),
|
||||||
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||||
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
|
|
@ -369,6 +370,8 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||||
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
|
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
|
||||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
|
||||||
|
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
|
||||||
|
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
|
||||||
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
|
|
@ -1377,6 +1380,32 @@ describe('ipc teams handlers', () => {
|
||||||
expect(service.getTeamData).not.toHaveBeenCalled();
|
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 () => {
|
it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => {
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({
|
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('Include relayOfMessageId="semantic-direct-');
|
||||||
expect(directCommand?.text).toContain('Action mode for this message: ask.');
|
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('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('"displayId":"59560c95"');
|
||||||
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
|
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
|
||||||
expect(directCommand?.text).toContain('never use #00000000');
|
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') {
|
if (process.env.OPENCODE_E2E_DUMP_PROMPTS === '1') {
|
||||||
await dumpOpenCodePromptArtifacts({
|
await dumpOpenCodePromptArtifacts({
|
||||||
outputDir: path.join(
|
outputDir: path.join(process.cwd(), 'test-results', 'opencode-semantic-prompts', teamName),
|
||||||
process.cwd(),
|
|
||||||
'test-results',
|
|
||||||
'opencode-semantic-prompts',
|
|
||||||
teamName
|
|
||||||
),
|
|
||||||
launchInput: launchInput!,
|
launchInput: launchInput!,
|
||||||
launchCommand: launchCommand!,
|
launchCommand: launchCommand!,
|
||||||
messageCommands: bridgeCapture.messageCommands,
|
messageCommands: bridgeCapture.messageCommands,
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,36 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
||||||
).toBe(true);
|
).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 () => {
|
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const unanswered = await store.ensurePending({
|
const unanswered = await store.ensurePending({
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
|
||||||
expect(decision.controlText).not.toContain('reportToken=');
|
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', () => {
|
it('does not repair terminal, permission, or session failures', () => {
|
||||||
expect(
|
expect(
|
||||||
decideOpenCodePromptDeliveryRepair(
|
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 () => {
|
it('executes OpenCode task ledger backfill through a direct read-only bridge command', async () => {
|
||||||
const executor = fakeExecutor(
|
const executor = fakeExecutor(
|
||||||
bridgeCommandSuccess({
|
bridgeCommandSuccess({
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,30 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
'OpenCode used tools, but did not create a visible reply or task progress proof.'
|
'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('<opencode_delivery_context>');
|
||||||
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
||||||
expect(sentText).toContain('"inboundMessageId":"msg-1"');
|
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).not.toContain('The inbound app messageId is');
|
||||||
expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message');
|
expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message');
|
||||||
expect(sentText).toContain('never use #00000000');
|
expect(sentText).toContain('never use #00000000');
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import {
|
||||||
RuntimeStoreBatchWriter,
|
RuntimeStoreBatchWriter,
|
||||||
} from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
} 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;
|
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 () => {
|
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 teamName = 'pure-opencode-direct-message-recovered-lane-safe-e2e';
|
||||||
const launchAdapter = new FakeOpenCodeRuntimeAdapter();
|
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 {
|
class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
|
||||||
readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = [];
|
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(
|
async function writeTeamMeta(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectPath: 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 () => {
|
it('omits source field from payload when not provided in request', async () => {
|
||||||
await writer.sendMessage('my-team', {
|
await writer.sendMessage('my-team', {
|
||||||
member: 'alice',
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-lead-owner-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
|
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
|
||||||
['network_error', 'Fetch failed because the network connection timed out.'],
|
['network_error', 'Fetch failed because the network connection timed out.'],
|
||||||
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
|
['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.'],
|
['backend_error', 'Unexpected backend blew up during request processing.'],
|
||||||
] as const)('classifies %s retry causes from api_error messages', async (expected, message) => {
|
] as const)('classifies %s retry causes from api_error messages', async (expected, message) => {
|
||||||
const service = new TeamMemberRuntimeAdvisoryService({} as never);
|
const service = new TeamMemberRuntimeAdvisoryService({} as never);
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ import {
|
||||||
getMixedLaunchFallbackRecoveryError,
|
getMixedLaunchFallbackRecoveryError,
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
} from '@main/services/team/TeamProvisioningService';
|
} from '@main/services/team/TeamProvisioningService';
|
||||||
|
import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService';
|
||||||
import {
|
import {
|
||||||
clearAutoResumeService,
|
clearAutoResumeService,
|
||||||
getAutoResumeService,
|
getAutoResumeService,
|
||||||
|
|
@ -1524,6 +1525,218 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(refreshLeadInbox).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('retries the owner status request when a member-spawn change lands while it is building', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const teamName = 'spawn-cache-owner-retry-team';
|
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 () => {
|
it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => {
|
||||||
const teamName = 'forge-labs-10';
|
const teamName = 'forge-labs-10';
|
||||||
const teamDir = path.join(tempTeamsBase, teamName);
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
|
|
@ -5582,6 +5968,294 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy();
|
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 () => {
|
it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -5876,6 +6550,242 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
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 () => {
|
it('accepts observed visible OpenCode user replies for lead-delegated inbox messages', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -18736,14 +19646,20 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () =>
|
(svc as any).runMemberLifecycleOperation(
|
||||||
'bob-ok'
|
'same-team',
|
||||||
|
'bob',
|
||||||
|
'manual_restart',
|
||||||
|
async () => 'bob-ok'
|
||||||
)
|
)
|
||||||
).resolves.toBe('bob-ok');
|
).resolves.toBe('bob-ok');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
(svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () =>
|
(svc as any).runMemberLifecycleOperation(
|
||||||
'other-ok'
|
'other-team',
|
||||||
|
'alice',
|
||||||
|
'manual_restart',
|
||||||
|
async () => 'other-ok'
|
||||||
)
|
)
|
||||||
).resolves.toBe('other-ok');
|
).resolves.toBe('other-ok');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
if (tempDir) {
|
if (tempDir) {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
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', {
|
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',
|
subject: 'Build',
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
workIntervals: [
|
workIntervals: [
|
||||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
{ 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: [
|
reviewIntervals: [
|
||||||
{
|
{
|
||||||
reviewer: 'bob',
|
reviewer: 'bob',
|
||||||
|
|
@ -143,14 +448,15 @@ describe('TeamTaskActivityIntervalService', () => {
|
||||||
'bob',
|
'bob',
|
||||||
'2026-05-08T10:20:00.000Z'
|
'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(result.changedTasks).toBe(2);
|
||||||
expect(task.workIntervals).toEqual([
|
expect(workTask.workIntervals).toEqual([
|
||||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
{ startedAt: '2026-05-08T10:20:00.000Z' },
|
{ startedAt: '2026-05-08T10:20:00.000Z' },
|
||||||
]);
|
]);
|
||||||
expect(task.reviewIntervals).toEqual([
|
expect(reviewTask.reviewIntervals).toEqual([
|
||||||
{
|
{
|
||||||
reviewer: 'bob',
|
reviewer: 'bob',
|
||||||
startedAt: '2026-05-08T10:06:00.000Z',
|
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 () => {
|
it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
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 () => {
|
it('repairs stale open intervals near their start time when no runtime evidence exists', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
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 () => {
|
it('throws when verify detects conflicting status', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
taskPath,
|
taskPath,
|
||||||
|
|
@ -217,7 +239,13 @@ describe('TeamTaskWriter', () => {
|
||||||
subject: 'task',
|
subject: 'task',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
historyEvents: [
|
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',
|
subject: 'task',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
historyEvents: [
|
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',
|
status: 'deleted',
|
||||||
deletedAt: '2024-01-01T00:02:00.000Z',
|
deletedAt: '2024-01-01T00:02:00.000Z',
|
||||||
historyEvents: [
|
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',
|
owner: 'alice',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
historyEvents: [
|
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 { 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 { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
|
||||||
import type { ParsedMessage } from '../../../../../src/main/types';
|
import type { ParsedMessage } from '../../../../../src/main/types';
|
||||||
import type { TeamTask } from '../../../../../src/shared/types';
|
import type { TeamTask } from '../../../../../src/shared/types';
|
||||||
|
|
@ -105,6 +108,33 @@ function createSnapshot(overrides: Partial<TeamTaskStallSnapshot>): TeamTaskStal
|
||||||
describe('TeamTaskStallPolicy', () => {
|
describe('TeamTaskStallPolicy', () => {
|
||||||
const policy = new 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', () => {
|
it('alerts for work stall after turn ended and threshold elapsed', () => {
|
||||||
const task: TeamTask = {
|
const task: TeamTask = {
|
||||||
id: 'task-a',
|
id: 'task-a',
|
||||||
|
|
|
||||||
|
|
@ -326,4 +326,36 @@ describe('selectCurrentActiveTeamTask', () => {
|
||||||
|
|
||||||
expect(selected?.id).toBe('task-a');
|
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 {
|
import {
|
||||||
type MemberLogStreamRequestOptions,
|
type MemberLogStreamRequestOptions,
|
||||||
type MemberLogStreamResponse,
|
type MemberLogStreamResponse,
|
||||||
|
type MemberRuntimeLogTailOptions,
|
||||||
|
type MemberRuntimeLogTailResponse,
|
||||||
} from '../../../../../src/features/member-log-stream/contracts';
|
} from '../../../../../src/features/member-log-stream/contracts';
|
||||||
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
|
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';
|
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
|
||||||
|
|
@ -42,6 +44,14 @@ const apiState = {
|
||||||
) => Promise<MemberLogStreamResponse>
|
) => Promise<MemberLogStreamResponse>
|
||||||
>(),
|
>(),
|
||||||
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
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>(),
|
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -53,6 +63,9 @@ vi.mock('@renderer/api', () => ({
|
||||||
setMemberLogStreamTracking: (
|
setMemberLogStreamTracking: (
|
||||||
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
|
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
|
||||||
) => apiState.setMemberLogStreamTracking(...args),
|
) => apiState.setMemberLogStreamTracking(...args),
|
||||||
|
getMemberRuntimeLogTail: (
|
||||||
|
...args: Parameters<typeof apiState.getMemberRuntimeLogTail>
|
||||||
|
) => apiState.getMemberRuntimeLogTail(...args),
|
||||||
},
|
},
|
||||||
teams: {
|
teams: {
|
||||||
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||||
|
|
@ -266,6 +279,7 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
apiState.getMemberLogStream.mockReset();
|
apiState.getMemberLogStream.mockReset();
|
||||||
apiState.setMemberLogStreamTracking.mockReset();
|
apiState.setMemberLogStreamTracking.mockReset();
|
||||||
|
apiState.getMemberRuntimeLogTail.mockReset();
|
||||||
apiState.onTeamChange.mockReset();
|
apiState.onTeamChange.mockReset();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
@ -280,6 +294,13 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
stubMatchMedia();
|
stubMatchMedia();
|
||||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||||
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
|
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
|
||||||
|
apiState.getMemberRuntimeLogTail.mockResolvedValue({
|
||||||
|
kind: 'stdout',
|
||||||
|
content: 'process stdout line',
|
||||||
|
truncated: false,
|
||||||
|
bytesRead: 19,
|
||||||
|
missing: false,
|
||||||
|
});
|
||||||
|
|
||||||
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
|
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
|
||||||
await createFixtureUseCase();
|
await createFixtureUseCase();
|
||||||
|
|
@ -375,4 +396,75 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
|
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
|
||||||
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
|
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();
|
).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', () => {
|
it('anchors review timers only after the reviewer actually starts review', () => {
|
||||||
const assignedOnly: TeamTaskWithKanban = {
|
const assignedOnly: TeamTaskWithKanban = {
|
||||||
...baseTask,
|
...baseTask,
|
||||||
|
|
|
||||||
|
|
@ -864,6 +864,23 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
expect(title).not.toContain('non_visible_tool_without_task_progress');
|
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', () => {
|
it('renders Codex native timeout separately from network errors', () => {
|
||||||
const advisory = {
|
const advisory = {
|
||||||
kind: 'api_error' as const,
|
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.'
|
'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', () => {
|
it('merges overlapping intervals to avoid double counting malformed data', () => {
|
||||||
const duration = calculateTaskImplementationDuration(
|
const duration = calculateTaskImplementationDuration(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue