fix(team): harden runtime advisory and pending sends

This commit is contained in:
777genius 2026-05-08 09:49:05 +03:00
parent 660875fa0a
commit 51650dca33
13 changed files with 1295 additions and 120 deletions

View file

@ -13,8 +13,14 @@ import {
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamTaskReader } from './TeamTaskReader';
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
import type {
MemberLogSummary,
MemberRuntimeAdvisory,
ResolvedTeamMember,
TeamTask,
} from '@shared/types';
interface RuntimeAdvisoryLogFileRef {
memberName: string;
@ -110,6 +116,11 @@ interface CachedTeamBatchAdvisories {
expiresAt: number;
}
interface OpenCodeRuntimeDeliverySupersedingProofTimes {
visibleReplyTimes: ReadonlyMap<string, number>;
taskProgressTimes: ReadonlyMap<string, number>;
}
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
return tokens.some((token) => value.includes(token));
}
@ -204,6 +215,7 @@ async function mapLimit<T, R>(
export class TeamMemberRuntimeAdvisoryService {
private readonly inboxReader = new TeamInboxReader();
private readonly taskReader = new TeamTaskReader();
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
private readonly cacheGenerationByTeam = new Map<string, number>();
@ -552,9 +564,10 @@ export class TeamMemberRuntimeAdvisoryService {
return new Map();
}
const visibleRuntimeReplyTimes = await this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(
const supersedingProofTimes = await this.readOpenCodeRuntimeDeliverySupersedingProofTimes(
teamName,
memberKeysWithRecentErrors
memberKeysWithRecentErrors,
recordsByMember
);
const result = new Map<string, MemberRuntimeAdvisory>();
for (const [memberKey, records] of recordsByMember) {
@ -567,7 +580,7 @@ export class TeamMemberRuntimeAdvisoryService {
originalName,
records,
now,
visibleRuntimeReplyTimes
supersedingProofTimes
)
: null;
if (advisory && originalName) {
@ -589,7 +602,7 @@ export class TeamMemberRuntimeAdvisoryService {
memberName: string,
records: readonly OpenCodePromptDeliveryLedgerRecord[],
now: number,
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
supersedingProofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
): MemberRuntimeAdvisory | null {
const ordered = records
.slice()
@ -610,10 +623,10 @@ export class TeamMemberRuntimeAdvisoryService {
return null;
}
if (
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
this.hasSupersedingProofForOpenCodeDeliveryRecord(
memberName,
latestError,
visibleRuntimeReplyTimes
supersedingProofTimes
)
) {
return null;
@ -632,6 +645,18 @@ export class TeamMemberRuntimeAdvisoryService {
};
}
private async readOpenCodeRuntimeDeliverySupersedingProofTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<OpenCodeRuntimeDeliverySupersedingProofTimes> {
const [visibleReplyTimes, taskProgressTimes] = await Promise.all([
this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(teamName, activeMemberKeys),
this.readTaskProgressProofTimes(teamName, activeMemberKeys, recordsByMember),
]);
return { visibleReplyTimes, taskProgressTimes };
}
private async readVisibleOpenCodeRuntimeDeliveryReplyTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>
@ -673,10 +698,133 @@ export class TeamMemberRuntimeAdvisoryService {
return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record);
}
private hasSupersedingProofForOpenCodeDeliveryRecord(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord,
proofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
): boolean {
if (
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
memberName,
record,
proofTimes.visibleReplyTimes
)
) {
return true;
}
return this.hasTaskProgressProofForOpenCodeDeliveryRecord(
memberName,
record,
proofTimes.taskProgressTimes
);
}
private async readTaskProgressProofTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<Map<string, number>> {
const taskIdsByMember = new Map<string, Set<string>>();
for (const [memberKey, records] of recordsByMember) {
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const record of records) {
for (const taskRef of record.taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
}
const taskIds = taskIdsByMember.get(memberKey) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByMember.set(memberKey, taskIds);
}
}
}
if (taskIdsByMember.size === 0) {
return new Map();
}
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
if (tasks.length === 0) {
return new Map();
}
const result = new Map<string, number>();
for (const task of tasks) {
const taskId = task.id?.trim();
if (!taskId) {
continue;
}
for (const [memberKey, taskIds] of taskIdsByMember) {
if (!taskIds.has(taskId)) {
continue;
}
const proofAt = this.getLatestMemberTaskProgressTime(task, memberKey);
if (proofAt <= 0) {
continue;
}
const key = this.getOpenCodeTaskProgressProofKey(memberKey, taskId);
result.set(key, Math.max(result.get(key) ?? 0, proofAt));
}
}
return result;
}
private getLatestMemberTaskProgressTime(task: TeamTask, memberKey: string): number {
let latest = 0;
for (const comment of task.comments ?? []) {
if (this.normalizeToken(comment.author) !== memberKey) {
continue;
}
const createdAt = Date.parse(comment.createdAt);
if (Number.isFinite(createdAt)) {
latest = Math.max(latest, createdAt);
}
}
for (const event of task.historyEvents ?? []) {
if (this.normalizeToken(event.actor ?? '') !== memberKey) {
continue;
}
const timestamp = Date.parse(event.timestamp);
if (Number.isFinite(timestamp)) {
latest = Math.max(latest, timestamp);
}
}
return latest;
}
private hasTaskProgressProofForOpenCodeDeliveryRecord(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord,
taskProgressTimes: ReadonlyMap<string, number>
): boolean {
const recordTime = getRecordTimeMs(record);
if (!Number.isFinite(recordTime) || recordTime <= 0 || record.taskRefs.length === 0) {
return false;
}
const memberKey = this.normalizeToken(memberName);
return record.taskRefs.some((taskRef) => {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
return false;
}
const proofAt = taskProgressTimes.get(
this.getOpenCodeTaskProgressProofKey(memberKey, taskId)
);
return typeof proofAt === 'number' && proofAt > recordTime;
});
}
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
return `${memberKey}::${relayOfMessageId.trim()}`;
}
private getOpenCodeTaskProgressProofKey(memberKey: string, taskId: string): string {
return `${memberKey}::task::${taskId.trim()}`;
}
private async findRecentMemberAdvisoriesFromBatchRefs(
teamName: string,
memberNames: readonly string[]

View file

@ -1374,11 +1374,20 @@ export function getOpenCodeMixedProviderProvisioningError(): string {
}
export function getMixedLaunchFallbackRecoveryError(): string {
return (
'Persisted mixed-team launch recovery requires members.meta.json lane-aware roster truth. ' +
'Inbox/config fallback cannot safely reconstruct an OpenCode secondary lane in V1. ' +
'Run a fresh team bootstrap or restore the missing mixed-team metadata first.'
);
return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.';
}
type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe';
type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing';
type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta';
interface TeamLaunchCompatibilityReport {
level: TeamLaunchCompatibilityLevel;
rosterSource: TeamLaunchCompatibilityRosterSource;
members: TeamCreateRequest['members'];
warnings: string[];
blockers: string[];
repairAction?: TeamLaunchCompatibilityRepairAction;
}
function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: {
@ -7206,6 +7215,7 @@ export class TeamProvisioningService {
: [],
observedAt: nowIso(),
});
this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, existing.message);
return { ledgerRecord, visibleReply: existing };
}
@ -7251,6 +7261,7 @@ export class TeamProvisioningService {
: ['opencode_plain_text_reply_materialized_to_user_inbox'],
observedAt: nowIso(),
});
this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReply.message);
return { ledgerRecord, visibleReply };
} catch (error) {
logger.warn(
@ -7750,6 +7761,41 @@ export class TeamProvisioningService {
}
}
private emitRuntimeDeliveryReplyAdvisoryRefresh(teamName: string, message: InboxMessage): void {
if (
message.source !== 'runtime_delivery' ||
typeof message.relayOfMessageId !== 'string' ||
message.relayOfMessageId.trim().length === 0
) {
return;
}
const memberName = message.from?.trim();
if (!memberName || memberName === 'user' || memberName === 'system') {
return;
}
try {
this.memberRuntimeAdvisoryInvalidator?.(teamName, memberName);
} catch (error) {
logger.warn(
`[${teamName}] Failed to invalidate runtime advisory after runtime delivery reply for ${memberName}: ${getErrorMessage(error)}`
);
}
try {
this.teamChangeEmitter?.({
type: 'member-advisory',
teamName,
detail: `runtime-delivery-reply:${memberName}:${message.relayOfMessageId.trim()}`,
});
} catch (error) {
logger.warn(
`[${teamName}] Failed to emit runtime advisory refresh after runtime delivery reply for ${memberName}: ${getErrorMessage(error)}`
);
}
}
private pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now: number): void {
const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS;
for (const [key, sentAt] of this.openCodeRuntimeDeliveryAdvisoryEventSentAt) {
@ -10111,6 +10157,7 @@ export class TeamProvisioningService {
slashCommand: message.slashCommand,
commandOutput: message.commandOutput,
});
this.emitRuntimeDeliveryReplyAdvisoryRefresh(teamName, message);
} catch (error) {
logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`);
}
@ -17936,11 +17983,23 @@ export class TeamProvisioningService {
}
}
const launchCompatibility = await this.probeLaunchCompatibility(
request.teamName,
configRaw,
request.providerId
);
if (launchCompatibility.level === 'unsafe') {
this.provisioningRunByTeam.delete(request.teamName);
throw new Error(launchCompatibility.blockers[0] ?? getMixedLaunchFallbackRecoveryError());
}
if (launchCompatibility.repairAction === 'materialize-members-meta') {
await this.materializeLaunchCompatibilityRepair(request, launchCompatibility);
}
const {
members: expectedMemberSpecs,
source,
warning,
} = await this.resolveLaunchExpectedMembers(request.teamName, configRaw, request.providerId);
} = this.resolveLaunchExpectedMembersFromCompatibility(launchCompatibility);
assertOpenCodeNotLaunchedThroughLegacyProvisioning({
providerId: request.providerId,
members: expectedMemberSpecs,
@ -30680,54 +30739,54 @@ export class TeamProvisioningService {
source: 'members-meta' | 'inboxes' | 'config-fallback';
warning?: string;
}> {
return this.resolveLaunchExpectedMembersFromCompatibility(
await this.probeLaunchCompatibility(teamName, configRaw, leadProviderId)
);
}
private resolveLaunchExpectedMembersFromCompatibility(report: TeamLaunchCompatibilityReport): {
members: TeamCreateRequest['members'];
source: 'members-meta' | 'inboxes' | 'config-fallback';
warning?: string;
} {
if (report.level === 'unsafe') {
throw new Error(report.blockers[0] ?? getMixedLaunchFallbackRecoveryError());
}
return {
members: report.members,
source:
report.rosterSource === 'members-meta'
? 'members-meta'
: report.rosterSource === 'inboxes'
? 'inboxes'
: 'config-fallback',
...(report.warnings.length > 0 ? { warning: report.warnings.join(' ') } : {}),
};
}
private async probeLaunchCompatibility(
teamName: string,
configRaw: string,
leadProviderId?: TeamProviderId
): Promise<TeamLaunchCompatibilityReport> {
// Keep this probe read-only: launch-state/bootstrap-state may inform existing resume guards,
// but compatibility repair must not mutate or trust stale runtime projections.
await Promise.allSettled([
this.launchStateStore.read(teamName),
readBootstrapLaunchSnapshot(teamName),
]);
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
const byName = new Map<string, TeamCreateRequest['members'][number]>();
for (const member of metaMembers) {
const rawName = member.name?.trim() ?? '';
const lower = rawName.toLowerCase();
if (isLeadMember(member) || lower === 'user') {
continue;
}
const name = rawName;
if (!name) continue;
if (member.removedAt) continue;
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const workflow =
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model =
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
const prev = byName.get(name);
if (!prev) {
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
} else {
byName.set(name, {
...prev,
role: prev.role || role,
workflow: prev.workflow || workflow,
isolation: prev.isolation || isolation,
cwd: prev.cwd || cwd,
providerId: prev.providerId || providerId,
model: prev.model || model,
effort: prev.effort || effort,
});
}
}
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(byName.keys());
const keepName = createCliAutoSuffixNameGuard(allNames);
for (const name of allNames) {
if (!keepName(name)) {
byName.delete(name);
}
}
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
const members = this.buildLaunchMembersFromMeta(metaMembers);
if (members.length > 0) {
return { members, source: 'members-meta' };
return {
level: 'ready',
rosterSource: 'members-meta',
members,
warnings: [],
blockers: [],
};
}
} catch (error) {
logger.warn(
@ -30781,28 +30840,37 @@ export class TeamProvisioningService {
const memberOverridesUsed = members.some(
(member) => member.providerId || member.model || member.effort || member.isolation
);
this.assertMixedLaunchFallbackSafe({
teamName,
leadProviderId,
source: 'inboxes',
members,
});
if (
this.hasIncompleteOpenCodeLaunchCompatibilityMember(members) ||
this.isUnsafeMixedLaunchFallback({
leadProviderId,
members,
})
) {
return {
level: 'unsafe',
rosterSource: 'inboxes',
members: [],
warnings: [],
blockers: [
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: inboxes.`,
],
};
}
return {
level: 'ready',
rosterSource: 'inboxes',
members,
source: 'inboxes',
...(memberOverridesUsed
? {
warning:
'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
warnings: memberOverridesUsed
? [
'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
'Multimodel reconnect is best-effort in this fallback path.',
}
: {}),
]
: [],
blockers: [],
};
}
} catch (error) {
if (error instanceof Error && error.message.includes(getMixedLaunchFallbackRecoveryError())) {
throw error;
}
logger.warn(
`[${teamName}] Failed to read inbox member names: ${
error instanceof Error ? error.message : String(error)
@ -30812,18 +30880,51 @@ export class TeamProvisioningService {
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
if (configMembers.length > 0) {
this.assertMixedLaunchFallbackSafe({
teamName,
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
return {
level: 'unsafe',
rosterSource: 'config',
members: [],
warnings: [],
blockers: [
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
],
};
}
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
leadProviderId,
source: 'config-fallback',
members: configMembers,
hasOpenCodeRuntimeAdapter: true,
});
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
const sideLanesHaveExplicitProviderModels = lanePlan.sideLanes.every(
(lane) =>
normalizeOptionalTeamProviderId(lane.member.providerId) === 'opencode' &&
typeof lane.member.model === 'string' &&
lane.member.model.trim().length > 0
);
if (!sideLanesHaveExplicitProviderModels) {
return {
level: 'unsafe',
rosterSource: 'config',
members: [],
warnings: [],
blockers: [
`[${teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: config.`,
],
};
}
}
return {
level: 'repairable',
rosterSource: 'config',
members: configMembers,
source: 'config-fallback',
warning:
warnings: [
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
'Run a fresh team bootstrap to persist stable member metadata.',
'Run a fresh team bootstrap to persist stable member metadata.',
],
blockers: [],
repairAction: 'materialize-members-meta',
};
}
@ -30835,30 +30936,121 @@ export class TeamProvisioningService {
}
return {
level: 'ready',
rosterSource: 'missing',
members: [],
source: 'config-fallback',
...(configParseFailed
? {
warning:
'Config could not be parsed during launch roster discovery. ' +
warnings: configParseFailed
? [
'Config could not be parsed during launch roster discovery. ' +
'Launch will continue without explicit teammate names.',
}
: {}),
]
: [],
blockers: [],
};
}
private buildLaunchMembersFromMeta(metaMembers: TeamMember[]): TeamCreateRequest['members'] {
const byName = new Map<string, TeamCreateRequest['members'][number]>();
for (const member of metaMembers) {
const rawName = member.name?.trim() ?? '';
const lower = rawName.toLowerCase();
if (isLeadMember(member) || lower === 'user') {
continue;
}
const name = rawName;
if (!name) continue;
if (member.removedAt) continue;
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const workflow =
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
const prev = byName.get(name);
if (!prev) {
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
} else {
byName.set(name, {
...prev,
role: prev.role || role,
workflow: prev.workflow || workflow,
isolation: prev.isolation || isolation,
cwd: prev.cwd || cwd,
providerId: prev.providerId || providerId,
model: prev.model || model,
effort: prev.effort || effort,
});
}
}
const allNames = Array.from(byName.keys());
const keepName = createCliAutoSuffixNameGuard(allNames);
for (const name of allNames) {
if (!keepName(name)) {
byName.delete(name);
}
}
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
}
private async materializeLaunchCompatibilityRepair(
request: TeamLaunchRequest,
report: TeamLaunchCompatibilityReport
): Promise<void> {
if (report.repairAction !== 'materialize-members-meta' || report.members.length === 0) {
return;
}
const joinedAt = Date.now();
const membersToWrite = this.buildMembersMetaWritePayload(
report.members.map((member) => ({
...member,
joinedAt,
}))
);
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
providerBackendId: request.providerBackendId,
});
}
private isUnsafeMixedLaunchFallback(params: {
leadProviderId?: TeamProviderId;
members: TeamCreateRequest['members'];
}): boolean {
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
leadProviderId: params.leadProviderId,
members: params.members,
hasOpenCodeRuntimeAdapter: true,
});
return this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan);
}
private hasIncompleteOpenCodeLaunchCompatibilityMember(
members: TeamCreateRequest['members']
): boolean {
return members.some((member) => {
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = typeof member.model === 'string' ? member.model.trim() : '';
const inferredProviderId = inferTeamProviderIdFromModel(model);
return (
(providerId === 'opencode' && model.length === 0) ||
(!providerId && inferredProviderId === 'opencode')
);
});
}
private assertMixedLaunchFallbackSafe(params: {
teamName: string;
leadProviderId?: TeamProviderId;
source: 'inboxes' | 'config-fallback';
members: TeamCreateRequest['members'];
}): void {
const lanePlan = this.runtimeLaneCoordinator.planProvisioningMembers({
leadProviderId: params.leadProviderId,
members: params.members,
hasOpenCodeRuntimeAdapter: true,
});
if (this.runtimeLaneCoordinator.isMixedSideLanePlan(lanePlan)) {
if (
this.isUnsafeMixedLaunchFallback({
leadProviderId: params.leadProviderId,
members: params.members,
})
) {
throw new Error(
`[${params.teamName}] ${getMixedLaunchFallbackRecoveryError()} Fallback source: ${params.source}.`
);
@ -30882,6 +31074,7 @@ export class TeamProvisioningService {
model?: string;
effort?: string;
cwd?: string;
removedAt?: unknown;
}[];
};
if (!Array.isArray(parsed.members)) {
@ -30894,6 +31087,7 @@ export class TeamProvisioningService {
if (!member || isLeadMember(member) || lower === 'user') continue;
const name = rawName;
if (!name) continue;
if (member.removedAt != null) continue;
byName.set(name, {
name,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,

View file

@ -59,6 +59,11 @@ import {
formatTaskDisplayLabel,
taskMatchesRef,
} from '@shared/utils/taskIdentity';
import {
calculateTaskImplementationDuration,
formatTaskImplementationDuration,
shouldShowTaskImplementationDuration,
} from '@shared/utils/taskWorkDuration';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinishedForDependency,
@ -330,6 +335,10 @@ export const TaskDetailDialog = ({
currentTask?.sourceMessageId && currentTask?.sourceMessage?.attachments?.length
? currentTask.sourceMessage.attachments.length
: 0;
const attachmentCount =
(currentTask?.attachments?.length ?? 0) +
commentImageAttachments.length +
sourceAttachmentCount;
// Changes is the explicit lazy-load entry point. Keep it visible for all team tasks,
// including old/pending tasks that may resolve to an empty result.
@ -574,6 +583,29 @@ export const TaskDetailDialog = ({
setChangesSectionOpen(isOpen);
}, []);
const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now());
const taskImplementationDuration = useMemo(
() => calculateTaskImplementationDuration(currentTask, taskDurationNowMs),
[currentTask, taskDurationNowMs]
);
const showTaskImplementationDuration = shouldShowTaskImplementationDuration(
taskImplementationDuration
);
const taskImplementationDurationLabel = formatTaskImplementationDuration(
taskImplementationDuration.elapsedMs
);
useEffect(() => {
if (!open || !taskImplementationDuration.hasRunningInterval) return;
setTaskDurationNowMs(Date.now());
const intervalId = window.setInterval(() => {
setTaskDurationNowMs(Date.now());
}, 1000);
return () => window.clearInterval(intervalId);
}, [open, taskImplementationDuration.hasRunningInterval, currentTask?.id]);
if (loading) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -1121,24 +1153,11 @@ export const TaskDetailDialog = ({
<CollapsibleTeamSection
title="Attachments"
icon={<ImageIcon size={14} />}
badge={
(currentTask.attachments?.length ?? 0) +
commentImageAttachments.length +
sourceAttachmentCount >
0
? (currentTask.attachments?.length ?? 0) +
commentImageAttachments.length +
sourceAttachmentCount
: undefined
}
badge={attachmentCount}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={
(currentTask.attachments?.length ?? 0) > 0 ||
commentImageAttachments.length > 0 ||
sourceAttachmentCount > 0
}
defaultOpen={attachmentCount > 0}
>
{currentTask.sourceMessageId && currentTask.sourceMessage ? (
<SourceMessageAttachments
@ -1352,6 +1371,17 @@ export const TaskDetailDialog = ({
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
headerExtra={
showTaskImplementationDuration ? (
<span
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title="Implementation time from persisted work intervals"
>
<Clock size={10} />
<span>Work time {taskImplementationDurationLabel}</span>
</span>
) : undefined
}
defaultOpen={false}
>
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />

View file

@ -59,6 +59,38 @@ const draftHarness = vi.hoisted(() => {
};
});
const provisioningHarness = vi.hoisted(() => {
const state = {
active: false,
};
return {
reset: () => {
state.active = false;
},
state,
};
});
const storeHarness = vi.hoisted(() => {
const state = {
crossTeamTargets: [] as {
teamName: string;
displayName: string;
description?: string;
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}[],
};
return {
reset: () => {
state.crossTeamTargets = [];
},
state,
};
});
vi.mock('@renderer/api', () => ({
api: {
teams: {
@ -176,7 +208,7 @@ vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
crossTeamTargets: [],
crossTeamTargets: storeHarness.state.crossTeamTargets,
fetchCrossTeamTargets: vi.fn(),
fetchSkillsCatalog: vi.fn(),
selectedTeamData: null,
@ -187,7 +219,7 @@ vi.mock('@renderer/store', () => ({
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
isTeamProvisioningActive: () => false,
isTeamProvisioningActive: () => provisioningHarness.state.active,
}));
import { MessageComposer } from './MessageComposer';
@ -203,6 +235,16 @@ const members: ResolvedTeamMember[] = [
status: 'idle',
taskCount: 0,
},
{
agentType: 'developer',
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
name: 'bob',
role: 'Developer',
status: 'idle',
taskCount: 0,
},
];
function renderComposer(overrides: Partial<React.ComponentProps<typeof MessageComposer>> = {}): {
@ -255,10 +297,22 @@ function getTextarea(host: HTMLElement): HTMLTextAreaElement {
return textarea;
}
function getButtonContainingText(host: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes(text)
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Button containing "${text}" not found`);
}
return button;
}
describe('MessageComposer pending send lifecycle', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
draftHarness.reset();
provisioningHarness.reset();
storeHarness.reset();
});
afterEach(() => {
@ -377,4 +431,101 @@ describe('MessageComposer pending send lifecycle', () => {
root.unmount();
});
});
it('keeps send enabled when stale provisioning state remains after the team is alive', () => {
provisioningHarness.state.active = true;
const { host, onSend, root } = renderComposer({ isTeamAlive: true });
expect(getSendButton(host).disabled).toBe(false);
act(() => {
getSendButton(host).click();
});
expect(onSend).toHaveBeenCalledOnce();
act(() => {
root.unmount();
});
});
it('keeps send disabled while provisioning before the team is alive', () => {
provisioningHarness.state.active = true;
const { host, onSend, root } = renderComposer({ isTeamAlive: false });
expect(getSendButton(host).disabled).toBe(true);
act(() => {
getSendButton(host).click();
});
expect(onSend).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after sending', () => {
const { host, root } = renderComposer();
const sendButton = getSendButton(host);
const textarea = getTextarea(host);
sendButton.focus();
expect(document.activeElement).toBe(sendButton);
act(() => {
sendButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after selecting a recipient member', () => {
const { host, root } = renderComposer();
const bobButton = getButtonContainingText(host, 'bob');
const textarea = getTextarea(host);
bobButton.focus();
expect(document.activeElement).toBe(bobButton);
act(() => {
bobButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
it('returns focus to the textarea after selecting a cross-team recipient', () => {
storeHarness.state.crossTeamTargets = [
{
teamName: 'team-beta',
displayName: 'Beta Team',
},
];
const { host, root } = renderComposer({ onCrossTeamSend: vi.fn() });
const betaTeamButton = getButtonContainingText(host, 'Beta Team');
const textarea = getTextarea(host);
betaTeamButton.focus();
expect(document.activeElement).toBe(betaTeamButton);
act(() => {
betaTeamButton.click();
});
expect(document.activeElement).toBe(textarea);
act(() => {
root.unmount();
});
});
});

View file

@ -120,6 +120,14 @@ export const MessageComposer = ({
}
};
}, [externalTextareaRef]);
const focusComposerTextarea = useCallback(() => {
const focus = (): void => {
internalTextareaRef.current?.focus();
};
focus();
queueMicrotask(focus);
window.requestAnimationFrame(focus);
}, []);
const [recipient, setRecipient] = useState<string>(() => {
const lead = members.find((m) => isLeadMember(m));
return lead?.name ?? members[0]?.name ?? '';
@ -251,6 +259,7 @@ export const MessageComposer = ({
);
const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const isLaunchBlocking = isProvisioning && !isTeamAlive;
// Fetch skills catalog for the team's project on mount / project change
useEffect(() => {
@ -285,9 +294,9 @@ export const MessageComposer = ({
useEffect(() => {
if (prevActionModeRef.current !== actionMode) {
prevActionModeRef.current = actionMode;
internalTextareaRef.current?.focus();
focusComposerTextarea();
}
}, [actionMode]);
}, [actionMode, focusComposerTextarea]);
// Auto-select delegate when lead recipient is chosen by the user.
// Wait until draft is restored from IndexedDB (draftLoaded) before running,
@ -358,7 +367,7 @@ export const MessageComposer = ({
trimmed.length > 0 &&
trimmed.length <= MAX_TEXT_LENGTH &&
!sending &&
!isProvisioning &&
!isLaunchBlocking &&
!attachmentsBlocked &&
!slashCommandRestrictionReason &&
(!isCrossTeam || onCrossTeamSend !== undefined);
@ -404,6 +413,7 @@ export const MessageComposer = ({
taskRefs
);
}
focusComposerTextarea();
}, [
actionMode,
canSend,
@ -418,6 +428,7 @@ export const MessageComposer = ({
draft.chips,
draft.text,
lastResult,
focusComposerTextarea,
taskSuggestions,
teamName,
]);
@ -642,7 +653,7 @@ export const MessageComposer = ({
) : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
{!isTeamAlive && !isProvisioning && (
{!isTeamAlive && !isLaunchBlocking && (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
</span>
@ -717,6 +728,7 @@ export const MessageComposer = ({
onClick={() => {
setSelectedTeam(null);
setTeamSelectorOpen(false);
focusComposerTextarea();
}}
>
{currentTeamColor ? (
@ -752,6 +764,7 @@ export const MessageComposer = ({
setSelectedTeam(target.teamName);
setRecipient('team-lead');
setTeamSelectorOpen(false);
focusComposerTextarea();
}}
>
<span
@ -894,6 +907,7 @@ export const MessageComposer = ({
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
focusComposerTextarea();
}}
>
<MemberBadge
@ -944,7 +958,7 @@ export const MessageComposer = ({
ref={textareaRef}
id={`compose-${teamName}`}
placeholder={
isProvisioning
isLaunchBlocking
? 'Team is launching... message will be queued for inbox delivery.'
: isCrossTeam
? `Cross-team message to ${targetDisplayName ?? 'team'}...`
@ -1013,7 +1027,7 @@ export const MessageComposer = ({
</TooltipTrigger>
{slashCommandRestrictionReason ? (
<TooltipContent side="top">{slashCommandRestrictionReason}</TooltipContent>
) : isProvisioning && !sending ? (
) : isLaunchBlocking && !sending ? (
<TooltipContent side="top">
Sending unavailable while team is launching
</TooltipContent>

View file

@ -1774,6 +1774,34 @@ export function initializeNotificationListeners(): () => void {
if (event.type === 'inbox') {
scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox');
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
if (existingDetailTimer) {
return;
}
const eventReason = `${buildTeamChangeFanoutReason(event.type)}:structural-safety`;
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
if (!isTeamVisibleInAnyPane(event.teamName)) {
return;
}
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: 'executed',
reason: eventReason,
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: true,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
return;
}

View file

@ -0,0 +1,93 @@
interface TaskWorkDurationIntervalLike {
startedAt?: string | null;
completedAt?: string | null;
}
export interface TaskWorkDurationLike<
TInterval extends TaskWorkDurationIntervalLike = TaskWorkDurationIntervalLike,
> {
status?: string | null;
workIntervals?: TInterval[] | null;
}
export interface TaskImplementationDuration {
elapsedMs: number;
hasRunningInterval: boolean;
countedIntervalCount: number;
}
function parseIsoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function calculateTaskImplementationDuration<TInterval extends TaskWorkDurationIntervalLike>(
task: TaskWorkDurationLike<TInterval> | null | undefined,
nowMs = Date.now()
): TaskImplementationDuration {
if (!task || !Array.isArray(task.workIntervals)) {
return { elapsedMs: 0, hasRunningInterval: false, countedIntervalCount: 0 };
}
const windows: { startMs: number; endMs: number }[] = [];
let hasRunningInterval = false;
for (const interval of task.workIntervals) {
const startMs = parseIsoMs(interval?.startedAt);
if (startMs <= 0) continue;
const completedAtMs = parseIsoMs(interval?.completedAt);
if (completedAtMs > startMs) {
windows.push({ startMs, endMs: completedAtMs });
continue;
}
if (!interval?.completedAt && task.status === 'in_progress' && nowMs > startMs) {
windows.push({ startMs, endMs: nowMs });
hasRunningInterval = true;
}
}
if (windows.length === 0) {
return { elapsedMs: 0, hasRunningInterval, countedIntervalCount: 0 };
}
windows.sort((left, right) => left.startMs - right.startMs);
const merged: { startMs: number; endMs: number }[] = [];
for (const window of windows) {
const previous = merged[merged.length - 1];
if (previous && window.startMs <= previous.endMs) {
previous.endMs = Math.max(previous.endMs, window.endMs);
} else {
merged.push({ ...window });
}
}
const elapsedMs = merged.reduce((sum, window) => sum + (window.endMs - window.startMs), 0);
return { elapsedMs, hasRunningInterval, countedIntervalCount: windows.length };
}
export function shouldShowTaskImplementationDuration(
duration: TaskImplementationDuration
): boolean {
return duration.elapsedMs > 0 || duration.hasRunningInterval;
}
export function formatTaskImplementationDuration(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalMinutes < 60) {
return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}

View file

@ -5,7 +5,10 @@ import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '../../../../src/main/services/team/TeamProvisioningService';
import type {
OpenCodeTeamRuntimeMessageInput,
OpenCodeTeamRuntimeMessageResult,
@ -233,6 +236,71 @@ describe('Team agent launch matrix safe e2e', () => {
});
});
it('materializes members metadata before relaunching a legacy pure team config', async () => {
const teamName = 'legacy-pure-config-repair-safe-e2e';
await writePureAnthropicTeamConfigWithMembers({
teamName,
projectPath,
members: ['alice', 'bob'],
});
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch').mockImplementation(async () => {
throw new Error('stop after compatibility repair');
});
await expect(
svc.launchTeam(
{
teamName,
cwd: projectPath,
providerId: 'anthropic',
model: 'sonnet',
skipPermissions: true,
},
() => undefined
)
).rejects.toThrow('stop after compatibility repair');
const membersMeta = JSON.parse(
await fs.readFile(path.join(getTeamsBasePath(), teamName, 'members.meta.json'), 'utf8')
) as { members: Array<{ name: string; providerId?: string; model?: string }> };
expect(membersMeta.members).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'alice', providerId: 'anthropic', model: 'haiku' }),
expect.objectContaining({ name: 'bob', providerId: 'anthropic', model: 'sonnet' }),
])
);
});
it('fails unsafe old mixed OpenCode config without launch-state or members metadata mutation', async () => {
const teamName = 'legacy-mixed-config-unsafe-safe-e2e';
await writeMixedTeamConfigWithoutOpenCodeProviderMetadata({ teamName, projectPath });
const svc = new TeamProvisioningService();
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
await expect(
svc.launchTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
skipPermissions: true,
},
() => undefined
)
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
expect(normalizeSpy).not.toHaveBeenCalled();
await expect(
fs.readFile(path.join(getTeamsBasePath(), teamName, 'members.meta.json'), 'utf8')
).rejects.toThrow();
await expect(
fs.readFile(path.join(getTeamsBasePath(), teamName, 'launch-state.json'), 'utf8')
).rejects.toThrow();
});
it('keeps permission-pending OpenCode members pending instead of reading the team as fully ready', async () => {
const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending');
const svc = new TeamProvisioningService();

View file

@ -562,6 +562,127 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory).toBeNull();
});
it('suppresses stale OpenCode proof advisories after same-task member progress exists', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'mission-control';
const laneId = 'secondary:opencode:bob';
const taskId = '10d1c1b5-e8be-4dc9-a500-a7e2bc619c9e';
const laneDir = path.join(
tmpDir,
'teams',
teamName,
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId)
);
await fs.mkdir(laneDir, { recursive: true });
await fs.mkdir(path.join(tmpDir, 'tasks', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
JSON.stringify({
version: 1,
updatedAt: '2026-05-08T06:37:47.470Z',
lanes: {
[laneId]: { laneId, state: 'active', updatedAt: '2026-05-08T06:37:47.470Z' },
},
}),
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
JSON.stringify({
schemaVersion: 1,
updatedAt: '2026-05-08T06:37:47.470Z',
data: [
{
id: 'opencode-prompt:task-progress-missing',
teamName,
memberName: 'bob',
laneId,
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'task-assignment-1',
inboxTimestamp: '2026-05-08T06:36:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [{ taskId, displayId: '10d1c1b5', teamName }],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'empty_assistant_turn',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-08T06:37:30.000Z',
lastObservedAt: '2026-05-08T06:37:33.167Z',
acceptedAt: '2026-05-08T06:36:29.651Z',
respondedAt: '2026-05-08T06:37:33.167Z',
failedAt: '2026-05-08T06:37:47.470Z',
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: 'assistant-1',
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
createdAt: '2026-05-08T06:36:00.000Z',
updatedAt: '2026-05-08T06:37:47.470Z',
},
],
}),
'utf8'
);
await fs.writeFile(
path.join(tmpDir, 'tasks', teamName, `${taskId}.json`),
JSON.stringify({
id: taskId,
displayId: '10d1c1b5',
subject: 'Keyboard shortcuts',
owner: 'bob',
status: 'completed',
updatedAt: '2026-05-08T06:40:55.128Z',
comments: [
{
id: 'progress-comment-1',
author: 'bob',
text: 'Keyboard shortcuts implemented and verified.',
createdAt: '2026-05-08T06:39:40.805Z',
type: 'regular',
},
],
historyEvents: [
{
id: 'status-event-1',
type: 'status_changed',
from: 'in_progress',
to: 'completed',
actor: 'bob',
timestamp: '2026-05-08T06:40:55.128Z',
},
],
}),
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(async () => []),
});
const advisory = await service.getMemberAdvisory(teamName, 'bob');
expect(advisory).toBeNull();
});
it('ignores expired retry advisories', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -122,7 +122,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
};
});
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import {
getMixedLaunchFallbackRecoveryError,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import {
clearAutoResumeService,
getAutoResumeService,
@ -11050,6 +11053,91 @@ describe('TeamProvisioningService', () => {
};
}
it('materializes members.meta.json before config normalization for a repairable legacy launch', async () => {
allowConsoleLogs();
const teamName = 'legacy-pure-launch-repair';
const leadSessionId = 'legacy-pure-launch-session';
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const { svc, membersMetaStore } = createSafeLaunchService();
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
const { runId } = await svc.launchTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
},
() => {}
);
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
teamName,
expect.arrayContaining([
expect.objectContaining({ name: 'alice' }),
expect.objectContaining({ name: 'bob' }),
]),
expect.objectContaining({ providerBackendId: 'codex-native' })
);
expect(membersMetaStore.writeMembers.mock.invocationCallOrder[0]).toBeLessThan(
normalizeSpy.mock.invocationCallOrder[0]
);
await svc.cancelProvisioning(runId);
});
it('blocks unsafe old mixed OpenCode launches before config normalization or launch-state cleanup', async () => {
allowConsoleLogs();
const teamName = 'legacy-mixed-unsafe-launch';
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: teamName,
projectPath: tempClaudeRoot,
leadSessionId: 'legacy-mixed-unsafe-session',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'jack', role: 'Developer', providerId: 'opencode' },
],
},
null,
2
)}\n`,
'utf8'
);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const { svc, membersMetaStore } = createSafeLaunchService();
const normalizeSpy = vi.spyOn(svc as any, 'normalizeTeamConfigForLaunch');
const clearLaunchStateSpy = vi.spyOn(svc as any, 'clearPersistedLaunchState');
await expect(
svc.launchTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
},
() => {}
)
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
expect(membersMetaStore.writeMembers).not.toHaveBeenCalled();
expect(normalizeSpy).not.toHaveBeenCalled();
expect(clearLaunchStateSpy).not.toHaveBeenCalled();
expect(spawnCli).not.toHaveBeenCalled();
});
it('invalidates config cache after writing OpenCode team config', async () => {
const teamName = 'opencode-config-cache-prime';
fs.mkdirSync(path.join(tempTeamsBase, teamName), { recursive: true });

View file

@ -120,6 +120,39 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']);
});
it('marks pure Claude/Codex legacy config without members.meta.json as repairable', async () => {
const svc = new TeamProvisioningService(
{} as never,
{ listInboxNames: vi.fn(async () => []) } as never,
{ getMembers: vi.fn(async () => []) } as never,
{} as never
);
const configRaw = JSON.stringify({
name: 'legacy-pure',
members: [
{ name: 'alice', role: 'reviewer', provider: 'anthropic', model: 'claude-opus-4-6' },
{ name: 'tom', role: 'developer', provider: 'codex', model: 'gpt-5.4' },
],
});
const report = await (svc as unknown as any).probeLaunchCompatibility(
'legacy-pure',
configRaw,
'anthropic'
);
expect(report).toMatchObject({
level: 'repairable',
rosterSource: 'config',
repairAction: 'materialize-members-meta',
});
expect(report.members.map((member: { name: string }) => member.name)).toEqual([
'alice',
'tom',
]);
});
it('rejects inbox fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
const svc = new TeamProvisioningService(
{} as never,
@ -138,7 +171,7 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
});
it('rejects config fallback when it would reconstruct a mixed OpenCode side lane without members.meta truth', async () => {
it('marks complete mixed OpenCode config fallback as repairable', async () => {
const svc = new TeamProvisioningService(
{} as never,
{ listInboxNames: vi.fn(async () => []) } as never,
@ -151,6 +184,48 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
members: [{ name: 'tom', role: 'developer', provider: 'opencode', model: 'minimax-m2.5-free' }],
});
const report = await (svc as unknown as any).probeLaunchCompatibility('t', configRaw, 'codex');
expect(report).toMatchObject({
level: 'repairable',
rosterSource: 'config',
repairAction: 'materialize-members-meta',
});
expect(report.members).toMatchObject([
{ name: 'tom', role: 'developer', providerId: 'opencode', model: 'minimax-m2.5-free' },
]);
});
it('rejects mixed OpenCode config fallback when the side lane is missing an explicit model', async () => {
const svc = new TeamProvisioningService(
{} as never,
{ listInboxNames: vi.fn(async () => []) } as never,
{ getMembers: vi.fn(async () => []) } as never,
{} as never
);
const configRaw = JSON.stringify({
name: 't',
members: [{ name: 'tom', role: 'developer', provider: 'opencode' }],
});
await expect(
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());
});
it('rejects config fallback when an OpenCode-looking model is missing an explicit provider', async () => {
const svc = new TeamProvisioningService(
{} as never,
{ listInboxNames: vi.fn(async () => []) } as never,
{ getMembers: vi.fn(async () => []) } as never,
{} as never
);
const configRaw = JSON.stringify({
name: 't',
members: [{ name: 'tom', role: 'developer', model: 'opencode/minimax-m2.5-free' }],
});
await expect(
(svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw, 'codex')
).rejects.toThrow(getMixedLaunchFallbackRecoveryError());

View file

@ -108,7 +108,8 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children),
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
@ -263,6 +264,36 @@ describe('TaskDetailDialog changes summary loading', () => {
vi.useRealTimers();
});
it('shows a zero attachments count in the attachments section header', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task: { ...makeTask('task-empty-attachments'), workIntervals: [] },
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
onViewChanges: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="section-badge-Attachments"]')?.textContent).toBe('0');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not drop a new task changes request while another task summary is still in flight', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const first = deferred<TaskChangeSetV2>();
@ -529,4 +560,56 @@ describe('TaskDetailDialog changes summary loading', () => {
await Promise.resolve();
});
});
it('shows total implementation time in the workflow history header', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-20T10:07:30.000Z'));
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const task: TeamTaskWithKanban = {
...makeTask('task-duration'),
workIntervals: [
{
startedAt: '2026-04-20T10:00:00.000Z',
completedAt: '2026-04-20T10:02:30.000Z',
},
{ startedAt: '2026-04-20T10:05:00.000Z' },
],
historyEvents: [
{
id: 'event-created',
timestamp: '2026-04-20T10:00:00.000Z',
type: 'task_created',
status: 'in_progress',
actor: 'lead',
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskDetailDialog, {
open: true,
variant: 'team',
teamName: 'team-a',
task,
taskMap: new Map<string, TeamTaskWithKanban>(),
members: [],
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Workflow History');
expect(host.textContent).toContain('Work time 5m 00s');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
calculateTaskImplementationDuration,
formatTaskImplementationDuration,
shouldShowTaskImplementationDuration,
} from '@shared/utils/taskWorkDuration';
describe('taskWorkDuration', () => {
it('sums completed intervals and the current in-progress interval', () => {
const duration = calculateTaskImplementationDuration(
{
status: 'in_progress',
workIntervals: [
{
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T10:02:30.000Z',
},
{ startedAt: '2026-05-08T10:05:00.000Z' },
],
},
Date.parse('2026-05-08T10:07:00.000Z')
);
expect(duration).toEqual({
elapsedMs: 270_000,
hasRunningInterval: true,
countedIntervalCount: 2,
});
expect(shouldShowTaskImplementationDuration(duration)).toBe(true);
});
it('does not keep an open interval running after the task leaves in progress', () => {
const duration = calculateTaskImplementationDuration(
{
status: 'completed',
workIntervals: [
{
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T10:02:00.000Z',
},
{ startedAt: '2026-05-08T10:05:00.000Z' },
],
},
Date.parse('2026-05-08T10:30:00.000Z')
);
expect(duration).toEqual({
elapsedMs: 120_000,
hasRunningInterval: false,
countedIntervalCount: 1,
});
});
it('merges overlapping intervals to avoid double counting malformed data', () => {
const duration = calculateTaskImplementationDuration(
{
status: 'completed',
workIntervals: [
{
startedAt: '2026-05-08T10:00:00.000Z',
completedAt: '2026-05-08T10:10:00.000Z',
},
{
startedAt: '2026-05-08T10:05:00.000Z',
completedAt: '2026-05-08T10:12:00.000Z',
},
],
},
Date.parse('2026-05-08T10:30:00.000Z')
);
expect(duration.elapsedMs).toBe(720_000);
expect(duration.countedIntervalCount).toBe(2);
});
it('formats seconds, minutes, and hours for compact UI labels', () => {
expect(formatTaskImplementationDuration(42_900)).toBe('42s');
expect(formatTaskImplementationDuration(65_000)).toBe('1m 05s');
expect(formatTaskImplementationDuration(7_260_000)).toBe('2h 01m');
});
});