From 0ace2a625574ade1182903616ccb5bfb128b4e73 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 23:07:45 +0300 Subject: [PATCH] fix(team): harden opencode runtime status and effort UI --- .../services/team/TeamMessageFeedService.ts | 67 +++++++++- .../services/team/TeamProvisioningService.ts | 42 ++++--- .../team/TeamRuntimeLivenessResolver.ts | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 3 - .../team/dialogs/EffortLevelSelector.tsx | 40 ++++-- .../team/dialogs/LaunchTeamDialog.tsx | 3 - .../utils/__tests__/teamEffortOptions.test.ts | 31 ++++- src/renderer/utils/memberHelpers.ts | 2 +- src/renderer/utils/teamEffortOptions.ts | 62 ++++++++++ .../utils/teamProvisioningPresentation.ts | 4 +- .../team/TeamMessageFeedService.test.ts | 41 +++++++ .../team/TeamProvisioningService.test.ts | 100 ++++++++++++++- .../team/dialogs/EffortLevelSelector.test.tsx | 114 ++++++++++++++++++ test/renderer/utils/memberHelpers.test.ts | 26 ++++ 14 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 test/renderer/components/team/dialogs/EffortLevelSelector.test.tsx diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index d2817917..eb34ba7a 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -11,6 +11,8 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000; const logger = createLogger('Service:TeamMessageFeedService'); +type TeamConfigMember = NonNullable[number]; + interface TeamMessageFeedDeps { getConfig: (teamName: string) => Promise; getInboxMessages: (teamName: string) => Promise; @@ -69,6 +71,68 @@ function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { return message.source === 'lead_session' || message.source === 'lead_process'; } +function resolveLeadName(config: TeamConfig): string { + const lead = + config.members?.find((member) => member.agentType === 'team-lead' || member.role === 'Lead') ?? + config.members?.find((member) => member.name === 'team-lead') ?? + config.members?.[0]; + return lead?.name?.trim() || 'team-lead'; +} + +function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { + const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return new Date(raw).toISOString(); + } + if (typeof raw === 'string') { + const parsed = Date.parse(raw); + if (Number.isFinite(parsed)) { + return new Date(parsed).toISOString(); + } + } + return new Date(0).toISOString(); +} + +function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string { + const role = member.role?.trim() || member.agentType?.trim() || 'team member'; + const displayName = config.description?.trim() || config.name; + const providerLine = '\nProvider override for this teammate: opencode.'; + const modelLine = member.model?.trim() + ? `\nModel override for this teammate: ${member.model.trim()}.` + : ''; + + return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine} + +The team has already been created and you are being attached as a persistent teammate. +Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with: +{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" } +Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step. +After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`; +} + +function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] { + const members = Array.isArray(config.members) ? config.members : []; + const leadName = resolveLeadName(config); + return members + .filter( + (member) => + member && + member.name?.trim() && + member.providerId === 'opencode' && + member.removedAt == null && + (member as { isActive?: unknown }).isActive !== false + ) + .map((member) => ({ + from: leadName, + to: member.name, + text: buildOpenCodeBootstrapDisplayPrompt(config, member), + timestamp: resolveOpenCodeBootstrapTimestamp(config, member), + read: true, + source: 'system_notification' as const, + messageId: `opencode-bootstrap-start:${config.name}:${member.name}`, + })); +} + function annotateSlashCommandResponses(messages: InboxMessage[]): void { let pendingSlash = null as InboxMessage['slashCommand'] | null; @@ -381,7 +445,8 @@ export class TeamMessageFeedService { this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), ]); - let messages = [...inboxMessages, ...leadTexts, ...sentMessages]; + const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); + let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; messages = dedupeLeadProcessCopies(messages, leadTexts); messages = ensureEffectiveMessageIds(messages); messages = dedupeByMessageId(messages); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2b1cda37..51141d2a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -157,6 +157,7 @@ import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeRunTombstonesPath, getOpenCodeTeamRuntimeDirectory, + inspectOpenCodeRuntimeLaneStorage, migrateLegacyOpenCodeRuntimeState, OpenCodeRuntimeManifestEvidenceReader, readOpenCodeRuntimeLaneIndex, @@ -3826,7 +3827,7 @@ function buildLaunchDiagnosticsFromRun( memberName, severity: 'warning', code: 'runtime_process_candidate', - label: `${memberName} - process candidate`, + label: `${memberName} - bootstrap unconfirmed`, detail: entry.runtimeDiagnostic, observedAt, }); @@ -6102,20 +6103,30 @@ export class TeamProvisioningService { if ( runtimeActive && laneIdentity.laneKind === 'secondary' && - laneIdentity.laneOwnerProviderId === 'opencode' && - !liveSecondaryLaneRunId + laneIdentity.laneOwnerProviderId === 'opencode' ) { + const laneStorage = await inspectOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: laneIdentity.laneId, + }); const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), teamName, laneId: laneIdentity.laneId, }); - if (staleLane.stale) { - this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId); + if (!laneStorage.hasRuntimeEvidenceOnDisk) { + if (staleLane.stale) { + this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId); + } return { delivered: false, reason: 'opencode_runtime_not_active', - diagnostics: staleLane.diagnostics, + diagnostics: staleLane.diagnostics.length + ? staleLane.diagnostics + : [ + `OpenCode runtime bootstrap evidence is not ready for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, + ], }; } } @@ -10957,7 +10968,8 @@ export class TeamProvisioningService { const next = { ...refreshed, livenessKind: metadata.livenessKind, - runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected', + runtimeDiagnostic: + runtimeDiagnostic ?? 'Runtime process candidate detected, but bootstrap is unconfirmed.', runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', livenessLastCheckedAt: nowIso(), }; @@ -12533,7 +12545,7 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is running but not producing output yet. Cloud sometimes delays logs, ` + + `The process is running but not producing output yet. Model responses can delay logs, ` + `and short waits like this are normal. The SDK also retries automatically if the ` + `request briefly hits rate limiting.\n\n` + `Waiting...` @@ -12544,7 +12556,7 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is still waiting on Cloud. Logs can sometimes show up after ` + + `The process is still waiting for a model response. Logs can sometimes show up after ` + `1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` + `request hits rate limiting (error 429 / model cooldown).\n\n` + `If there is still no output after 2 minutes, that starts to look unusual.\n\n` + @@ -12558,21 +12570,21 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Extended CLI wait** (silent for ${elapsed})\n\n` + - `Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` + + `Model **${modelName}**${effortLabel} is still waiting to respond. Some delay is normal, ` + `but no logs for ${elapsed} is already unusual.\n\n` + `Possible causes:\n` + - `- Rate limiting / model cooldown (429) — SDK retries automatically\n` + + `- Rate limiting / model cooldown (429) - SDK retries automatically\n` + `- API server overload for this model\n` + - `- A stalled or delayed Cloud response\n\n` + + `- A stalled or delayed model response\n\n` + `Consider canceling and trying with a different model.` ); } private buildStallProgressMessage(silenceSec: number, elapsed: string): string { if (silenceSec < 120) { - return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`; + return `Waiting for model response for ${elapsed} - logs can be delayed, this is still OK`; } - return `Still waiting on Cloud response for ${elapsed} — this is unusual`; + return `Still waiting for model response for ${elapsed} - this is unusual`; } /** @@ -17331,7 +17343,7 @@ export class TeamProvisioningService { ? `${launchSummary.runtimeProcessPendingCount} waiting for bootstrap` : '', launchSummary.runtimeCandidatePendingCount - ? `${launchSummary.runtimeCandidatePendingCount} process candidates` + ? `${launchSummary.runtimeCandidatePendingCount} bootstrap unconfirmed` : '', launchSummary.noRuntimePendingCount ? `${launchSummary.noRuntimePendingCount} waiting for runtime` diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 2ce967c6..429d73e7 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -328,7 +328,7 @@ export function resolveTeamMemberRuntimeLiveness( paneCurrentCommand: pane.currentCommand, runtimeSessionId, processCommand: sanitizeProcessCommandForDiagnostics(candidate.command), - runtimeDiagnostic: 'runtime process candidate detected', + runtimeDiagnostic: 'Runtime process candidate detected, but bootstrap is unconfirmed.', runtimeDiagnosticSeverity: 'warning', diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'], }); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f735a9a7..2bc7cbf6 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -2183,9 +2183,6 @@ export const CreateTeamDialog = ({ Open Existing Team ) : null} -