diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 6053656b..265f336c 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -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; + taskProgressTimes: ReadonlyMap; +} + function includesAnyToken(value: string, tokens: readonly string[]): boolean { return tokens.some((token) => value.includes(token)); } @@ -204,6 +215,7 @@ async function mapLimit( export class TeamMemberRuntimeAdvisoryService { private readonly inboxReader = new TeamInboxReader(); + private readonly taskReader = new TeamTaskReader(); private readonly memberCache = new Map(); private readonly teamBatchCacheByTeam = new Map(); private readonly cacheGenerationByTeam = new Map(); @@ -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(); 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 + 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, + recordsByMember: ReadonlyMap + ): Promise { + 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 @@ -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, + recordsByMember: ReadonlyMap + ): Promise> { + const taskIdsByMember = new Map>(); + 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(); + 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(); + 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 + ): 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[] diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 57f9bdbe..1e653293 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + // 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(); - 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(); + 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 { + 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, diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index b88df1d5..b60ec5f3 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 ( !v && onClose()}> @@ -1121,24 +1153,11 @@ export const TaskDetailDialog = ({ } - 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 ? ( + + Work time {taskImplementationDurationLabel} + + ) : undefined + } defaultOpen={false} > diff --git a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx index 2e7bb6af..5027f04a 100644 --- a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx +++ b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx @@ -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) => 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> = {}): { @@ -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(); + }); + }); }); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 626b9db7..f96031c3 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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(() => { 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}
- {!isTeamAlive && !isProvisioning && ( + {!isTeamAlive && !isLaunchBlocking && ( Team offline @@ -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(); }} > {slashCommandRestrictionReason ? ( {slashCommandRestrictionReason} - ) : isProvisioning && !sending ? ( + ) : isLaunchBlocking && !sending ? ( Sending unavailable while team is launching diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index e57fea10..c078e3f3 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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; } diff --git a/src/shared/utils/taskWorkDuration.ts b/src/shared/utils/taskWorkDuration.ts new file mode 100644 index 00000000..c644294f --- /dev/null +++ b/src/shared/utils/taskWorkDuration.ts @@ -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( + task: TaskWorkDurationLike | 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`; +} diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 55189d09..60380b9e 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -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(); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 09c8a2f9..f949c2e4 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -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); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index dc72235f..e3adcecf 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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 }); diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index c5d43997..022805b5 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -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()); diff --git a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts index 6d8c4ea9..ae68871f 100644 --- a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts +++ b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts @@ -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(), + 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(); @@ -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(), + 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(); + }); + }); }); diff --git a/test/shared/utils/taskWorkDuration.test.ts b/test/shared/utils/taskWorkDuration.test.ts new file mode 100644 index 00000000..ecfde4da --- /dev/null +++ b/test/shared/utils/taskWorkDuration.test.ts @@ -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'); + }); +});