fix(team): harden runtime advisory and pending sends
This commit is contained in:
parent
660875fa0a
commit
51650dca33
13 changed files with 1295 additions and 120 deletions
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
93
src/shared/utils/taskWorkDuration.ts
Normal file
93
src/shared/utils/taskWorkDuration.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
82
test/shared/utils/taskWorkDuration.test.ts
Normal file
82
test/shared/utils/taskWorkDuration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue