From 3fe9a24e416e3c9a393d8fcfd8a97dfc42129e10 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 24 Apr 2026 22:34:08 +0300 Subject: [PATCH] feat(team): harden launch liveness and recovery --- README.md | 1 + .../member-liveness-hardening-plan.md | 18 +- ...opencode-native-semantic-messaging-plan.md | 424 +++++------- package.json | 1 - packages/agent-graph/src/ports/types.ts | 5 +- scripts/prove-opencode-production.mjs | 72 --- src/main/index.ts | 11 +- src/main/ipc/teams.ts | 24 + .../services/infrastructure/FileWatcher.ts | 37 ++ .../services/team/TeamLaunchStateEvaluator.ts | 94 ++- .../team/TeamLaunchSummaryProjection.ts | 20 + .../services/team/TeamProvisioningService.ts | 312 +++++++-- src/main/services/team/index.ts | 2 - .../bridge/OpenCodeBridgeCommandContract.ts | 3 - .../bridge/OpenCodeReadinessBridge.ts | 114 +--- .../opencode/config/OpenCodeLaunchModeEnv.ts | 17 - .../e2e/OpenCodeProductionE2EEvidence.ts | 612 ------------------ .../e2e/OpenCodeProductionE2EEvidencePath.ts | 20 - .../e2e/OpenCodeProductionE2EEvidenceStore.ts | 253 -------- .../readiness/OpenCodeTeamLaunchReadiness.ts | 80 +-- .../opencode/version/OpenCodeVersionPolicy.ts | 37 -- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 73 +-- src/main/services/team/runtime/index.ts | 2 - src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 3 + .../components/team/TeamDetailView.tsx | 65 +- src/renderer/components/team/TeamListView.tsx | 18 + .../components/team/members/MemberCard.tsx | 169 ++++- .../components/team/members/MemberList.tsx | 11 + .../components/team/provisioningSteps.ts | 27 +- src/renderer/store/slices/teamSlice.ts | 174 +---- ...teamModelAvailability.codexCatalog.test.ts | 11 +- src/renderer/utils/memberHelpers.ts | 20 + src/renderer/utils/teamModelAvailability.ts | 1 - src/renderer/utils/teamModelCatalog.ts | 23 +- .../utils/teamProvisioningPresentation.ts | 230 +++++-- src/shared/types/api.ts | 1 + src/shared/types/team.ts | 22 +- .../infrastructure/FileWatcher.test.ts | 64 ++ .../team/OpenCodeLaunchModeEnv.test.ts | 33 - .../team/OpenCodeMixedRecovery.live.test.ts | 221 +++---- .../OpenCodeProductionE2EEvidence.test.ts | 375 ----------- .../OpenCodeProductionE2EEvidencePath.test.ts | 33 - .../team/OpenCodeProductionGate.live.test.ts | 468 -------------- .../team/OpenCodeReadinessBridge.test.ts | 195 ------ .../team/OpenCodeTeamLaunchReadiness.test.ts | 132 +--- .../OpenCodeTeamProvisioning.live.test.ts | 224 ++++--- .../team/OpenCodeTeamRuntimeAdapter.test.ts | 52 +- .../team/OpenCodeVersionPolicy.test.ts | 129 +--- .../team/TeamLaunchStateEvaluator.test.ts | 67 ++ .../team/TeamProvisioningService.test.ts | 299 ++++++++- .../TeamProvisioningServicePrepare.test.ts | 58 +- .../TeamProvisioningServicePrompts.test.ts | 2 + .../utils/electronUserDataMigration.test.ts | 1 - .../TeamModelSelectorDisabledState.test.ts | 4 +- .../team/TeamProvisioningBanner.test.ts | 6 +- .../ProvisioningProviderStatusList.test.ts | 7 +- .../providerPrepareDiagnostics.test.ts | 62 -- .../team/members/MemberCard.test.ts | 334 +++++++++- .../team/members/MemberList.test.ts | 159 ++++- .../components/team/provisioningSteps.test.ts | 21 + test/renderer/store/teamSlice.test.ts | 54 ++ .../teamProvisioningPresentation.test.ts | 69 ++ 64 files changed, 2423 insertions(+), 3660 deletions(-) delete mode 100644 scripts/prove-opencode-production.mjs delete mode 100644 src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts delete mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts delete mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts delete mode 100644 src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts delete mode 100644 test/main/services/team/OpenCodeLaunchModeEnv.test.ts delete mode 100644 test/main/services/team/OpenCodeProductionE2EEvidence.test.ts delete mode 100644 test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts delete mode 100644 test/main/services/team/OpenCodeProductionGate.live.test.ts diff --git a/README.md b/README.md index b4e7aacc..669e9c28 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux - [ ] Run terminal commands - [ ] Monitor agents processes/stats - [ ] Reusable agents with SOUL.md +- [ ] Сommunicate via messenger --- diff --git a/docs/team-management/member-liveness-hardening-plan.md b/docs/team-management/member-liveness-hardening-plan.md index bba76a0d..0913ae99 100644 --- a/docs/team-management/member-liveness-hardening-plan.md +++ b/docs/team-management/member-liveness-hardening-plan.md @@ -48,7 +48,7 @@ - Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется. - `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation. - `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику. -- В проекте есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для member liveness финальное решение другое: strict model включена по умолчанию без отдельного env-флага. +- Для member liveness strict model включена по умолчанию без отдельного env-флага. - `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape. - `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service. - `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module. @@ -147,14 +147,14 @@ const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; Актуальное поведение: -| Area | Strict-only behavior | -| ------------------------------ | ---------------------------------------- | -| `livenessKind` | always filled when evidence exists | -| UI labels | enabled | -| `runtimeAlive` from shell-only | always false | -| `already_running` shortcut | waits for strong runtime verification | -| timeout self-heal | strong evidence only | -| launchDiagnostics | enabled for warning/error states | +| Area | Strict-only behavior | +| ------------------------------ | ------------------------------------- | +| `livenessKind` | always filled when evidence exists | +| UI labels | enabled | +| `runtimeAlive` from shell-only | always false | +| `already_running` shortcut | waits for strong runtime verification | +| timeout self-heal | strong evidence only | +| launchDiagnostics | enabled for warning/error states | Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом. diff --git a/docs/team-management/opencode-native-semantic-messaging-plan.md b/docs/team-management/opencode-native-semantic-messaging-plan.md index 5f5a668f..59b4348e 100644 --- a/docs/team-management/opencode-native-semantic-messaging-plan.md +++ b/docs/team-management/opencode-native-semantic-messaging-plan.md @@ -1,7 +1,7 @@ # OpenCode Native Semantic Messaging Plan -Status: planning document -Scope: `claude_team` + `agent_teams_orchestrator` +Status: planning document +Scope: `claude_team` + `agent_teams_orchestrator` Goal: make OpenCode teammates use the correct app MCP messaging protocol without breaking Codex/Claude native teammates. ## Problem @@ -30,13 +30,13 @@ This can make OpenCode teammates look started but not answer through the Message Chosen approach: OpenCode-native semantic messaging seam. -Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC +Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC This hides symptoms only. It does not fix the wrong tool instructions sent to OpenCode. -Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC +Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC This is necessary for runtime identity and MCP proof, but not sufficient because `member_briefing` and task assignment messages are produced in `claude_team`. -Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests +Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests This fixes the actual contract. Orchestrator owns OpenCode session identity. `claude_team` owns team protocol text and MCP tool schemas. ## Extra Research Corrections @@ -54,11 +54,9 @@ This section records the higher-risk places that were checked after the first dr - The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools. - `claude_team` already exports `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` from `agent-teams-controller`; app-side required tools should derive from that instead of duplicating a second list. - Orchestrator direct `mcp:tools/list` proof sees plain MCP names like `message_send`, not OpenCode canonical ids. Do not compare direct stdio results against `agent_teams_message_send` or `agent-teams_message_send`. -- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`, and the production live evidence builder reuses that field as `evidence.mcpTools.observedTools`. So direct proof should match plain names internally, but should still emit canonical OpenCode ids for public bridge/evidence output, or add a separate explicit `observedDirectToolNames` field. Do not silently change `observedMcpTools` to plain names. +- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently change `observedMcpTools` semantics. - `agent_teams_orchestrator` does not currently depend on `agent-teams-controller`. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency. -- `OpenCodeReadinessBridge.applyProductionE2EGate()` still builds `requiredMcpTools` from runtime-only tools. If app-side readiness starts requiring teammate-operational tools, the production E2E gate must be moved to the same app tool contract or it will validate a weaker/stale artifact. -- `assertOpenCodeProductionE2EArtifactGate()` compares expected tool ids against `evidence.mcpTools.observedTools` exactly. Stale evidence generated before this change should fail production mode clearly instead of silently pretending the stronger proof exists. -- `scripts/prove-opencode-production.mjs` is only a launcher. The production evidence JSON is built in `test/main/services/team/OpenCodeProductionGate.live.test.ts` inside `buildCandidateEvidence()`. Updating the script alone does nothing; update the live test builder and gate expectations. +- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe. - Current controller teammate-operational catalog includes more than the obvious message/task-start tools: `task_attach_comment_file`, `task_attach_file`, `task_create`, `task_create_from_message`, `task_link`, and `task_unlink` are also teammate-operational and must be included in any explicit orchestrator v1 list. - `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts` mirror controller signatures and must be updated when `memberBriefing(memberName, options)` is added. - `agent-teams-controller` is CommonJS and existing TS code imports it as `import * as agentTeamsControllerModule from 'agent-teams-controller'`; use that pattern in new app-side imports instead of assuming a default ESM export. @@ -190,13 +188,10 @@ Implementation rule: - `/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts` -- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts` - `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` -- `/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts` -- `/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs` `agent_teams_orchestrator` files: @@ -222,7 +217,9 @@ Example: ```js function normalizeRuntimeProvider(value) { - const normalized = String(value || '').trim().toLowerCase(); + const normalized = String(value || '') + .trim() + .toLowerCase(); return normalized === 'opencode' ? 'opencode' : 'native'; } @@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) { } function isOpenCodeMember(member) { - const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase(); + const provider = String(member?.providerId || member?.provider || '') + .trim() + .toLowerCase(); return provider === 'opencode'; } @@ -302,9 +301,7 @@ Edit pattern: ```js function copyTrimmedString(member, key) { - return typeof member[key] === 'string' && member[key].trim() - ? { [key]: member[key].trim() } - : {}; + return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {}; } ``` @@ -314,9 +311,15 @@ Then preserve fields: return { name, ...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}), - ...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}), - ...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}), - ...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}), + ...(typeof member.workflow === 'string' && member.workflow.trim() + ? { workflow: member.workflow.trim() } + : {}), + ...(typeof member.agentType === 'string' && member.agentType.trim() + ? { agentType: member.agentType.trim() } + : {}), + ...(typeof member.color === 'string' && member.color.trim() + ? { color: member.color.trim() } + : {}), ...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}), ...copyTrimmedString(member, 'providerId'), ...copyTrimmedString(member, 'providerBackendId'), @@ -398,7 +401,8 @@ Inside the function: ```js const explicitRuntimeProvider = options.runtimeProvider; -const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native'); +const inferredRuntimeProvider = + explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native'); const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider); ``` @@ -702,13 +706,11 @@ Do not hide `runtime_deliver_message` from readiness or app tool availability pr Instead, make tool descriptions and OpenCode prompts explicitly route normal replies: ```ts -description: - 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.' +description: 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.'; ``` ```ts -description: - 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.' +description: 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.'; ``` OpenCode-specific prompt wording should avoid generic "deliver message" language: @@ -778,7 +780,9 @@ function normalizeMessageSendFlags(context, flags) { throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.'); } if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) { - throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); + throw new Error( + 'message_send cannot target cross_team_send. Use cross_team_send with toTeam.' + ); } if (!resolvedTo) { throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`); @@ -837,13 +841,13 @@ Current fact: Options: -Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC +Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC Safest if we want the smallest messaging seam. The helper must not accept or render `taskRefs` for `buildCrossTeamMessageExample()` yet. -Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC +Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC Best if the helper is meant to be a real semantic messaging seam with uniform traceability. Add `taskRefs` to `cross_team_send` schema, normalize it in controller, store it in target inbox row, append it to sent message, and persist it in `sent-cross-team.json`. -Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B. +Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B. Chosen for v1 if Step 1 helper only renders static examples: Option A. Implementation for Option B: @@ -903,7 +907,7 @@ Current risk: `TeamProvisioningService.captureSendMessages()` recognizes only: ```ts -part.name === 'mcp__agent-teams__message_send' +part.name === 'mcp__agent-teams__message_send'; ``` But OpenCode and MCP tooling can expose names as: @@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: { } const hasKnownPrefix = - rawName !== canonical || - AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix)); + rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix)); if (hasKnownPrefix) { return true; } @@ -1078,23 +1081,23 @@ Add a helper: ```ts function buildOpenCodeRuntimeIdentityBlock(input: { - teamName: string - memberName: string - runId: string - runtimeSessionId: string + teamName: string; + memberName: string; + runId: string; + runtimeSessionId: string; }): string { const checkinPayload = { teamName: input.teamName, runId: input.runId, memberName: input.memberName, runtimeSessionId: input.runtimeSessionId, - } + }; const briefingPayload = { teamName: input.teamName, memberName: input.memberName, runtimeProvider: 'opencode', - } + }; return [ '', @@ -1105,7 +1108,7 @@ function buildOpenCodeRuntimeIdentityBlock(input: { `Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`, 'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.', '', - ].join('\n') + ].join('\n'); } ``` @@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({ memberName: name, runId, runtimeSessionId: record.opencodeSessionId, -}) +}); await openCodeSessionBridge.promptAsync(record, { text: `${runtimeIdentityBlock}\n\n${prompt}`, agent: 'teammate', -}) +}); ``` Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`. @@ -1135,13 +1138,13 @@ Do not add this as a serial wait inside the existing member loop. Options: -Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC +Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC Easy, but bad for UX. Three OpenCode teammates with an 8 second preview cap can add 24 seconds of launch latency. -Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC +Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC First ensure sessions and enqueue prompts for all members. Then run bounded preview/reconcile concurrently per prompted member with a small local concurrency cap. This fixes early false `created` without multiplying wait time by teammate count or opening one preview stream per teammate in large teams. -Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC +Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC Avoids launch delay, but keeps the stale/early UI state that caused OpenCode teammates to look unspawned or stuck. Chosen for v1: Option B with a local cap of 3 concurrent settle observers. Do not add a dependency just for this; use a tiny local mapper/helper in the orchestrator testable unit. @@ -1419,13 +1422,13 @@ Risk: Options: -Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC +Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC Smallest, but fragile and contradicts the semantic seam goal. -Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC +Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC Better recipient reliability, but still leaves confusing `SendMessage` wording inside the OpenCode prompt. -Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests +Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests Best shape. Codex/Claude keep the existing persisted inbox text because they read inbox files directly. OpenCode inbox rows stay clean/retryable with base user text, while OpenCode receives explicit runtime delivery metadata through the adapter/relay. Chosen for v1: Option C. @@ -1512,7 +1515,9 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) '', '', input.text, - ].filter((line): line is string => line !== null).join('\n'); + ] + .filter((line): line is string => line !== null) + .join('\n'); } ``` @@ -1569,13 +1574,13 @@ Risk: Options: -Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC +Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC This helps debugging but keeps the user-facing contract dishonest. -Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests +Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests This keeps native persistence behavior unchanged, makes OpenCode failure visible, keeps retry routing in one OpenCode inbox relay path, and fixes the existing dead caller catch path that controls pending-reply cleanup. -Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC +Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC This is the best long-term reliability shape, but it is too much to bundle into the semantic messaging seam unless delivery reliability remains flaky after v1. Chosen for v1: Option B. @@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => { }); throw error; } -} +}; ``` Update call sites so pending-reply state reflects actual delivery truth: @@ -1730,13 +1735,13 @@ Risk examples: Options: -Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC +Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC This leaves OpenCode-to-OpenCode and system notification routes unreliable. It is not enough for a real team messaging seam. -Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests +Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests This preserves native behavior, routes only recipients whose provider is OpenCode, and makes any persisted inbox row deliverable to live OpenCode lanes. -Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC +Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC Architecturally clean long-term, but too large for this seam and risky with existing native watchers. Chosen for v1: Option B. @@ -1873,13 +1878,13 @@ File: First decide how the required teammate-operational tool list is owned. -Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC +Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC This removes list duplication, but it adds a new package dependency from the runtime/orchestrator repo into the app controller package. That is a larger architecture decision than this fix needs. -Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC +Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC This matches the current repo boundary. The orchestrator only needs plain MCP names for direct `Client.listTools()` proof. Add tests that fail when critical teammate tools like `message_send`, `member_briefing`, `task_start`, or `cross_team_send` are missing. -Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC +Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC This is the best long-term shape, but it needs generation, publishing, and CI checks. Treat it as a follow-up after v1 proves the semantic seam. Chosen for v1: Option B. Do not import `agent-teams-controller` into `agent_teams_orchestrator` in this change. @@ -1904,7 +1909,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ 'runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat', -] as const +] as const; ``` Change to route-specific direct MCP names. @@ -1913,10 +1918,10 @@ Important: - `Client.listTools()` returns plain names such as `message_send`. - Do not prefix direct stdio results with `agent-teams_`. -- Only OpenCode app/API tool-id proof and production E2E evidence should deal with canonical ids like `agent-teams_message_send`. +- Only OpenCode app/API tool-id proof should deal with canonical ids like `agent-teams_message_send`. - `agent_teams_message_send` is an accepted alias, not the canonical id produced by `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')`. - The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness. -- `readiness.evidence.observedMcpTools` is already consumed by production evidence as OpenCode tool ids. Keep that public field canonical. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`. +- Keep `readiness.evidence.observedMcpTools` canonical if it is exposed through the bridge. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`. ```ts const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ @@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ 'runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat', -] as const +] as const; const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [ 'member_briefing', @@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [ 'cross_team_send', 'cross_team_list_targets', 'cross_team_get_outbox', -] as const +] as const; const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [ ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, -] as const +] as const; ``` Update direct listTools mapping: ```ts return (result.tools ?? []) - .map(tool => tool.name) - .filter((name): name is string => typeof name === 'string' && name.trim().length > 0) + .map((tool) => tool.name) + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0); ``` Compare plain names internally: @@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence: ```ts function buildOpenCodeCanonicalMcpToolId(toolName: string): string { - return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}` + return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`; } function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof { - const observedDirect = new Set(observedDirectToolNames) + const observedDirect = new Set(observedDirectToolNames); const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter( - tool => !observedDirect.has(tool) - ) + (tool) => !observedDirect.has(tool) + ); return { ok: missingTools.length === 0, @@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App ), observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames), missingTools, - diagnostics: missingTools.length === 0 - ? [] - : [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`], + diagnostics: + missingTools.length === 0 + ? [] + : [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`], route, - } + }; } ``` @@ -2093,96 +2099,26 @@ Acceptance: - Existing callers that truly mean runtime schema tools should use `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS`, not the full app list. - Existing callers that mean launch-visible app tools should use `REQUIRED_AGENT_TEAMS_APP_TOOLS` or `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`. -### Step 12 - Update production E2E gate to prove the same app tools +### Step 12 - Keep app tool proof in readiness only Files: ```text /Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts -/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts -/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts -/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts ``` -Current risk: +Current rule: -```ts -requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) -) -``` - -That is weaker than the planned app-tool readiness proof. It can create two bad states: - -- Production gate passes an artifact that proves runtime tools only, while OpenCode can still miss `message_send` or `member_briefing`. -- Production gate fails confusingly after the required list changes because old evidence was generated with the weaker runtime-only set. - -Change `OpenCodeReadinessBridge.applyProductionE2EGate()` to use the full app tool id list: - -```ts -import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability'; - -// ... -requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, -``` - -Update the live evidence builder in `OpenCodeProductionGate.live.test.ts`. - -Current builder path: - -```ts -mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), - observedTools: input.readinessObservedTools, -}, -``` - -Change it to: - -```ts -mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - observedTools: input.readinessObservedTools, -}, -``` - -Guard the shape explicitly because `input.readinessObservedTools` comes from the orchestrator bridge: - -```ts -const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter( - (toolId) => !input.readinessObservedTools.includes(toolId) -); -expect(missingObservedAppToolIds).toEqual([]); -``` - -If that assertion fails after Step 10, the orchestrator is probably returning plain direct tool names in `readiness.evidence.observedMcpTools`. Fix the bridge output instead of weakening production evidence. - -Keep the evidence validator exact: - -```ts -const observedTools = new Set(evidence.mcpTools.observedTools); -const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); -``` - -Do not add alias fallback here. Evidence should prove the canonical OpenCode app ids that production readiness expects. Alias tolerance belongs in transcript/capture parsing, not in production artifact gating. - -Update live/evidence test fixtures: - -```ts -const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; -``` - -`scripts/prove-opencode-production.mjs` should not need evidence JSON logic changes because it only launches the live test. Do update its console copy only if it refers to runtime-only proof. The real acceptance point is the artifact written by `OpenCodeProductionGate.live.test.ts`. +- OpenCode launch readiness should not require a project-scoped proof artifact. +- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks. +- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly. Acceptance: -- Production evidence must include canonical ids for runtime and teammate-operational tools. -- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests. -- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools. -- Dogfood mode can still warn instead of blocking if current policy already allows degraded evidence there. -- No schema validation is added for all operational tools in this step. That would be a separate hardening pass. +- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests. +- Missing app MCP tools fails readiness directly with a clear diagnostic. +- No project-specific artifact is required to create or launch a team. ### Step 13 - Resolve secondary lane current-run evidence from lane manifest @@ -2268,10 +2204,7 @@ currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), Change to async durable resolution: ```ts -const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId( - input.teamName, - input.laneId -); +const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId); await store.assertEvidenceAccepted({ teamName: input.teamName, @@ -2384,7 +2317,7 @@ emit: (event) => { teamName: event.teamName, detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined, }); -} +}; ``` Reason: @@ -2421,62 +2354,62 @@ Do not add a frontend fake "agent answered" path. Frontend may show "message sav These are the places most likely to produce regressions if implemented casually. -1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests -`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident. +1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests + `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident. -2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests -The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract. +2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests + The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract. -3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests -`runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. +3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests + `runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. -4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests -Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious. +4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests + Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious. -5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests -The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness. +5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests + The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness. -6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge -`OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass. +6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge + `OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass. -7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests -OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. +7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests + OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. -8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests -The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`. +8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests + The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`. -8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests +8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests Alias parsing must accept plain `message_send` for OpenCode direct MCP proof/capture, but a plain name alone is not enough in arbitrary transcripts. For capture/log paths, require Agent Teams payload shape and current team match before treating a plain short name as our tool. Canonical/prefixed names remain accepted directly. -9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests -`activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1. +9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests + `activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1. -10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end -Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. +10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end + Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. -11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests -OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`. +11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests + OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`. -12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests -`RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. +12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests + `RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. -13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests -The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact. +13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests + The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact. -14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests -The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures. +14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests + The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures. -15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests -`runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested. +15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests + `runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested. -16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests -The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`. +16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests + The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`. -17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests -Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly. +17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests + Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly. -18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane -The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam. +18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane + The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam. ## Tests @@ -2547,7 +2480,9 @@ it('persists taskRefs through message_send', async () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8') + ); expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); }); ``` @@ -2574,7 +2509,9 @@ it('keeps legacy user-to-member message_send valid without from', async () => { text: 'Please check this', }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8') + ); expect(rows.at(-1)).toMatchObject({ from: 'user', to: 'alice', @@ -2609,7 +2546,9 @@ it('canonicalizes message_send lead aliases before writing inbox files', async ( }); expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true); - expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(false); + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe( + false + ); }); ``` @@ -2624,7 +2563,9 @@ it('canonicalizes message_send sender aliases before persistence', async () => { text: 'Please review', }); - const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + const rows = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8') + ); expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' }); }); ``` @@ -2682,7 +2623,9 @@ it('keeps configured dotted local members valid before applying cross-team heuri text: 'Local dotted member', }); - expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe( + true + ); }); ``` @@ -2717,11 +2660,18 @@ it('persists taskRefs through cross_team_send when enabled', async () => { }); const targetInbox = JSON.parse( - fs.readFileSync(path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), 'utf8') + fs.readFileSync( + path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), + 'utf8' + ) ); - expect(targetInbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); + expect(targetInbox.at(-1).taskRefs).toEqual([ + { teamName, taskId: 'task-1', displayId: 'abcd1234' }, + ]); - const outbox = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')); + const outbox = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8') + ); expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); }); ``` @@ -2812,70 +2762,19 @@ it('does not classify unrelated plain message_send tool_use without Agent Teams }); ``` -Add app-side OpenCode readiness/evidence tests: +Add app-side OpenCode readiness tests: ```ts -it('uses full app tool ids for OpenCode production E2E gate expectations', async () => { - const evidence = buildEvidence({ - observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }); - +it('uses full app tool ids for OpenCode readiness expectations', async () => { const result = await bridge.runReadiness({ - launchMode: 'production', selectedModel: 'minimax-m2.5-free', // ... }); expect(result.supportLevel).toBe('production_supported'); - expect(productionE2eEvidence.read).toHaveBeenCalled(); -}); -``` - -```ts -it('rejects stale runtime-only OpenCode production evidence', async () => { - const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + expect(result.evidence.observedMcpTools).toEqual( + expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS) ); - const evidence = buildEvidence({ - observedTools: runtimeOnlyToolIds, - requiredTools: runtimeOnlyToolIds, - }); - - const gate = assertOpenCodeProductionE2EArtifactGate({ - evidence, - artifactPath: '/tmp/opencode-e2e.json', - expected: { - requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - }, - }); - - expect(gate.ok).toBe(false); - expect(gate.diagnostics.join('\n')).toContain('agent-teams_message_send'); - expect(gate.diagnostics.join('\n')).toContain('agent-teams_member_briefing'); -}); -``` - -```ts -it('live production evidence builder writes full app tool ids', () => { - const evidence = buildCandidateEvidence({ - readinessObservedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, - // other required live-test fields - }); - - expect(evidence.mcpTools.requiredTools).toEqual(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS); - expect(evidence.mcpTools.observedTools).toContain('agent-teams_message_send'); - expect(evidence.mcpTools.observedTools).toContain('agent-teams_cross_team_send'); -}); -``` - -```ts -it('live production evidence builder rejects plain direct tool names for artifact output', () => { - expect(() => - buildCandidateEvidence({ - readinessObservedTools: ['message_send', 'member_briefing'], - // other required live-test fields - }) - ).toThrow(/agent-teams_message_send/); }); ``` @@ -3437,7 +3336,7 @@ Run targeted tests first: cd /Users/belief/dev/projects/claude/claude_team pnpm --filter agent-teams-controller test -- test/controller.test.js pnpm --filter agent-teams-mcp test -- test/tools.test.ts -pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/main/services/team/OpenCodeProductionE2EEvidence.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx ``` ```bash @@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass. 19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking. 20. Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids. 21. Expand app-side OpenCode MCP availability proof from controller catalog. -22. Update production E2E gate and evidence fixtures to require the full app tool id list. +22. Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts. 23. Add lane-scoped manifest `activeRunId` recovery and consume it in evidence acceptance/message delivery/runtime delivery service. 24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests. 25. Add tests. @@ -3534,9 +3433,7 @@ Avoid heavy E2E until targeted tests pass. - Readiness passes while `message_send` is missing. This means proof list is still incomplete. - Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools. - Direct MCP readiness fails even though `tools/list` contains `message_send`. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids. -- Production live evidence fails after direct MCP proof succeeds. This usually means the orchestrator started exposing plain names in `readiness.evidence.observedMcpTools`; keep plain names internal and expose canonical `agent-teams_*` ids there. -- Production mode passes with runtime-only evidence. This means `OpenCodeReadinessBridge` still uses `REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS` instead of the full app tool id list. -- Production mode blocks with stale evidence after this change. That is expected until the OpenCode production E2E artifact is regenerated, but the diagnostic must list the missing app tools clearly. +- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list. - App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact. - OpenCode member stays `created` even though the prompt was accepted. This usually means `promptAsync()` was reconciled too early; use the bounded launch-settle helper before final launch mapping. - Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending. @@ -3557,7 +3454,6 @@ Avoid heavy E2E until targeted tests pass. - OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use `agent-teams_message_send`. - OpenCode cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`. - OpenCode readiness fails if required app MCP tools are absent. -- OpenCode production E2E gate proves the same app MCP tools that readiness requires. - Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence. - Runtime tool descriptions make `message_send` the normal visible reply API and keep `runtime_deliver_message` scoped to explicit low-level runtime delivery flows. - OpenCode can prove liveness through `runtime_bootstrap_checkin`. diff --git a/package.json b/package.json index 3d17935f..acb8da72 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "dev": "node ./scripts/dev-with-runtime.mjs", "dev:web": "node ./scripts/dev-web.mjs", "dev:kill": "node bin/kill-dev.js", - "opencode:prove-production": "node ./scripts/prove-opencode-production.mjs", "opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs", "opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs", "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 9b1a254c..cf7b21cc 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -27,7 +27,8 @@ export type GraphLaunchVisualState = | 'registered_only' | 'stale_runtime' | 'settling' - | 'error'; + | 'error' + | 'skipped'; // ─── Edge & Particle Types ─────────────────────────────────────────────────── @@ -86,7 +87,7 @@ export interface GraphNode { /** Avatar image URL (e.g., robohash) */ avatarUrl?: string; /** Spawn lifecycle status */ - spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped'; /** Shared launch-stage visual derived by the host app */ launchVisualState?: GraphLaunchVisualState; /** Shared launch-stage text shown beside the node during launch only */ diff --git a/scripts/prove-opencode-production.mjs b/scripts/prove-opencode-production.mjs deleted file mode 100644 index 6c3c221c..00000000 --- a/scripts/prove-opencode-production.mjs +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath } from 'node:url'; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, '..'); -const defaultEvidencePath = path.join( - resolveAppDataDir(), - 'Agent Teams UI', - 'opencode-bridge', - 'production-e2e-evidence.json' -); -const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); -const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator'); - -const env = { - ...process.env, - OPENCODE_E2E: '1', - OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot, - OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle', - OPENCODE_E2E_WRITE_APP_EVIDENCE: '1', - OPENCODE_E2E_WRITE_EVIDENCE_PATH: - process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() || - process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() || - defaultEvidencePath, - OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', -}; - -if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) { - const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator; - env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli'); -} - -console.log('Running OpenCode production proof'); -console.log(`Model: ${env.OPENCODE_E2E_MODEL}`); -console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`); -console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`); -console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); - -const result = spawnSync( - 'pnpm', - ['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'], - { - cwd: repoRoot, - env, - stdio: 'inherit', - shell: process.platform === 'win32', - } -); - -if (result.error) { - console.error(`Failed to run OpenCode production proof: ${result.error.message}`); - process.exit(1); -} - -process.exit(result.status ?? 1); - -function resolveAppDataDir() { - if (process.platform === 'darwin') { - return path.join(os.homedir(), 'Library', 'Application Support'); - } - - if (process.platform === 'win32') { - return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - } - - return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); -} diff --git a/src/main/index.ts b/src/main/index.ts index f0df99ac..4d1e452e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -116,9 +116,6 @@ import { OpenCodeBridgeCommandHandshakePort, } from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv'; -import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath'; -import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { buildTeamControlApiBaseUrl, @@ -273,13 +270,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedMemberName = validateMemberName(memberName); + if (!validatedMemberName.valid) { + return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; + } + return wrapTeamHandler('skipMemberForLaunch', async () => + getTeamProvisioningService().skipMemberForLaunch( + validatedTeamName.value!, + validatedMemberName.value! + ) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 30dcd444..1146cf47 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -52,6 +52,8 @@ const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes /** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */ const CATCH_UP_SCAN_BUDGET = 24; +/** Retire one file from catch-up after repeated local stat timeouts. */ +const CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT = 3; interface AppendedParseResult { messages: ParsedMessage[]; @@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter { private catchUpInProgress = false; /** Round-robin cursor so catch-up work is spread across tracked files. */ private catchUpCursor = 0; + /** Consecutive catch-up stat timeouts per file. */ + private catchUpStatFailures = new Map(); /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ @@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); @@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.polledFileSizes.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); @@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.delete(filePath); this.lastProcessedSize.delete(filePath); this.activeSessionFiles.delete(filePath); + this.catchUpStatFailures.delete(filePath); } /** @@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpStatFailures.clear(); this.catchUpCursor = 0; this.catchUpInProgress = false; } @@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter { } const stats = await this.fsProvider.stat(filePath); + this.catchUpStatFailures.delete(filePath); // Skip files not modified recently if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { @@ -1145,6 +1154,8 @@ export class FileWatcher extends EventEmitter { // File may have been deleted between iterations if ((err as NodeJS.ErrnoException).code === 'ENOENT') { this.clearErrorTracking(filePath); + } else if (this.isStatTimeoutError(err)) { + this.handleCatchUpStatTimeout(filePath); } else { logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err); } @@ -1156,6 +1167,32 @@ export class FileWatcher extends EventEmitter { } } + private isStatTimeoutError(err: unknown): boolean { + return err instanceof Error && err.message === 'stat timeout'; + } + + private handleCatchUpStatTimeout(filePath: string): void { + const failures = (this.catchUpStatFailures.get(filePath) ?? 0) + 1; + + if (failures >= CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT) { + logger.warn( + `FileWatcher: Retiring ${filePath} from catch-up after ${failures} stat timeouts` + ); + this.retireCatchUpFile(filePath); + return; + } + + this.catchUpStatFailures.set(filePath, failures); + logger.debug( + `FileWatcher: Catch-up stat timeout for ${filePath} (${failures}/${CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT})` + ); + } + + private retireCatchUpFile(filePath: string): void { + this.activeSessionFiles.delete(filePath); + this.catchUpStatFailures.delete(filePath); + } + // =========================================================================== // Debouncing // =========================================================================== diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index e010533b..a6f65031 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -39,6 +39,9 @@ type RuntimeMemberSpawnState = Pick< | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailure' + | 'skippedForLaunch' + | 'skipReason' + | 'skippedAt' | 'pendingPermissionRequestIds' | 'livenessKind' | 'runtimeDiagnostic' @@ -130,6 +133,8 @@ function buildDiagnostics( | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' + | 'skippedForLaunch' + | 'skipReason' | 'sources' | 'pendingPermissionRequestIds' > @@ -145,6 +150,13 @@ function buildDiagnostics( } if (member.hardFailureReason) diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); + if (member.skippedForLaunch) { + diagnostics.push( + member.skipReason + ? `skipped for this launch: ${member.skipReason}` + : 'skipped for this launch' + ); + } if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); if (member.sources?.configDrift) diagnostics.push('config drift detected'); return diagnostics; @@ -159,6 +171,9 @@ export function deriveTeamLaunchAggregateState( if (summary.pendingCount > 0) { return 'partial_pending'; } + if ((summary.skippedCount ?? 0) > 0) { + return 'partial_skipped'; + } return 'clean_success'; } @@ -169,6 +184,7 @@ export function summarizePersistedLaunchMembers( let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; let shellOnlyPendingCount = 0; let runtimeProcessPendingCount = 0; @@ -193,6 +209,10 @@ export function summarizePersistedLaunchMembers( confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; @@ -223,6 +243,7 @@ export function summarizePersistedLaunchMembers( confirmedCount, pendingCount, failedCount, + skippedCount, runtimeAlivePendingCount, shellOnlyPendingCount, runtimeProcessPendingCount, @@ -260,9 +281,13 @@ function deriveMemberLaunchState( | 'bootstrapConfirmed' | 'runtimeAlive' | 'agentToolAccepted' + | 'skippedForLaunch' | 'pendingPermissionRequestIds' > ): MemberLaunchState { + if (member.skippedForLaunch) { + return 'skipped_for_launch'; + } if (member.hardFailure) { return 'failed_to_start'; } @@ -390,14 +415,19 @@ function normalizePersistedMemberState( return null; } const providerId = normalizeOptionalTeamProviderId(parsed.providerId); + const skippedForLaunch = + toBoolean(parsed.skippedForLaunch) || parsed.launchState === 'skipped_for_launch'; const bootstrapConfirmed = - toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive'; + !skippedForLaunch && + (toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive'); const livenessKind = normalizeLivenessKind(parsed.livenessKind); - const runtimeAlive = preservesStrongRuntimeAlive({ - runtimeAlive: toBoolean(parsed.runtimeAlive), - bootstrapConfirmed, - livenessKind, - }); + const runtimeAlive = skippedForLaunch + ? false + : preservesStrongRuntimeAlive({ + runtimeAlive: toBoolean(parsed.runtimeAlive), + bootstrapConfirmed, + livenessKind, + }); const sources = normalizeSources(parsed.sources) ?? {}; if (!runtimeAlive) { sources.processAlive = undefined; @@ -431,12 +461,16 @@ function normalizePersistedMemberState( laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId), launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId), launchState: 'starting', - agentToolAccepted: toBoolean(parsed.agentToolAccepted), + skippedForLaunch, + skipReason: normalizeOptionalString(parsed.skipReason), + skippedAt: normalizeOptionalString(parsed.skippedAt), + agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted), runtimeAlive, bootstrapConfirmed, - hardFailure: toBoolean(parsed.hardFailure), - hardFailureReason: - typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 + hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure), + hardFailureReason: skippedForLaunch + ? undefined + : typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 ? parsed.hardFailureReason.trim() : undefined, pendingPermissionRequestIds: normalizePendingPermissionRequestIds( @@ -469,7 +503,8 @@ function normalizePersistedMemberState( parsed.launchState === 'runtime_pending_bootstrap' || parsed.launchState === 'runtime_pending_permission' || parsed.launchState === 'confirmed_alive' || - parsed.launchState === 'failed_to_start' + parsed.launchState === 'failed_to_start' || + parsed.launchState === 'skipped_for_launch' ? parsed.launchState : deriveMemberLaunchState(next); next.launchState = launchState; @@ -511,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: { members[name] = { name, launchState: 'starting', + skippedForLaunch: false, agentToolAccepted: false, runtimeAlive: false, bootstrapConfirmed: false, @@ -587,18 +623,29 @@ export function snapshotFromRuntimeMemberStatuses(params: { sources.nativeHeartbeat = true; sources.inboxHeartbeat = true; } - const runtimeAlive = preservesStrongRuntimeAlive(runtime); + const skippedForLaunch = + runtime?.skippedForLaunch === true || runtime?.launchState === 'skipped_for_launch'; + const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(runtime); if (runtime?.livenessSource === 'process' && runtimeAlive) { sources.processAlive = true; } const entry: PersistedTeamLaunchMemberState = { name, launchState: runtime?.launchState ?? 'starting', - agentToolAccepted: runtime?.agentToolAccepted === true, + skippedForLaunch, + skipReason: runtime?.skipReason, + skippedAt: runtime?.skippedAt, + agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true, runtimeAlive, - bootstrapConfirmed: runtime?.bootstrapConfirmed === true, - hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', - hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true, + hardFailure: + runtime?.launchState === 'skipped_for_launch' + ? false + : runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', + hardFailureReason: + runtime?.launchState === 'skipped_for_launch' + ? undefined + : (runtime?.hardFailureReason ?? runtime?.error), pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length ? [...new Set(runtime.pendingPermissionRequestIds)] : undefined, @@ -644,9 +691,13 @@ export function snapshotToMemberSpawnStatuses( if (!entry) continue; let status: MemberSpawnStatusEntry['status'] = 'offline'; let livenessSource: MemberSpawnLivenessSource | undefined; - const runtimeAlive = preservesStrongRuntimeAlive(entry); + const skippedForLaunch = + entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true; + const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry); if (entry.launchState === 'failed_to_start') { status = 'error'; + } else if (entry.launchState === 'skipped_for_launch') { + status = 'skipped'; } else if (entry.launchState === 'confirmed_alive') { status = 'online'; livenessSource = 'heartbeat'; @@ -664,11 +715,14 @@ export function snapshotToMemberSpawnStatuses( launchState: entry.launchState, error: entry.hardFailure ? entry.hardFailureReason : undefined, hardFailureReason: entry.hardFailureReason, + skippedForLaunch: entry.skippedForLaunch, + skipReason: entry.skipReason, + skippedAt: entry.skippedAt, livenessSource, - agentToolAccepted: entry.agentToolAccepted, + agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted, runtimeAlive, - bootstrapConfirmed: entry.bootstrapConfirmed, - hardFailure: entry.hardFailure, + bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed, + hardFailure: skippedForLaunch ? false : entry.hardFailure, pendingPermissionRequestIds: entry.pendingPermissionRequestIds, livenessKind: entry.livenessKind, runtimeDiagnostic: entry.runtimeDiagnostic, diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts index 1a69ffe9..48345c60 100644 --- a/src/main/services/team/TeamLaunchSummaryProjection.ts +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -13,11 +13,13 @@ export interface LaunchStateSummary { expectedMemberCount?: number; confirmedMemberCount?: number; missingMembers?: string[]; + skippedMembers?: string[]; teamLaunchState?: TeamSummary['teamLaunchState']; launchUpdatedAt?: string; confirmedCount?: number; pendingCount?: number; failedCount?: number; + skippedCount?: number; runtimeAlivePendingCount?: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; @@ -60,6 +62,10 @@ export function createLaunchStateSummary( const member = snapshot.members[name]; return member?.launchState === 'failed_to_start'; }); + const skippedMembers = persistedMemberNames.filter((name) => { + const member = snapshot.members[name]; + return member?.launchState === 'skipped_for_launch' || member?.skippedForLaunch === true; + }); return { ...(snapshot.teamLaunchState === 'partial_failure' @@ -72,11 +78,13 @@ export function createLaunchStateSummary( ? { confirmedMemberCount: snapshot.summary.confirmedCount } : {}), ...(missingMembers.length > 0 ? { missingMembers } : {}), + ...(skippedMembers.length > 0 ? { skippedMembers } : {}), teamLaunchState: snapshot.teamLaunchState, launchUpdatedAt: snapshot.updatedAt, confirmedCount: snapshot.summary.confirmedCount, pendingCount: snapshot.summary.pendingCount, failedCount: snapshot.summary.failedCount, + skippedCount: snapshot.summary.skippedCount, runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount, runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount, @@ -138,8 +146,17 @@ export function normalizePersistedLaunchSummaryProjection( normalized.missingMembers = missingMembers; } } + if (Array.isArray(record.skippedMembers)) { + const skippedMembers = record.skippedMembers.filter( + (member): member is string => typeof member === 'string' && member.trim().length > 0 + ); + if (skippedMembers.length > 0) { + normalized.skippedMembers = skippedMembers; + } + } if ( record.teamLaunchState === 'partial_failure' || + record.teamLaunchState === 'partial_skipped' || record.teamLaunchState === 'partial_pending' || record.teamLaunchState === 'clean_success' ) { @@ -154,6 +171,9 @@ export function normalizePersistedLaunchSummaryProjection( if (typeof record.failedCount === 'number' && record.failedCount >= 0) { normalized.failedCount = record.failedCount; } + if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) { + normalized.skippedCount = record.skippedCount; + } if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) { normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 682c3c3a..18c30523 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -563,32 +563,6 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2; -const OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC = - 'OpenCode production E2E evidence artifact has no entry for the current working directory'; -const OPENCODE_PROJECT_EVIDENCE_NOTE = - 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.'; - -function pushUniqueProvisioningWarning(warnings: string[], warning: string): void { - if (!warnings.includes(warning)) { - warnings.push(warning); - } -} - -function isOpenCodeProjectEvidenceMissingDiagnostic(value: string): boolean { - return value.trim() === OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC; -} - -function isOpenCodeProjectEvidenceMissingPrepareFailure( - prepare: TeamRuntimePrepareResult -): boolean { - if (prepare.ok || prepare.reason !== 'e2e_missing') { - return false; - } - const diagnostics = prepare.diagnostics - .map((diagnostic) => diagnostic.trim()) - .filter((diagnostic) => diagnostic.length > 0); - return diagnostics.length > 0 && diagnostics.every(isOpenCodeProjectEvidenceMissingDiagnostic); -} function applyDistinctProvisioningMemberColors< T extends { name: string; color?: string; removedAt?: number }, @@ -1576,6 +1550,7 @@ function summarizeMemberSpawnStatusRecord( let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; let shellOnlyPendingCount = 0; let runtimeProcessPendingCount = 0; @@ -1594,6 +1569,10 @@ function summarizeMemberSpawnStatusRecord( confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; @@ -1624,6 +1603,7 @@ function summarizeMemberSpawnStatusRecord( confirmedCount, pendingCount, failedCount, + skippedCount, runtimeAlivePendingCount, shellOnlyPendingCount, runtimeProcessPendingCount, @@ -1828,8 +1808,12 @@ function deriveMemberLaunchState(entry: { runtimeAlive?: boolean; bootstrapConfirmed?: boolean; hardFailure?: boolean; + skippedForLaunch?: boolean; pendingPermissionRequestIds?: string[]; }): MemberLaunchState { + if (entry.skippedForLaunch) { + return 'skipped_for_launch'; + } if (entry.hardFailure) { return 'failed_to_start'; } @@ -2364,7 +2348,9 @@ ${getAgentLanguageInstruction()} Your FIRST action: call MCP tool member_briefing with: { teamName: "${teamName}", memberName: "${member.name}" } Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} @@ -2393,7 +2379,9 @@ The team has just been reconnected after a restart. Your FIRST action: call MCP tool member_briefing with: { teamName: "${teamName}", memberName: "${member.name}" } Call member_briefing directly. Do NOT use Agent, any subagent, or any delegated helper for this step. -If member_briefing fails, SendMessage "${leadName}" one short natural-language sentence with the exact error text. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} @@ -2439,7 +2427,9 @@ Your FIRST action: call MCP tool member_briefing with: Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. -If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed". +If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. +If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". +Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). ${getCanonicalSendMessageFieldRule()} Correct example: @@ -2513,7 +2503,9 @@ ${providerArgLine}${modelArgLine}${effortArgLine} - prompt: Call member_briefing directly as your own MCP tool call. Do NOT use the Agent tool, any subagent, or any delegated helper for this step. member_briefing is expected to be available in your initial MCP tool list. If it is missing or unavailable, treat that as a real bootstrap error and report the exact error text to your team lead. Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds. - If member_briefing fails, send one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then wait. Do NOT send only "bootstrap failed". + If tool search says agent-teams is still connecting, wait briefly and retry tool search at most once. + If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". + Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). ${buildTeammateAgentBlockReminder()} ${actionModeProtocol} @@ -6754,6 +6746,9 @@ export class TeamProvisioningService { }; if (status === 'spawning') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = false; next.runtimeAlive = false; next.bootstrapConfirmed = false; @@ -6769,6 +6764,9 @@ export class TeamProvisioningService { next.lastHeartbeatAt = undefined; next.launchState = 'starting'; } else if (status === 'waiting') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = true; next.runtimeAlive = false; next.bootstrapConfirmed = false; @@ -6784,6 +6782,9 @@ export class TeamProvisioningService { next.lastHeartbeatAt = undefined; next.launchState = 'runtime_pending_bootstrap'; } else if (status === 'online') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.agentToolAccepted = true; next.runtimeAlive = true; next.livenessSource = livenessSource; @@ -6803,14 +6804,39 @@ export class TeamProvisioningService { next.hardFailureReason = undefined; next.launchState = deriveMemberLaunchState(next); } else if (status === 'error') { + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.error = error; next.hardFailure = true; next.hardFailureReason = error; next.launchState = 'failed_to_start'; + } else if (status === 'skipped') { + next.skippedForLaunch = true; + next.skipReason = + error?.trim() || prev.hardFailureReason || prev.error || 'Skipped for this launch'; + next.skippedAt = updatedAt; + next.agentToolAccepted = false; + next.runtimeAlive = false; + next.bootstrapConfirmed = false; + next.hardFailure = false; + next.error = undefined; + next.hardFailureReason = undefined; + next.livenessSource = undefined; + next.livenessKind = undefined; + next.runtimeDiagnostic = undefined; + next.runtimeDiagnosticSeverity = undefined; + next.livenessLastCheckedAt = undefined; + next.firstSpawnAcceptedAt = undefined; + next.lastHeartbeatAt = undefined; + next.launchState = 'skipped_for_launch'; } else if (status === 'offline') { Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt }); next.error = undefined; next.hardFailureReason = undefined; + next.skippedForLaunch = false; + next.skipReason = undefined; + next.skippedAt = undefined; next.livenessSource = undefined; next.livenessKind = undefined; next.runtimeDiagnostic = undefined; @@ -6826,6 +6852,9 @@ export class TeamProvisioningService { prev.launchState === next.launchState && prev.error === next.error && prev.hardFailureReason === next.hardFailureReason && + (prev.skippedForLaunch === true) === (next.skippedForLaunch === true) && + prev.skipReason === next.skipReason && + prev.skippedAt === next.skippedAt && prev.livenessSource === next.livenessSource && prev.agentToolAccepted === next.agentToolAccepted && prev.runtimeAlive === next.runtimeAlive && @@ -6844,7 +6873,8 @@ export class TeamProvisioningService { if ( (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || status === 'offline' || - status === 'error' + status === 'error' || + status === 'skipped' ) { run.pendingMemberRestarts?.delete(memberName); } @@ -6885,6 +6915,14 @@ export class TeamProvisioningService { memberName, error?.trim().length ? error.trim() : 'bootstrap failed' ); + } else if (status === 'skipped') { + this.appendMemberBootstrapDiagnostic( + run, + memberName, + error?.trim().length + ? `skipped for this launch: ${error.trim()}` + : 'skipped for this launch' + ); } if (!this.isCurrentTrackedRun(run)) return; this.emitMemberSpawnChange(run, memberName); @@ -7515,6 +7553,144 @@ export class TeamProvisioningService { } } + async skipMemberForLaunch(teamName: string, memberName: string): Promise { + const normalizedMemberName = memberName.trim(); + if (!normalizedMemberName) { + throw new Error('Member name is required'); + } + + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team "${teamName}" configuration is no longer available`); + } + + let metaMembers: Awaited> = []; + try { + metaMembers = await this.membersMetaStore.getMembers(teamName); + } catch { + metaMembers = []; + } + + const configuredMember = this.resolveEffectiveConfiguredMember( + config.members ?? [], + metaMembers, + normalizedMemberName + ); + if (!configuredMember) { + throw new Error(`Member "${normalizedMemberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${normalizedMemberName}" has been removed`); + } + if (isLeadMember({ name: normalizedMemberName, agentType: configuredMember.agentType })) { + throw new Error('Lead cannot be skipped for a launch'); + } + + const runId = this.getTrackedRunId(teamName); + const run = runId ? this.runs.get(runId) : undefined; + const persistedSnapshot = await this.launchStateStore.read(teamName).catch(() => null); + const runEntry = run?.memberSpawnStatuses.get(normalizedMemberName); + const persistedMember = persistedSnapshot?.members[normalizedMemberName]; + const alreadySkipped = + runEntry?.launchState === 'skipped_for_launch' || + runEntry?.skippedForLaunch === true || + persistedMember?.launchState === 'skipped_for_launch' || + persistedMember?.skippedForLaunch === true; + + if (alreadySkipped) { + return; + } + + const failedThisLaunch = + runEntry?.launchState === 'failed_to_start' || + runEntry?.status === 'error' || + persistedMember?.launchState === 'failed_to_start' || + persistedMember?.hardFailure === true; + if (!failedThisLaunch) { + throw new Error(`Member "${normalizedMemberName}" has not failed this launch`); + } + + if (run?.pendingMemberRestarts.has(normalizedMemberName)) { + throw new Error(`Restart for teammate "${normalizedMemberName}" is already in progress`); + } + + const previousFailureReason = + runEntry?.hardFailureReason ?? + runEntry?.error ?? + persistedMember?.hardFailureReason ?? + persistedMember?.runtimeDiagnostic; + const reason = previousFailureReason?.trim() + ? `Skipped by user after launch failure: ${previousFailureReason.trim()}` + : 'Skipped by user for this launch'; + + if (run && !run.processKilled && !run.cancelRequested) { + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.resetRuntimeToolActivity(run, normalizedMemberName); + this.clearMemberSpawnToolTracking(run, normalizedMemberName); + this.setMemberSpawnStatus(run, normalizedMemberName, 'skipped', reason); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot( + run, + run.provisioningComplete ? 'finished' : 'active' + ); + } + + try { + await this.sendMessageToRun( + run, + `Teammate "${normalizedMemberName}" was skipped for this launch after a startup failure. Continue without waiting for this teammate unless the user retries it.` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to notify lead about skipped teammate "${normalizedMemberName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return; + } + + if (!persistedSnapshot || !persistedMember) { + throw new Error(`No launch state is available for member "${normalizedMemberName}"`); + } + + const updatedAt = nowIso(); + const nextMembers = { + ...persistedSnapshot.members, + [normalizedMemberName]: { + ...persistedMember, + launchState: 'skipped_for_launch' as const, + skippedForLaunch: true, + skipReason: reason, + skippedAt: updatedAt, + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds: undefined, + livenessKind: undefined, + runtimeDiagnostic: undefined, + runtimeDiagnosticSeverity: undefined, + lastEvaluatedAt: updatedAt, + diagnostics: [`skipped for this launch: ${reason}`], + }, + }; + const nextSnapshot = createPersistedLaunchSnapshot({ + teamName: persistedSnapshot.teamName, + expectedMembers: persistedSnapshot.expectedMembers, + bootstrapExpectedMembers: persistedSnapshot.bootstrapExpectedMembers, + leadSessionId: persistedSnapshot.leadSessionId, + launchPhase: persistedSnapshot.launchPhase, + members: nextMembers, + updatedAt, + }); + await this.launchStateStore.write(teamName, nextSnapshot); + this.agentRuntimeSnapshotCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + } + private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun { const runId = this.getAliveRunId(teamName); if (!runId) { @@ -7801,7 +7977,11 @@ export class TeamProvisioningService { } return run.expectedMembers.every((memberName) => { const entry = run.memberSpawnStatuses.get(memberName); - return entry?.launchState === 'failed_to_start' || entry?.launchState === 'confirmed_alive'; + return ( + entry?.launchState === 'failed_to_start' || + entry?.launchState === 'confirmed_alive' || + entry?.launchState === 'skipped_for_launch' + ); }); } @@ -8267,12 +8447,6 @@ export class TeamProvisioningService { const primaryReason = prepare.diagnostics.find((entry) => entry.trim().length > 0) ?? prepare.reason; - if (isOpenCodeProjectEvidenceMissingPrepareFailure(prepare)) { - details.push(`Selected model ${modelId} verified for launch.`); - pushUniqueProvisioningWarning(warnings, OPENCODE_PROJECT_EVIDENCE_NOTE); - continue; - } - const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; if (prepare.retryable) { @@ -8362,14 +8536,6 @@ export class TeamProvisioningService { if (!sharedPrepare.ok) { const primaryReason = sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason; - if (isOpenCodeProjectEvidenceMissingPrepareFailure(sharedPrepare)) { - pushUniqueProvisioningWarning(warnings, OPENCODE_PROJECT_EVIDENCE_NOTE); - for (const modelId of modelIds) { - details.push(`Selected model ${modelId} verified for launch.`); - } - return { details, warnings, blockingMessages }; - } - for (const modelId of modelIds) { const unavailableLine = `Selected model ${modelId} is unavailable. ${primaryReason}`; const verificationWarningLine = `Selected model ${modelId} could not be verified. ${primaryReason}`; @@ -12378,7 +12544,9 @@ export class TeamProvisioningService { const current = run.memberSpawnStatuses.get(expected); if ( current?.launchState === 'failed_to_start' || - current?.launchState === 'confirmed_alive' + current?.launchState === 'confirmed_alive' || + current?.launchState === 'skipped_for_launch' || + current?.skippedForLaunch === true ) { continue; } @@ -12466,6 +12634,8 @@ export class TeamProvisioningService { const current = run.memberSpawnStatuses.get(expected); if ( current?.launchState === 'failed_to_start' || + current?.launchState === 'skipped_for_launch' || + current?.skippedForLaunch === true || current?.bootstrapConfirmed || current?.runtimeAlive ) { @@ -12504,6 +12674,22 @@ export class TeamProvisioningService { if (!current) { continue; } + if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) { + nextStatuses[resolvedStatusKey] = { + ...current, + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + livenessSource: undefined, + livenessLastCheckedAt: nowIso(), + }; + continue; + } const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); const nextEntry: MemberSpawnStatusEntry = { ...current, @@ -13196,6 +13382,7 @@ export class TeamProvisioningService { confirmedCount: number; pendingCount: number; failedCount: number; + skippedCount?: number; runtimeAlivePendingCount: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; @@ -13208,6 +13395,7 @@ export class TeamProvisioningService { let confirmedCount = 0; let pendingCount = 0; let failedCount = 0; + let skippedCount = 0; let runtimeAlivePendingCount = 0; let shellOnlyPendingCount = 0; let runtimeProcessPendingCount = 0; @@ -13220,6 +13408,10 @@ export class TeamProvisioningService { confirmedCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedCount += 1; + continue; + } if (entry.launchState === 'failed_to_start') { failedCount += 1; continue; @@ -13249,6 +13441,7 @@ export class TeamProvisioningService { confirmedCount, pendingCount, failedCount, + skippedCount, runtimeAlivePendingCount, shellOnlyPendingCount, runtimeProcessPendingCount, @@ -13339,13 +13532,18 @@ export class TeamProvisioningService { } const persistedMemberNames = this.getPersistedLaunchMemberNames(snapshot); - const allPendingMembers = persistedMemberNames.filter((memberName) => { - const member = snapshot.members[memberName]; - if (!member) { - return false; - } - return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; - }); + const allPendingMembers = persistedMemberNames + .filter((memberName) => { + const member = snapshot.members[memberName]; + if (!member) { + return false; + } + return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; + }) + .filter((memberName) => { + const member = snapshot.members[memberName]; + return member?.launchState !== 'skipped_for_launch'; + }); if ( allPendingMembers.length > 0 && allPendingMembers.every((memberName) => { @@ -13374,7 +13572,11 @@ export class TeamProvisioningService { if (!member) { return true; } - return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; + return ( + member.launchState !== 'confirmed_alive' && + member.launchState !== 'failed_to_start' && + member.launchState !== 'skipped_for_launch' + ); }); if (secondaryPendingMembers.length === 0) { return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary); diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 8c6d8b04..63c45d22 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -22,8 +22,6 @@ export type { export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge'; export { ReviewApplierService } from './ReviewApplierService'; export type { - OpenCodeTeamLaunchMode, - OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, TeamLaunchRuntimeAdapter, TeamRuntimeLaunchInput, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 39a0b95d..1ff3c554 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName = | 'opencode.getRuntimeTranscript' | 'opencode.recoverDeliveryJournal'; -export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production'; - export type OpenCodeTeamLaunchBridgeState = | 'blocked' | 'launching' @@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec { } export interface OpenCodeLaunchTeamCommandBody { - mode: OpenCodeTeamLaunchMode; runId: string; laneId: string; teamId: string; diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index f022e09e..7405f082 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -1,13 +1,3 @@ -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - type OpenCodeProductionE2EEvidence, -} from '../e2e/OpenCodeProductionE2EEvidence'; -import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, -} from '../mcp/OpenCodeMcpToolAvailability'; - import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { OpenCodeTeamLaunchReadiness, @@ -26,7 +16,6 @@ import type { OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, - OpenCodeTeamLaunchMode, } from './OpenCodeBridgeCommandContract'; import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService'; @@ -51,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions { sendTimeoutMs?: number; stopTimeoutMs?: number; stateChangingCommands?: Pick; - productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort; -} - -export interface OpenCodeProductionE2EEvidenceReadPort { - read(input?: { - selectedModel?: string | null; - projectPathFingerprint?: string | null; - opencodeVersion?: string | null; - binaryFingerprint?: string | null; - capabilitySnapshotId?: string | null; - }): Promise<{ - ok: boolean; - evidence: OpenCodeProductionE2EEvidence | null; - artifactPath: string; - diagnostics: string[]; - }>; } export interface OpenCodeReadinessBridgeCommandBody { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; } const DEFAULT_READINESS_TIMEOUT_MS = 120_000; @@ -106,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { if (result.ok) { this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime); - return this.applyProductionE2EGate({ - input, - readiness: result.data, - runtime: result.runtime, - }); + return result.data; } this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); @@ -125,86 +93,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { }); } - private async applyProductionE2EGate(input: { - input: OpenCodeReadinessBridgeCommandBody; - readiness: OpenCodeTeamLaunchReadiness; - runtime: OpenCodeBridgeRuntimeSnapshot; - }): Promise { - const launchMode = input.input.launchMode; - if (launchMode !== 'production' && launchMode !== 'dogfood') { - return input.readiness; - } - if (!input.readiness.launchAllowed) { - return input.readiness; - } - - const expectedModel = input.readiness.modelId ?? input.input.selectedModel; - const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath); - const evidenceRead = this.options.productionE2eEvidence - ? await this.options.productionE2eEvidence.read({ - selectedModel: expectedModel, - projectPathFingerprint, - opencodeVersion: input.runtime.version, - binaryFingerprint: input.runtime.binaryFingerprint, - capabilitySnapshotId: input.runtime.capabilitySnapshotId, - }) - : { - ok: false, - evidence: null, - artifactPath: '', - diagnostics: ['OpenCode production E2E evidence store is not configured'], - }; - const gate = evidenceRead.ok - ? assertOpenCodeProductionE2EArtifactGate({ - evidence: evidenceRead.evidence, - artifactPath: evidenceRead.artifactPath, - expected: { - opencodeVersion: input.runtime.version, - binaryFingerprint: input.runtime.binaryFingerprint, - capabilitySnapshotId: input.runtime.capabilitySnapshotId, - selectedModel: expectedModel, - projectPathFingerprint, - requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), - }, - }) - : { - ok: false, - diagnostics: evidenceRead.diagnostics, - }; - - if (gate.ok) { - return { - ...input.readiness, - diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]), - supportLevel: 'production_supported', - }; - } - - const diagnostics = dedupe([ - ...input.readiness.diagnostics, - ...evidenceRead.diagnostics, - ...gate.diagnostics, - ]); - if (launchMode === 'dogfood') { - return { - ...input.readiness, - supportLevel: 'supported_e2e_pending', - diagnostics, - }; - } - - return { - ...input.readiness, - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: dedupe([...input.readiness.missing, ...gate.diagnostics]), - diagnostics, - }; - } - getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null { return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null; } diff --git a/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts b/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts deleted file mode 100644 index ccabe61c..00000000 --- a/src/main/services/team/opencode/config/OpenCodeLaunchModeEnv.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; - -export const CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV = 'CLAUDE_TEAM_OPENCODE_LAUNCH_MODE'; -export const CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV = 'CLAUDE_TEAM_OPENCODE_DOGFOOD'; - -export function resolveOpenCodeTeamLaunchModeFromEnv( - env: NodeJS.ProcessEnv = process.env -): OpenCodeTeamLaunchMode { - const raw = env[CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV]?.trim().toLowerCase(); - if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') { - return raw; - } - if (env[CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV] === '1') { - return 'dogfood'; - } - return 'production'; -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts deleted file mode 100644 index 93a61e70..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { createHash } from 'node:crypto'; -import * as path from 'node:path'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1; -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [ - 'required_tools_proven', - 'delivery_ready', - 'member_ready', - 'run_ready', -] as const; - -export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [ - 'app_mcp_tools_visible', - 'state_changing_launch_completed', - 'session_records_persisted', - 'bootstrap_confirmed_alive', - 'canonical_log_projection_observed', - 'reconcile_completed', - 'stop_completed', - 'stale_run_rejected', -] as const; - -export type OpenCodeProductionE2ERequiredSignal = - (typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number]; - -export interface OpenCodeProductionE2ECheckpointEvidence { - name: string; - observedAt: string; -} - -export interface OpenCodeProductionE2ESessionEvidence { - memberName: string; - sessionId: string; - launchState: 'confirmed_alive'; -} - -export interface OpenCodeProductionE2EEvidence { - schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION; - evidenceId: string; - createdAt: string; - expiresAt: string; - version: string; - passed: boolean; - artifactPath: string | null; - binaryFingerprint: string; - capabilitySnapshotId: string; - selectedModel: string; - projectPathFingerprint: string | null; - requiredSignals: Record; - mcpTools: { - requiredTools: string[]; - observedTools: string[]; - }; - launch: { - runId: string; - teamId: string; - teamLaunchState: 'ready'; - memberCount: number; - sessions: OpenCodeProductionE2ESessionEvidence[]; - durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[]; - }; - reconcile: { - runId: string; - teamLaunchState: 'ready'; - memberCount: number; - }; - stop: { - runId: string; - stopped: true; - stoppedSessionIds: string[]; - }; - logProjection: { - observed: true; - projectedMessageCount: number; - }; - diagnostics?: string[]; -} - -export interface OpenCodeProductionE2EEvidenceCollection { - collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION; - entriesByModel: Record; -} - -export type OpenCodeProductionE2EEvidenceStoreData = - | OpenCodeProductionE2EEvidence - | OpenCodeProductionE2EEvidenceCollection - | null; - -export interface OpenCodeProductionE2EGateExpectation { - opencodeVersion: string | null; - binaryFingerprint: string | null; - capabilitySnapshotId: string | null; - /** - * The currently selected raw model id. Kept for observability and evidence - * lookup preference, but not as a hard production-proof gate. - */ - selectedModel: string | null; - projectPathFingerprint?: string | null; - requiredMcpTools?: string[]; -} - -export interface OpenCodeProductionE2EGateResult { - ok: boolean; - diagnostics: string[]; -} - -export function buildOpenCodeProjectPathFingerprint( - projectPath: string | null | undefined -): string | null { - const trimmed = projectPath?.trim() ?? ''; - if (!trimmed) { - return null; - } - - const normalized = path.resolve(trimmed).replace(/\\/g, '/'); - return `project:${createHash('sha256').update(normalized).digest('hex')}`; -} - -export function validateOpenCodeProductionE2EEvidence( - value: unknown -): OpenCodeProductionE2EEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence must be an object'); - } - - if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) { - throw new Error('OpenCode production E2E evidence has unsupported schemaVersion'); - } - - const evidence: OpenCodeProductionE2EEvidence = { - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - evidenceId: requireString(record.evidenceId, 'evidenceId'), - createdAt: requireIsoDate(record.createdAt, 'createdAt'), - expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'), - version: requireString(record.version, 'version'), - passed: requireBoolean(record.passed, 'passed'), - artifactPath: optionalString(record.artifactPath, 'artifactPath'), - binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'), - capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'), - selectedModel: requireString(record.selectedModel, 'selectedModel'), - projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'), - requiredSignals: normalizeRequiredSignals(record.requiredSignals), - mcpTools: normalizeMcpTools(record.mcpTools), - launch: normalizeLaunch(record.launch), - reconcile: normalizeReconcile(record.reconcile), - stop: normalizeStop(record.stop), - logProjection: normalizeLogProjection(record.logProjection), - diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'), - }; - - return evidence; -} - -export function validateNullableOpenCodeProductionE2EEvidence( - value: unknown -): OpenCodeProductionE2EEvidence | null { - if (value === null) { - return null; - } - return validateOpenCodeProductionE2EEvidence(value); -} - -export function validateOpenCodeProductionE2EEvidenceStoreData( - value: unknown -): OpenCodeProductionE2EEvidenceStoreData { - if (value === null) { - return null; - } - - const record = asRecord(value); - if ( - record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION - ) { - return validateOpenCodeProductionE2EEvidenceCollection(record); - } - - return validateOpenCodeProductionE2EEvidence(value); -} - -export function isOpenCodeProductionE2EEvidenceCollection( - value: OpenCodeProductionE2EEvidenceStoreData -): value is OpenCodeProductionE2EEvidenceCollection { - return ( - value !== null && - typeof value === 'object' && - 'collectionSchemaVersion' in value && - value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION - ); -} - -export function assertOpenCodeProductionE2EEvidenceBasics(input: { - evidence: OpenCodeProductionE2EEvidence | null; - testedVersion: string; - now?: Date; - artifactPath?: string | null; -}): OpenCodeProductionE2EGateResult { - const diagnostics: string[] = []; - const now = input.now ?? new Date(); - const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; - - if (!input.evidence) { - return { - ok: false, - diagnostics: [ - 'OpenCode version is capability-compatible but production E2E evidence is missing', - ], - }; - } - - diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); - - if (input.evidence.version !== input.testedVersion) { - diagnostics.push( - `OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}` - ); - } - - return { - ok: diagnostics.length === 0, - diagnostics, - }; -} - -export function assertOpenCodeProductionE2EArtifactGate(input: { - evidence: OpenCodeProductionE2EEvidence | null; - expected: OpenCodeProductionE2EGateExpectation; - now?: Date; - artifactPath?: string | null; -}): OpenCodeProductionE2EGateResult { - const diagnostics: string[] = []; - const now = input.now ?? new Date(); - const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null; - - if (!input.evidence) { - return { - ok: false, - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }; - } - - diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath)); - diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected)); - - return { - ok: diagnostics.length === 0, - diagnostics, - }; -} - -function collectArtifactShapeDiagnostics( - evidence: OpenCodeProductionE2EEvidence, - now: Date, - artifactPath: string | null -): string[] { - const diagnostics: string[] = []; - const createdAtMs = Date.parse(evidence.createdAt); - const expiresAtMs = Date.parse(evidence.expiresAt); - - if (!evidence.passed) { - diagnostics.push('OpenCode production E2E evidence did not pass'); - } - - if (!artifactPath) { - diagnostics.push('OpenCode production E2E evidence artifact path is missing'); - } - - if (!Number.isFinite(createdAtMs)) { - diagnostics.push('OpenCode production E2E evidence createdAt is invalid'); - } else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) { - diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age'); - } - - if (!Number.isFinite(expiresAtMs)) { - diagnostics.push('OpenCode production E2E evidence expiresAt is invalid'); - } else if (expiresAtMs <= now.getTime()) { - diagnostics.push('OpenCode production E2E evidence is expired'); - } - - const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter( - (signal) => evidence.requiredSignals[signal] !== true - ); - if (missingSignals.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}` - ); - } - - const checkpointNames = new Set( - evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name) - ); - const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter( - (checkpoint) => !checkpointNames.has(checkpoint) - ); - if (missingCheckpoints.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}` - ); - } - - if ( - evidence.launch.memberCount <= 0 || - evidence.launch.sessions.length !== evidence.launch.memberCount - ) { - diagnostics.push( - 'OpenCode production E2E evidence must include confirmed session evidence for every member' - ); - } - - if (evidence.reconcile.runId !== evidence.launch.runId) { - diagnostics.push( - 'OpenCode production E2E reconcile evidence runId does not match launch runId' - ); - } - - if (evidence.reconcile.memberCount !== evidence.launch.memberCount) { - diagnostics.push( - 'OpenCode production E2E reconcile member count does not match launch member count' - ); - } - - if (evidence.stop.runId !== evidence.launch.runId) { - diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId'); - } - - if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) { - diagnostics.push( - 'OpenCode production E2E evidence does not prove every launched session was stopped' - ); - } - - if (evidence.logProjection.projectedMessageCount <= 0) { - diagnostics.push('OpenCode production E2E evidence must include projected log messages'); - } - - const observedTools = new Set(evidence.mcpTools.observedTools); - const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool)); - if (missingTools.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}` - ); - } - - return diagnostics; -} - -function collectExpectedRuntimeDiagnostics( - evidence: OpenCodeProductionE2EEvidence, - expected: OpenCodeProductionE2EGateExpectation -): string[] { - const diagnostics: string[] = []; - - if (!expected.opencodeVersion) { - diagnostics.push('OpenCode production gate cannot verify runtime version'); - } else if (evidence.version !== expected.opencodeVersion) { - diagnostics.push( - `OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}` - ); - } - - if (!expected.binaryFingerprint) { - diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint'); - } else if (evidence.binaryFingerprint !== expected.binaryFingerprint) { - diagnostics.push( - 'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint' - ); - } - - if (!expected.capabilitySnapshotId) { - diagnostics.push('OpenCode production gate cannot verify capability snapshot id'); - } else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) { - diagnostics.push( - 'OpenCode production E2E evidence capability snapshot does not match current runtime' - ); - } - - if ( - expected.projectPathFingerprint && - evidence.projectPathFingerprint !== expected.projectPathFingerprint - ) { - diagnostics.push( - 'OpenCode production E2E evidence project context does not match the current working directory' - ); - } - - const requiredTools = expected.requiredMcpTools ?? []; - if (requiredTools.length > 0) { - const observedTools = new Set(evidence.mcpTools.observedTools); - const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); - if (missingTools.length > 0) { - diagnostics.push( - `OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}` - ); - } - } - - return diagnostics; -} - -function normalizeRequiredSignals( - value: unknown -): Record { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence requiredSignals must be an object'); - } - - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [ - signal, - requireBoolean(record[signal], `requiredSignals.${signal}`), - ]) - ) as Record; -} - -function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence mcpTools must be an object'); - } - return { - requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'), - observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'), - }; -} - -function validateOpenCodeProductionE2EEvidenceCollection( - value: Record -): OpenCodeProductionE2EEvidenceCollection { - const entriesRecord = asRecord(value.entriesByModel); - if (!entriesRecord) { - throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object'); - } - - const entries: Record = {}; - for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) { - const trimmedEntryKey = entryKey.trim(); - if (!trimmedEntryKey) { - throw new Error('OpenCode production E2E evidence collection key must be non-empty'); - } - - const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence); - entries[trimmedEntryKey] = evidence; - } - - return { - collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION, - entriesByModel: entries, - }; -} - -function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence launch must be an object'); - } - if (record.teamLaunchState !== 'ready') { - throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready'); - } - return { - runId: requireString(record.runId, 'launch.runId'), - teamId: requireString(record.teamId, 'launch.teamId'), - teamLaunchState: 'ready', - memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'), - sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession), - durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map( - normalizeCheckpoint - ), - }; -} - -function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence launch session must be an object'); - } - if (record.launchState !== 'confirmed_alive') { - throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive'); - } - return { - memberName: requireString(record.memberName, 'launch.sessions.memberName'), - sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'), - launchState: 'confirmed_alive', - }; -} - -function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence durable checkpoint must be an object'); - } - return { - name: requireString(record.name, 'launch.durableCheckpoints.name'), - observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'), - }; -} - -function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence reconcile must be an object'); - } - if (record.teamLaunchState !== 'ready') { - throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready'); - } - return { - runId: requireString(record.runId, 'reconcile.runId'), - teamLaunchState: 'ready', - memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'), - }; -} - -function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence stop must be an object'); - } - if (record.stopped !== true) { - throw new Error('OpenCode production E2E evidence stop.stopped must be true'); - } - return { - runId: requireString(record.runId, 'stop.runId'), - stopped: true, - stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'), - }; -} - -function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] { - const record = asRecord(value); - if (!record) { - throw new Error('OpenCode production E2E evidence logProjection must be an object'); - } - if (record.observed !== true) { - throw new Error('OpenCode production E2E evidence logProjection.observed must be true'); - } - return { - observed: true, - projectedMessageCount: requirePositiveInteger( - record.projectedMessageCount, - 'logProjection.projectedMessageCount' - ), - }; -} - -function asRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : null; -} - -function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`); - } - return value.trim(); -} - -function optionalString(value: unknown, field: string): string | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`); - } - return value.trim(); -} - -function requireBoolean(value: unknown, field: string): boolean { - if (typeof value !== 'boolean') { - throw new Error(`OpenCode production E2E evidence ${field} must be boolean`); - } - return value; -} - -function requirePositiveInteger(value: unknown, field: string): number { - if (!Number.isInteger(value) || (value as number) <= 0) { - throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`); - } - return value as number; -} - -function requireIsoDate(value: unknown, field: string): string { - const text = requireString(value, field); - if (!Number.isFinite(Date.parse(text))) { - throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`); - } - return text; -} - -function requireArray(value: unknown, field: string): unknown[] { - if (!Array.isArray(value)) { - throw new Error(`OpenCode production E2E evidence ${field} must be an array`); - } - return value; -} - -function requireStringArray(value: unknown, field: string): string[] { - return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`)); -} - -function optionalStringArray(value: unknown, field: string): string[] | undefined { - if (value === undefined) { - return undefined; - } - return requireStringArray(value, field); -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts deleted file mode 100644 index ee3f7b50..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { join, resolve } from 'path'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV = - 'CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH'; - -export const OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE = 'production-e2e-evidence.json'; - -export function resolveOpenCodeProductionE2EEvidencePath(input: { - bridgeControlDir: string; - env?: NodeJS.ProcessEnv; -}): string { - const env = input.env ?? process.env; - const overridePath = env[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]?.trim(); - - if (overridePath) { - return resolve(overridePath); - } - - return join(input.bridgeControlDir, OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE); -} diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts deleted file mode 100644 index 03b934e8..00000000 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ /dev/null @@ -1,253 +0,0 @@ -import * as path from 'path'; - -import { VersionedJsonStore } from '../store/VersionedJsonStore'; - -import { - isOpenCodeProductionE2EEvidenceCollection, - OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - type OpenCodeProductionE2EEvidence, - type OpenCodeProductionE2EEvidenceCollection, - type OpenCodeProductionE2EEvidenceStoreData, - validateOpenCodeProductionE2EEvidence, - validateOpenCodeProductionE2EEvidenceStoreData, -} from './OpenCodeProductionE2EEvidence'; - -export interface OpenCodeProductionE2EEvidenceStoreReadResult { - ok: boolean; - evidence: OpenCodeProductionE2EEvidence | null; - artifactPath: string; - diagnostics: string[]; -} - -export interface OpenCodeProductionE2EEvidenceStoreOptions { - filePath: string; - clock?: () => Date; -} - -export interface OpenCodeProductionE2EEvidenceStoreReadOptions { - /** - * Preferred exact raw model id when a matching project-scoped proof exists. - * Production proof is primarily scoped to the runtime/project integration, not - * to a mandatory per-model whitelist. - */ - selectedModel?: string | null; - projectPathFingerprint?: string | null; - opencodeVersion?: string | null; - binaryFingerprint?: string | null; - capabilitySnapshotId?: string | null; -} - -export class OpenCodeProductionE2EEvidenceStore { - private readonly filePath: string; - private readonly store: VersionedJsonStore; - - constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) { - this.filePath = options.filePath; - this.store = new VersionedJsonStore({ - filePath: options.filePath, - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - defaultData: () => null, - validate: validateOpenCodeProductionE2EEvidenceStoreData, - clock: options.clock, - quarantineDir: path.dirname(options.filePath), - }); - } - - async read( - options: OpenCodeProductionE2EEvidenceStoreReadOptions = {} - ): Promise { - const result = await this.store.read(); - if (!result.ok) { - return { - ok: false, - evidence: null, - artifactPath: this.filePath, - diagnostics: [ - `OpenCode production E2E evidence store is unreadable: ${result.message}`, - ...(result.quarantinePath - ? [`Quarantined corrupt evidence at ${result.quarantinePath}`] - : []), - ], - }; - } - - const selection = selectEvidence(result.data, options); - return { - ok: true, - evidence: selection.evidence, - artifactPath: this.filePath, - diagnostics: [ - ...selection.diagnostics, - ...(result.status === 'missing' - ? ['OpenCode production E2E evidence artifact has not been written yet'] - : []), - ], - }; - } - - async write(evidence: OpenCodeProductionE2EEvidence): Promise { - const validated = validateOpenCodeProductionE2EEvidence(evidence); - await this.store.updateLocked((current) => { - const nextEvidence = { - ...validated, - artifactPath: validated.artifactPath ?? this.filePath, - }; - return upsertEvidence(current, nextEvidence); - }); - } -} - -function selectEvidence( - data: OpenCodeProductionE2EEvidenceStoreData, - options: OpenCodeProductionE2EEvidenceStoreReadOptions -): { - evidence: OpenCodeProductionE2EEvidence | null; - diagnostics: string[]; -} { - if (!data) { - return { evidence: null, diagnostics: [] }; - } - - if (!isOpenCodeProductionE2EEvidenceCollection(data)) { - return { evidence: data, diagnostics: [] }; - } - - const modelId = options.selectedModel?.trim() ?? ''; - const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? ''; - const entries = Object.values(data.entriesByModel); - const pickBestForRuntime = ( - candidates: OpenCodeProductionE2EEvidence[] - ): OpenCodeProductionE2EEvidence | null => { - const runtimeMatched = candidates.filter((entry) => runtimeIdentityMatches(entry, options)); - return pickNewestEvidence(runtimeMatched.length > 0 ? runtimeMatched : candidates); - }; - - if (projectPathFingerprint) { - const pathEntries = entries.filter( - (entry) => entry.projectPathFingerprint === projectPathFingerprint - ); - if (pathEntries.length === 0) { - return { - evidence: null, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for the current working directory', - ], - }; - } - - if (modelId) { - const exactModelMatch = pickBestForRuntime( - pathEntries.filter((entry) => entry.selectedModel === modelId) - ); - if (exactModelMatch) { - return { - evidence: exactModelMatch, - diagnostics: [], - }; - } - } - - return { - evidence: pickBestForRuntime(pathEntries), - diagnostics: [], - }; - } - - if (modelId) { - const exactModelEntries = entries.filter((entry) => entry.selectedModel === modelId); - if (exactModelEntries.length === 0) { - return { - evidence: null, - diagnostics: [ - `OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`, - ], - }; - } - - return { - evidence: pickNewestEvidence(exactModelEntries), - diagnostics: [], - }; - } - - if (entries.length === 1) { - return { evidence: entries[0] ?? null, diagnostics: [] }; - } - - return { - evidence: null, - diagnostics: - entries.length === 0 - ? ['OpenCode production E2E evidence artifact has no model entries'] - : [ - `OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`, - ], - }; -} - -function upsertEvidence( - current: OpenCodeProductionE2EEvidenceStoreData, - evidence: OpenCodeProductionE2EEvidence -): OpenCodeProductionE2EEvidenceCollection { - const entriesByModel: Record = {}; - if (isOpenCodeProductionE2EEvidenceCollection(current)) { - Object.assign(entriesByModel, current.entriesByModel); - } else if (current) { - entriesByModel[current.selectedModel] = current; - } - - entriesByModel[buildEvidenceKey(evidence)] = evidence; - return { - collectionSchemaVersion: 1, - entriesByModel, - }; -} - -function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string { - return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::'); -} - -function runtimeIdentityMatches( - evidence: OpenCodeProductionE2EEvidence, - options: OpenCodeProductionE2EEvidenceStoreReadOptions -): boolean { - const expectedVersion = options.opencodeVersion?.trim() ?? ''; - if (expectedVersion && evidence.version !== expectedVersion) { - return false; - } - - const expectedBinaryFingerprint = options.binaryFingerprint?.trim() ?? ''; - if (expectedBinaryFingerprint && evidence.binaryFingerprint !== expectedBinaryFingerprint) { - return false; - } - - const expectedCapabilitySnapshotId = options.capabilitySnapshotId?.trim() ?? ''; - if ( - expectedCapabilitySnapshotId && - evidence.capabilitySnapshotId !== expectedCapabilitySnapshotId - ) { - return false; - } - - return true; -} - -function pickNewestEvidence( - entries: OpenCodeProductionE2EEvidence[] -): OpenCodeProductionE2EEvidence | null { - if (entries.length === 0) { - return null; - } - - return entries.slice(1).reduce((latest, entry) => { - const latestAt = Date.parse(latest.createdAt); - const entryAt = Date.parse(entry.createdAt); - if (!Number.isFinite(entryAt)) { - return latest; - } - if (!Number.isFinite(latestAt) || entryAt >= latestAt) { - return entry; - } - return latest; - }, entries[0]); -} diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index a7f8ebb9..0cf779d9 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -2,12 +2,10 @@ import { evaluateOpenCodeSupport, OPENCODE_TEAM_LAUNCH_VERSION_POLICY, type OpenCodeInstallMethod, - type OpenCodeProductionE2EEvidence, type OpenCodeSupportedVersionPolicy, type OpenCodeSupportLevel, } from '../version/OpenCodeVersionPolicy'; -import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; @@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState = | 'not_authenticated' | 'unsupported_version' | 'capabilities_missing' - | 'e2e_missing' | 'runtime_store_blocked' | 'mcp_unavailable' | 'model_unavailable' @@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort { }): Promise; } -export interface OpenCodeProductionE2EEvidencePort { - read(input: { - projectPath: string; - inventory: OpenCodeRuntimeInventory; - capabilities: OpenCodeApiCapabilities; - }): Promise; -} - export interface OpenCodeTeamLaunchReadinessServiceOptions { versionPolicy?: OpenCodeSupportedVersionPolicy; - launchMode?: OpenCodeTeamLaunchMode; - /** - * @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate. - */ - adapterEnabled?: boolean; } export class OpenCodeTeamLaunchReadinessService { @@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService { private readonly mcpTools: OpenCodeMcpToolProofPort, private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort, private readonly modelExecution: OpenCodeModelExecutionProbePort, - private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort, private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {} ) {} @@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; }): Promise { - const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options); const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; - const dogfoodWarnings: string[] = []; - - if (launchMode === 'disabled') { - return readiness({ - state: 'adapter_disabled', - inventory: null, - modelId: input.selectedModel, - missing: ['OpenCode team launch adapter is disabled by feature gate'], - diagnostics: ['OpenCode team launch adapter is disabled by feature gate'], - }); - } try { const inventory = await this.inventory.probe({ projectPath: input.projectPath }); @@ -184,34 +154,22 @@ export class OpenCodeTeamLaunchReadinessService { projectPath: input.projectPath, inventory, }); - const evidence = await this.e2eEvidence.read({ - projectPath: input.projectPath, - inventory, - capabilities, - }); const support = evaluateOpenCodeSupport({ version: inventory.version ?? '0.0.0', capabilities, - evidence, policy, }); if (!support.supported) { - if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') { - dogfoodWarnings.push( - 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.' - ); - } else { - return readiness({ - state: mapSupportLevelToReadinessState(support.supportLevel), - inventory, - modelId, - capabilities, - supportLevel: support.supportLevel, - missing: support.diagnostics, - diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), - }); - } + return readiness({ + state: mapSupportLevelToReadinessState(support.supportLevel), + inventory, + modelId, + capabilities, + supportLevel: support.supportLevel, + missing: support.diagnostics, + diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics), + }); } const runtimeStoreReadiness = await this.runtimeStores.check({ @@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService { runtimeStoreReadiness, supportLevel: support.supportLevel, launchAllowed: true, - diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings), + diagnostics: inventory.diagnostics, }); } catch (error) { return readiness({ @@ -293,22 +251,6 @@ export class OpenCodeTeamLaunchReadinessService { } } -function resolveReadinessLaunchMode( - requested: OpenCodeTeamLaunchMode | undefined, - options: OpenCodeTeamLaunchReadinessServiceOptions -): OpenCodeTeamLaunchMode { - if (requested) { - return requested; - } - if (options.launchMode) { - return options.launchMode; - } - if (options.adapterEnabled === true) { - return 'production'; - } - return 'disabled'; -} - function readiness(input: { state: OpenCodeTeamLaunchReadinessState; inventory: OpenCodeRuntimeInventory | null; @@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState( return 'unsupported_version'; case 'supported_capabilities_pending': return 'capabilities_missing'; - case 'supported_e2e_pending': - return 'e2e_missing'; case 'production_supported': return 'ready'; } diff --git a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts index 3f9882c1..a033d4f3 100644 --- a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts +++ b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts @@ -1,11 +1,6 @@ import { createHash } from 'crypto'; import { promises as fs } from 'fs'; -import { - assertOpenCodeProductionE2EEvidenceBasics, - type OpenCodeProductionE2EEvidence, -} from '../e2e/OpenCodeProductionE2EEvidence'; - import type { OpenCodeApiCapabilities, OpenCodeApiEndpointKey, @@ -14,18 +9,14 @@ import type { export interface OpenCodeSupportedVersionPolicy { minimumVersion: string; - testedVersion: string; allowedPrerelease: boolean; requireCapabilities: boolean; - requireE2EArtifactsForTestedVersion: boolean; } export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = { minimumVersion: '1.14.19', - testedVersion: '1.14.19', allowedPrerelease: false, requireCapabilities: true, - requireE2EArtifactsForTestedVersion: true, }; export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown'; @@ -41,11 +32,8 @@ export type OpenCodeSupportLevel = | 'unsupported_too_old' | 'unsupported_prerelease' | 'supported_capabilities_pending' - | 'supported_e2e_pending' | 'production_supported'; -export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence'; - export interface OpenCodeCompatibilitySnapshot { schemaVersion: 1; createdAt: string; @@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot { supported: boolean; supportLevel: OpenCodeSupportLevel; apiCapabilities: OpenCodeApiCapabilities; - testedEvidencePath: string | null; diagnostics: string[]; } @@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: { export function evaluateOpenCodeSupport(input: { version: string; capabilities: OpenCodeApiCapabilities; - evidence: OpenCodeProductionE2EEvidence | null; policy?: OpenCodeSupportedVersionPolicy; }): OpenCodeSupportDecision { const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY; @@ -157,21 +143,6 @@ export function evaluateOpenCodeSupport(input: { }; } - if (policy.requireE2EArtifactsForTestedVersion) { - const evidenceDecision = assertOpenCodeProductionE2EGate({ - evidence: input.evidence, - testedVersion: policy.testedVersion, - }); - if (!evidenceDecision.ok) { - return { - supported: false, - supportLevel: 'supported_e2e_pending', - semver: parsed, - diagnostics: evidenceDecision.diagnostics, - }; - } - } - return { supported: true, supportLevel: 'production_supported', @@ -180,14 +151,6 @@ export function evaluateOpenCodeSupport(input: { }; } -export function assertOpenCodeProductionE2EGate(input: { - evidence: OpenCodeProductionE2EEvidence | null; - testedVersion: string; - now?: Date; -}): { ok: boolean; diagnostics: string[] } { - return assertOpenCodeProductionE2EEvidenceBasics(input); -} - export function selectPermissionReplyRouteFromCache( cache: OpenCodeRouteCompatibilityCache ): OpenCodePermissionReplyRoute | null { diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index c13cb0d8..000e20d9 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -9,7 +9,6 @@ import type { OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, - OpenCodeTeamLaunchMode, OpenCodeTeamMemberLaunchBridgeState, } from '../opencode/bridge/OpenCodeBridgeCommandContract'; import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; @@ -31,7 +30,6 @@ export interface OpenCodeTeamRuntimeBridgePort { projectPath: string; selectedModel: string | null; requireExecutionProbe: boolean; - launchMode?: OpenCodeTeamLaunchMode; }): Promise; getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null; launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise; @@ -44,14 +42,6 @@ export interface OpenCodeTeamRuntimeBridgePort { ): Promise; } -export interface OpenCodeTeamRuntimeAdapterOptions { - launchMode?: OpenCodeTeamLaunchMode; - /** - * @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired. - */ - launchEnabled?: boolean; -} - export interface OpenCodeTeamRuntimeMessageInput { runId?: string; teamName: string; @@ -71,8 +61,6 @@ export interface OpenCodeTeamRuntimeMessageResult { diagnostics: string[]; } -export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract'; - const REQUIRED_READY_CHECKPOINTS = new Set([ 'required_tools_proven', 'delivery_ready', @@ -85,32 +73,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { private readonly lastProjectPathByTeamName = new Map(); private readonly lastReadinessByProjectPath = new Map(); - constructor( - private readonly bridge: OpenCodeTeamRuntimeBridgePort, - private readonly options: OpenCodeTeamRuntimeAdapterOptions = {} - ) {} + constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {} async prepare(input: TeamRuntimeLaunchInput): Promise { - const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); - if (configuredLaunchMode === 'disabled') { - return { - ok: false, - providerId: this.providerId, - reason: 'opencode_team_launch_disabled', - retryable: false, - diagnostics: [ - 'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.', - ], - warnings: [], - }; - } - const runtimeOnly = input.runtimeOnly === true; const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({ projectPath: input.cwd, selectedModel: input.model ?? null, requireExecutionProbe: !runtimeOnly, - launchMode: runtimeOnly ? undefined : configuredLaunchMode, }); this.lastReadinessByProjectPath.set(input.cwd, readiness); @@ -125,36 +95,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } - const warnings = - configuredLaunchMode === 'dogfood' && !runtimeOnly - ? [ - 'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.', - ] - : []; - - if ( - !runtimeOnly && - configuredLaunchMode === 'production' && - readiness.supportLevel !== 'production_supported' - ) { - return { - ok: false, - providerId: this.providerId, - reason: 'opencode_production_e2e_evidence_missing', - retryable: false, - diagnostics: [ - 'OpenCode production launch requires strict production E2E evidence before enabling team launch.', - ], - warnings, - }; - } - return { ok: true, providerId: this.providerId, modelId: readiness.modelId, diagnostics: readiness.diagnostics, - warnings, + warnings: [], }; } @@ -172,7 +118,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ); } - const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); const prepared = await this.prepare(input); if (!prepared.ok) { return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings); @@ -194,7 +139,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null; this.lastProjectPathByTeamName.set(input.teamName, input.cwd); const data = await this.bridge.launchOpenCodeTeam({ - mode: configuredLaunchMode, runId: input.runId, laneId: input.laneId?.trim() || 'primary', teamId: input.teamName, @@ -420,18 +364,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } } -export function resolveOpenCodeTeamLaunchMode( - options: OpenCodeTeamRuntimeAdapterOptions = {} -): OpenCodeTeamLaunchMode { - if (options.launchMode) { - return options.launchMode; - } - if (options.launchEnabled === true) { - return 'production'; - } - return 'disabled'; -} - function mapOpenCodeLaunchDataToRuntimeResult( input: TeamRuntimeLaunchInput, data: OpenCodeLaunchTeamCommandData, @@ -738,7 +670,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']): return ( state === 'not_installed' || state === 'not_authenticated' || - state === 'e2e_missing' || state === 'runtime_store_blocked' || state === 'mcp_unavailable' || state === 'model_unavailable' || diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index 6d8909a2..bd6de5b5 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -1,6 +1,4 @@ export type { - OpenCodeTeamLaunchMode, - OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, OpenCodeTeamRuntimeMessageInput, OpenCodeTeamRuntimeMessageResult, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 95de628a..b12a70e1 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -385,6 +385,9 @@ export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime'; /** Restart a specific teammate runtime */ export const TEAM_RESTART_MEMBER = 'team:restartMember'; +/** Skip a failed teammate for the current launch */ +export const TEAM_SKIP_MEMBER_FOR_LAUNCH = 'team:skipMemberForLaunch'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index c7bbd64f..b840f74d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -171,6 +171,7 @@ import { TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, + TEAM_SKIP_MEMBER_FOR_LAUNCH, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_START_TASK_BY_USER, @@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = { restartMember: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_RESTART_MEMBER, teamName, memberName); }, + skipMemberForLaunch: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_SKIP_MEMBER_FOR_LAUNCH, teamName, memberName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 746b7aad..485dc4bc 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI { restartMember: async (): Promise => { throw new Error('Member restart is not available in browser mode'); }, + skipMemberForLaunch: async (): Promise => { + throw new Error('Member launch skip is not available in browser mode'); + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c6a48305..4b734b60 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -56,7 +56,6 @@ import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummar import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { deriveContextMetrics } from '@shared/utils/contextMetrics'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; -import { createLogger } from '@shared/utils/logger'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -158,11 +157,6 @@ interface CreateTaskDialogState { defaultChip?: InlineChip; } -const logger = createLogger('Component:TeamDetailView'); -const TEAM_DETAIL_COMMIT_WARN_MS = 32; -const TEAM_DETAIL_RENDER_BURST_WINDOW_MS = 4_000; -const TEAM_DETAIL_RENDER_BURST_WARN_COUNT = 8; -const TEAM_DETAIL_RENDER_WARN_THROTTLE_MS = 2_000; const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000; function areResolvedMembersEqual( @@ -940,13 +934,6 @@ export const TeamDetailView = ({ teamName, isPaneFocused = false, }: TeamDetailViewProps): React.JSX.Element => { - const renderStartedAtRef = useRef(performance.now()); - const renderDiagnosticsRef = useRef({ - windowStartedAt: Date.now(), - count: 0, - lastWarnAt: 0, - }); - renderStartedAtRef.current = performance.now(); const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -1247,6 +1234,7 @@ export const TeamDetailView = ({ reviewActionError, addMember, restartMember, + skipMemberForLaunch, removeMember, updateMemberRole, launchTeam, @@ -1296,6 +1284,7 @@ export const TeamDetailView = ({ reviewActionError: s.reviewActionError, addMember: s.addMember, restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, removeMember: s.removeMember, updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, @@ -1334,38 +1323,6 @@ export const TeamDetailView = ({ const isThisTabActive = tabId ? activeTabId === tabId : false; const wasInteractiveRef = useRef(false); - useEffect(() => { - const now = Date.now(); - const diagnostic = renderDiagnosticsRef.current; - if (now - diagnostic.windowStartedAt > TEAM_DETAIL_RENDER_BURST_WINDOW_MS) { - diagnostic.windowStartedAt = now; - diagnostic.count = 0; - } - diagnostic.count += 1; - - const commitMs = performance.now() - renderStartedAtRef.current; - const tasksCount = data?.tasks.length ?? 0; - const membersCount = members.length; - const processesCount = data?.processes.length ?? 0; - const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS; - const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT; - const shouldWarnLarge = tasksCount >= 80; - - if ( - (shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) && - now - diagnostic.lastWarnAt >= TEAM_DETAIL_RENDER_WARN_THROTTLE_MS - ) { - diagnostic.lastWarnAt = now; - logger.warn( - `[perf] commit team=${teamName} ms=${commitMs.toFixed(1)} renders=${diagnostic.count} windowMs=${ - now - diagnostic.windowStartedAt - } activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${ - loading ? 'yes' : 'no' - } tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` - ); - } - }); - // Messages panel resize const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = useResizablePanel({ @@ -1801,6 +1758,20 @@ export const TeamDetailView = ({ openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); + + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { setSelectedMember(member); setSelectedMemberView(null); @@ -2490,6 +2461,8 @@ export const TeamDetailView = ({ onSendMessage={handleSendMessageToMember} onAssignTask={handleAssignTaskToMember} onOpenTask={handleOpenTaskById} + onRestartMember={handleRestartMember} + onSkipMemberForLaunch={handleSkipMemberForLaunch} /> @@ -2789,7 +2762,7 @@ export const TeamDetailView = ({ closeSelectedMemberDialog(); openCreateTaskDialog('', '', name); }} - onRestartMember={(memberName) => restartMember(teamName, memberName)} + onRestartMember={handleRestartMember} onTaskClick={(task) => { closeSelectedMemberDialog(); setSelectedTask(task); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index ef85f72f..29862972 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -82,6 +82,7 @@ type TeamStatus = | 'provisioning' | 'offline' | 'partial_failure' + | 'partial_skipped' | 'partial_pending'; function getRecentProjects(team: TeamSummary): string[] { @@ -206,6 +207,9 @@ function resolveTeamStatus( if (team.teamLaunchState === 'partial_pending') { return 'partial_pending'; } + if (team.teamLaunchState === 'partial_skipped') { + return 'partial_skipped'; + } if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') { return 'partial_failure'; } @@ -249,6 +253,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { Launch failed partway ); + case 'partial_skipped': + return ( + + + Launch skipped member + + ); case 'partial_pending': return ( @@ -905,6 +916,7 @@ export const TeamListView = (): React.JSX.Element => {
{(status === 'offline' || status === 'partial_failure' || + status === 'partial_skipped' || status === 'partial_pending') && team.projectPath && ( @@ -997,6 +1009,12 @@ export const TeamListView = (): React.JSX.Element => { ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.` : 'Last launch stopped before all teammates joined.'}

+ ) : team.teamLaunchState === 'partial_skipped' ? ( +

+ {team.skippedMembers?.length + ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.` + : 'Last launch has skipped teammates.'} +

) : null}
{team.members && team.members.length > 0 ? ( diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 47c7abc1..4f03e023 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -20,7 +20,16 @@ import { } from '@renderer/utils/memberLaunchDiagnostics'; import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; -import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { + AlertTriangle, + Ban, + GitBranch, + Loader2, + MessageSquare, + Plus, + RotateCcw, +} from 'lucide-react'; import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; @@ -63,6 +72,8 @@ interface MemberCardProps { onClick?: () => void; onSendMessage?: () => void; onAssignTask?: () => void; + onRestartMember?: (memberName: string) => Promise | void; + onSkipMemberForLaunch?: (memberName: string) => Promise | void; } function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { @@ -111,6 +122,8 @@ export const MemberCard = ({ onClick, onSendMessage, onAssignTask, + onRestartMember, + onSkipMemberForLaunch, }: MemberCardProps): React.JSX.Element => { // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); @@ -118,6 +131,10 @@ export const MemberCard = ({ // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // ); const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); const teamMembers = useStore((s) => selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] ); @@ -214,12 +231,68 @@ export const MemberCard = ({ !isRemoved && hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; const showRuntimeAdvisoryBadge = !isRemoved && Boolean(runtimeAdvisoryLabel) && !showLaunchBadge && - spawnStatus !== 'error' && + !isFailedLaunch && + !isSkippedLaunch && (Boolean(activityTask) || !isAwaitingReply); + const handleRetryFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate'); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; return (
- ) : spawnStatus === 'error' ? ( + ) : showFailedLaunchBadge ? ( @@ -382,6 +455,94 @@ export const MemberCard = ({ className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200" /> ) : null} + {canSkipFailedLaunch ? ( + + + + + + {skipLaunchError ?? + (skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')} + + + ) : null} + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')} + + + ) : null} + + ) : showSkippedLaunchBadge ? ( + + + + + + + {displayPresenceLabel} + + + + + {spawnEntry?.skipReason ?? 'Skipped for this launch'} + + + {canRetryLaunch ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')} + + + ) : null} ) : showRuntimeAdvisoryBadge ? ( diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 09e07a21..8b3f6140 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -33,6 +33,8 @@ interface MemberListProps { onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; onOpenTask?: (taskId: string) => void; + onRestartMember?: (memberName: string) => Promise | void; + onSkipMemberForLaunch?: (memberName: string) => Promise | void; } function areResolvedMembersEquivalent( @@ -151,6 +153,9 @@ function areMemberSpawnStatusesEquivalent( leftEntry.error !== rightEntry.error || leftEntry.hardFailure !== rightEntry.hardFailure || leftEntry.hardFailureReason !== rightEntry.hardFailureReason || + leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch || + leftEntry.skipReason !== rightEntry.skipReason || + leftEntry.skippedAt !== rightEntry.skippedAt || leftEntry.livenessSource !== rightEntry.livenessSource || leftEntry.livenessKind !== rightEntry.livenessKind || leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic || @@ -244,6 +249,8 @@ function areMemberListPropsEqual( prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && prev.leadActivity === next.leadActivity && + prev.onRestartMember === next.onRestartMember && + prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch && areLaunchParamsEquivalent(prev.launchParams, next.launchParams) ); } @@ -265,6 +272,8 @@ export const MemberList = memo(function MemberList({ onSendMessage, onAssignTask, onOpenTask, + onRestartMember, + onSkipMemberForLaunch, }: MemberListProps): React.JSX.Element { const containerRef = useRef(null); const [isWide, setIsWide] = useState(false); @@ -372,6 +381,8 @@ export const MemberList = memo(function MemberList({ onClick={() => onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} onAssignTask={() => onAssignTask?.(member)} + onRestartMember={isRemoved ? undefined : onRestartMember} + onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch} /> ); }; diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 0268548c..5d1e1d8f 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -27,6 +27,7 @@ export interface LaunchJoinMilestones { processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; + skippedSpawnCount: number; } type DisplayStepMilestones = LaunchJoinMilestones & { @@ -106,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: { let processOnlyAliveCount = 0; let pendingSpawnCount = 0; let failedSpawnCount = 0; + let skippedSpawnCount = 0; let observedTeammateCount = 0; for (const memberName of teammateNames) { @@ -127,6 +129,10 @@ function summarizeLiveLaunchJoinMilestones(params: { failedSpawnCount += 1; continue; } + if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) { + skippedSpawnCount += 1; + continue; + } if (entry.launchState === 'confirmed_alive') { heartbeatConfirmedCount += 1; continue; @@ -153,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: { processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, observedTeammateCount, }; } @@ -208,19 +215,23 @@ export function getLaunchJoinMilestonesFromMembers({ processOnlyAliveCount: snapshotProcessOnlyAliveCount, pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount), failedSpawnCount: snapshotSummary.failedCount, + skippedSpawnCount: snapshotSummary.skippedCount ?? 0, }; const snapshotAccountedFor = snapshotMilestones.heartbeatConfirmedCount + snapshotMilestones.processOnlyAliveCount + - snapshotMilestones.failedSpawnCount; + snapshotMilestones.failedSpawnCount + + snapshotMilestones.skippedSpawnCount; const liveAccountedFor = liveSummary.heartbeatConfirmedCount + liveSummary.processOnlyAliveCount + - liveSummary.failedSpawnCount; + liveSummary.failedSpawnCount + + liveSummary.skippedSpawnCount; const liveSummaryIsMoreAdvanced = liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount || + liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount || liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount || liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount || (snapshotMilestones.failedSpawnCount === 0 && @@ -248,6 +259,7 @@ export function getLaunchJoinState({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }: LaunchJoinMilestones): { allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; @@ -256,14 +268,16 @@ export function getLaunchJoinState({ const allTeammatesConfirmedAlive = expectedTeammateCount > 0 && failedSpawnCount === 0 && + skippedSpawnCount === 0 && heartbeatConfirmedCount >= expectedTeammateCount; const remainingJoinCount = - expectedTeammateCount > 0 && failedSpawnCount === 0 + expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0 ? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount) : 0; const hasMembersStillJoining = expectedTeammateCount > 0 && failedSpawnCount === 0 && + skippedSpawnCount === 0 && remainingJoinCount > 0 && (processOnlyAliveCount > 0 || pendingSpawnCount > 0); @@ -295,6 +309,7 @@ export function getDisplayStepIndex({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }: DisplayStepMilestones): number { switch (progress.state) { case 'ready': @@ -322,8 +337,12 @@ export function getDisplayStepIndex({ if (failedSpawnCount > 0) { return 2; } + if (skippedSpawnCount > 0) { + return 2; + } - const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount; + const accountedForTeammates = + heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount; if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) { return 2; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 030ac9b2..772cf7a1 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -71,14 +71,7 @@ const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; const TEAM_FETCH_TIMEOUT_MS = 30_000; const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000; -const TEAM_DATA_IPC_WARN_MS = 350; -const TEAM_DATA_SET_WARN_MS = 12; -const TEAM_DATA_POST_WARN_MS = 24; -const TEAM_DATA_LARGE_MESSAGES = 150; -const TEAM_DATA_LARGE_TASKS = 80; const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; -const TEAM_REFRESH_BURST_WARN_COUNT = 5; -const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Map>(); @@ -567,45 +560,6 @@ function fetchTeamDataFresh(teamName: string): Promise { ); } -function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): { - tasks: number; - members: number; - activeMembers: number; - processes: number; -} { - if (!data) { - return { tasks: 0, members: 0, activeMembers: 0, processes: 0 }; - } - - return { - tasks: data.tasks.length, - members: data.members.length, - activeMembers: data.members.filter((member) => !member.removedAt).length, - processes: data.processes.length, - }; -} - -function estimateTeamPayloadWeight(data: TeamViewSnapshot): { - taskComments: number; - taskHistoryEvents: number; - taskDescriptionChars: number; -} { - let taskComments = 0; - let taskHistoryEvents = 0; - let taskDescriptionChars = 0; - for (const task of data.tasks) { - taskComments += task.comments?.length ?? 0; - taskHistoryEvents += task.historyEvents?.length ?? 0; - taskDescriptionChars += task.description?.length ?? 0; - } - - return { - taskComments, - taskHistoryEvents, - taskDescriptionChars, - }; -} - function noteTeamRefreshBurst(teamName: string): number { const now = Date.now(); const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? { @@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number { diagnostic.count += 1; - if ( - diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT && - now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS - ) { - diagnostic.lastWarnAt = now; - logger.warn( - `[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${ - now - diagnostic.windowStartedAt - }` - ); - } - teamRefreshBurstDiagnostics.set(teamName, diagnostic); return diagnostic.count; } -function maybeLogTeamDataPerf(params: { - phase: 'selectTeam' | 'refreshTeamData'; - teamName: string; - ipcMs: number; - setMs: number; - postMs: number; - totalMs: number; - previousData: TeamViewSnapshot | null | undefined; - nextData: TeamViewSnapshot; - deduped: boolean; - reusedInFlightRequest: boolean; - burstCount?: number; -}): void { - const { - phase, - teamName, - ipcMs, - setMs, - postMs, - totalMs, - previousData, - nextData, - deduped, - reusedInFlightRequest, - burstCount, - } = params; - - const nextCounts = summarizeTeamDataCounts(nextData); - const previousCounts = summarizeTeamDataCounts(previousData); - const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; - const slow = - ipcMs >= TEAM_DATA_IPC_WARN_MS || - setMs >= TEAM_DATA_SET_WARN_MS || - postMs >= TEAM_DATA_POST_WARN_MS; - - if (!slow && !largePayload && !reusedInFlightRequest) { - return; - } - - const payloadWeight = estimateTeamPayloadWeight(nextData); - logger.warn( - `[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed( - 1 - )}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${ - reusedInFlightRequest ? 'yes' : 'no' - } burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${ - previousCounts.members - }->${nextCounts.members},activeMembers:${ - previousCounts.activeMembers - }->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${ - payloadWeight.taskDescriptionChars - },taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}` - ); -} - function areLaunchSummaryCountsEqual( left: PersistedTeamLaunchSummary | undefined, right: PersistedTeamLaunchSummary | undefined @@ -702,6 +589,7 @@ function areLaunchSummaryCountsEqual( left.confirmedCount === right.confirmedCount && left.pendingCount === right.pendingCount && left.failedCount === right.failedCount && + left.skippedCount === right.skippedCount && left.runtimeAlivePendingCount === right.runtimeAlivePendingCount && left.shellOnlyPendingCount === right.shellOnlyPendingCount && left.runtimeProcessPendingCount === right.runtimeProcessPendingCount && @@ -741,6 +629,9 @@ function areMemberSpawnStatusEntriesEqual( left.launchState === right.launchState && left.error === right.error && left.hardFailureReason === right.hardFailureReason && + left.skippedForLaunch === right.skippedForLaunch && + left.skipReason === right.skipReason && + left.skippedAt === right.skippedAt && left.livenessSource === right.livenessSource && left.runtimeAlive === right.runtimeAlive && left.runtimeModel === right.runtimeModel && @@ -2158,6 +2049,7 @@ export interface TeamSlice { ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; restartMember: (teamName: string, memberName: string) => Promise; + skipMemberForLaunch: (teamName: string, memberName: string) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( teamName: string, @@ -3234,7 +3126,6 @@ export const createTeamSlice: StateCreator = (set, }, selectTeam: async (teamName: string, opts) => { - const startedAt = performance.now(); const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. @@ -3267,7 +3158,6 @@ export const createTeamSlice: StateCreator = (set, if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; } - const ipcMs = performance.now() - startedAt; // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { return; @@ -3299,7 +3189,6 @@ export const createTeamSlice: StateCreator = (set, } : data; const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); - const setStartedAt = performance.now(); set((state) => { const nextCache = state.teamDataCacheByName[teamName] === nextTeamData @@ -3318,8 +3207,6 @@ export const createTeamSlice: StateCreator = (set, }; }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); - const setMs = performance.now() - setStartedAt; - const postStartedAt = performance.now(); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; @@ -3329,19 +3216,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const postMs = performance.now() - postStartedAt; - maybeLogTeamDataPerf({ - phase: 'selectTeam', - teamName, - ipcMs, - setMs, - postMs, - totalMs: performance.now() - startedAt, - previousData, - nextData: nextTeamData, - deduped: true, - reusedInFlightRequest: false, - }); // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -3445,19 +3319,15 @@ export const createTeamSlice: StateCreator = (set, }, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { - const startedAt = performance.now(); const teamStateEpoch = captureTeamLocalStateEpoch(teamName); const refreshToken = beginInFlightTeamDataRefresh(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). const reusedInFlightRequest = opts?.withDedup === true && inFlightTeamDataRequests.has(teamName); - const burstCount = noteTeamRefreshBurst(teamName); + noteTeamRefreshBurst(teamName); if (reusedInFlightRequest) { pendingFreshTeamDataRefreshes.add(teamName); - logger.warn( - `[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup` - ); } try { const previousData = selectTeamDataForName(get(), teamName); @@ -3467,7 +3337,6 @@ export const createTeamSlice: StateCreator = (set, if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; } - const ipcMs = performance.now() - startedAt; const projectedTeamData = previousData ? { ...data, @@ -3475,7 +3344,6 @@ export const createTeamSlice: StateCreator = (set, } : data; const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); - const setStartedAt = performance.now(); set((state) => { const nextCache = state.teamDataCacheByName[teamName] === nextTeamData @@ -3507,8 +3375,6 @@ export const createTeamSlice: StateCreator = (set, }; }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); - const setMs = performance.now() - setStartedAt; - const postStartedAt = performance.now(); const invalidationState = previousData ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) : { cacheKeys: [], taskIds: [] }; @@ -3518,20 +3384,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const postMs = performance.now() - postStartedAt; - maybeLogTeamDataPerf({ - phase: 'refreshTeamData', - teamName, - ipcMs, - setMs, - postMs, - totalMs: performance.now() - startedAt, - previousData, - nextData: nextTeamData, - deduped: opts?.withDedup === true, - reusedInFlightRequest, - burstCount, - }); } catch (error) { if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) { return; @@ -4224,6 +4076,20 @@ export const createTeamSlice: StateCreator = (set, } }, + skipMemberForLaunch: async (teamName: string, memberName: string) => { + try { + await unwrapIpc('team:skipMemberForLaunch', () => + api.teams.skipMemberForLaunch(teamName, memberName) + ); + } finally { + await Promise.allSettled([ + get().fetchMemberSpawnStatuses(teamName), + get().fetchTeamAgentRuntime(teamName), + get().fetchTeams(), + ]); + } + }, + removeMember: async (teamName: string, memberName: string) => { await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName)); await get().refreshTeamData(teamName); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index ac4f6def..555233f9 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, getTeamModelSelectionError, @@ -151,7 +150,7 @@ describe('team model availability Codex catalog integration', () => { ]); }); - it('shows app-server future models but blocks launch until runtime declares dynamic support', () => { + it('allows app-server catalog models even when the runtime does not declare dynamic model launch', () => { const providerStatus = createCodexProviderStatus([ { id: 'gpt-5.5', @@ -168,16 +167,14 @@ describe('team model availability Codex catalog integration', () => { }, ]); - expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]); + expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']); expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({ value: 'gpt-5.5', label: '5.5', badgeLabel: 'New', - availabilityStatus: null, + availabilityStatus: 'available', }); - expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain( - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON - ); + expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); }); it('keeps existing disabled model policy on top of the dynamic catalog', () => { diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index c21db4e4..2af7729f 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -119,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record = { spawning: 'bg-amber-400', online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]', error: 'bg-red-400', + skipped: 'bg-zinc-500', }; export const SPAWN_PRESENCE_LABELS: Record = { @@ -127,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record = { spawning: 'starting', online: 'ready', error: 'spawn failed', + skipped: 'skipped', }; function isLaunchStillStarting( @@ -138,6 +140,9 @@ function isLaunchStillStarting( if (spawnLaunchState === 'failed_to_start') { return false; } + if (spawnLaunchState === 'skipped_for_launch') { + return false; + } if (spawnLaunchState === 'runtime_pending_permission') { return false; } @@ -171,6 +176,9 @@ export function getSpawnAwareDotClass( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_DOT_COLORS.error; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return SPAWN_DOT_COLORS.skipped; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'bg-amber-400 animate-pulse'; } @@ -218,6 +226,9 @@ export function getSpawnAwarePresenceLabel( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_PRESENCE_LABELS.error; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return SPAWN_PRESENCE_LABELS.skipped; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'connecting'; } @@ -259,6 +270,9 @@ export function getSpawnCardClass( ) { return 'member-waiting-shimmer'; } + if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + return 'opacity-70'; + } if (spawnLaunchState === 'runtime_pending_permission') { return 'member-waiting-shimmer'; } @@ -518,6 +532,7 @@ export function getLaunchAwarePresenceLabel( basePresenceLabel === 'starting' || basePresenceLabel === 'connecting' || basePresenceLabel === 'spawn failed' || + basePresenceLabel === 'skipped' || basePresenceLabel === 'offline' || basePresenceLabel === 'terminated' ) { @@ -538,6 +553,7 @@ export type MemberLaunchVisualState = | 'stale_runtime' | 'settling' | 'error' + | 'skipped' | null; export interface MemberLaunchPresentation { @@ -574,6 +590,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) return 'joining team'; case 'error': return 'failed'; + case 'skipped': + return 'skipped'; default: return null; } @@ -643,6 +661,8 @@ export function buildMemberLaunchPresentation({ if (isTeamAlive !== false || isTeamProvisioning) { if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { launchVisualState = 'error'; + } else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') { + launchVisualState = 'skipped'; } else if (spawnLaunchState === 'runtime_pending_permission') { launchVisualState = 'permission_pending'; } else if (runtimeEntry?.livenessKind === 'shell_only') { diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index ddd09c16..fd944ac1 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -23,7 +23,6 @@ import type { } from '@shared/types'; export { - CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON, GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index e951b4f3..da480b2c 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -21,7 +21,7 @@ export { type SupportedProviderId = CliProviderId | TeamProviderId; type RuntimeAwareProviderStatus = Pick< CliProviderStatus, - 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' | 'runtimeCapabilities' + 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' >; export interface TeamProviderModelOption { @@ -40,8 +40,6 @@ export const GPT_5_2_CODEX_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model is not currently available on the Codex native runtime.'; export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON = 'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.'; -export const CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON = - 'Available in Codex, waiting for Agent Teams runtime support.'; const TEAM_PROVIDER_LABELS: Record = { anthropic: 'Anthropic', @@ -166,13 +164,6 @@ function getKnownTeamProviderModelOption( return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed); } -function isKnownTeamProviderModel( - providerId: SupportedProviderId | undefined, - model: string | undefined -): boolean { - return Boolean(getKnownTeamProviderModelOption(providerId, model)); -} - export function getTeamProviderModelOptions( providerId: SupportedProviderId ): readonly TeamProviderModelOption[] { @@ -471,18 +462,6 @@ export function getRuntimeAwareTeamModelUiDisabledReason( return null; } - if ( - providerId === 'codex' && - providerStatus?.modelCatalog?.providerId === 'codex' && - providerStatus.modelCatalog.models.some( - (item) => item.launchModel === trimmed || item.id === trimmed - ) && - !isKnownTeamProviderModel(providerId, trimmed) && - providerStatus.runtimeCapabilities?.modelCatalog?.dynamic !== true - ) { - return CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON; - } - return isRuntimeHiddenTeamModel(providerId, trimmed, providerStatus) ? GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON : null; diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 5bc78b84..ee4c6fda 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -32,6 +32,11 @@ interface FailedSpawnDetail { reason: string | null; } +interface SkippedSpawnDetail { + name: string; + reason: string | null; +} + type PendingDiagnosticBucket = | 'shellOnly' | 'runtimeProcess' @@ -55,6 +60,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } +function isSkippedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + return entry?.launchState === 'skipped_for_launch' || entry?.skippedForLaunch === true; +} + function shouldPreferSnapshotEntryOverLive(params: { liveEntry: MemberSpawnStatusEntry | undefined; snapshotEntry: MemberSpawnStatusEntry | undefined; @@ -181,7 +190,12 @@ function getPendingDiagnosticNameGroups(params: { snapshotEntry, snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, }); - if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) { + if ( + !entry || + entry.launchState === 'confirmed_alive' || + isFailedSpawnEntry(entry) || + isSkippedSpawnEntry(entry) + ) { continue; } if ( @@ -326,6 +340,56 @@ function getFailedSpawnDetails(params: { .sort((left, right) => left.name.localeCompare(right.name)); } +function getSkippedSpawnDetails(params: { + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; +}): SkippedSpawnDetail[] { + const names = new Set(); + if (params.memberSpawnStatuses instanceof Map) { + for (const name of params.memberSpawnStatuses.keys()) { + names.add(name); + } + } else if (params.memberSpawnStatuses) { + for (const name of Object.keys(params.memberSpawnStatuses)) { + names.add(name); + } + } + for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) { + names.add(name); + } + + if (names.size === 0) { + return []; + } + + return [...names] + .map((name) => { + const liveEntry = + params.memberSpawnStatuses instanceof Map + ? params.memberSpawnStatuses.get(name) + : params.memberSpawnStatuses?.[name]; + const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; + return [ + name, + getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }), + ] as const; + }) + .filter(([, entry]) => isSkippedSpawnEntry(entry)) + .map(([name, entry]) => ({ + name, + reason: + typeof entry?.skipReason === 'string' && entry.skipReason.trim().length > 0 + ? entry.skipReason.trim() + : null, + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + function truncateFailureReason(reason: string, maxLength = 160): string { const normalized = reason.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLength) { @@ -381,6 +445,42 @@ function buildGenericFailedSpawnPanelMessage( return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`; } +function buildSkippedSpawnPanelMessage( + skippedSpawnDetails: readonly SkippedSpawnDetail[] +): string | null { + if (skippedSpawnDetails.length === 0) { + return null; + } + if (skippedSpawnDetails.length === 1) { + const [skipped] = skippedSpawnDetails; + return skipped.reason + ? `${skipped.name} skipped for this launch - ${truncateFailureReason(skipped.reason, 220)}` + : `${skipped.name} skipped for this launch`; + } + const listedSkipped = skippedSpawnDetails + .slice(0, 3) + .map((skipped) => + skipped.reason + ? `${skipped.name} - ${truncateFailureReason(skipped.reason, 100)}` + : skipped.name + ) + .join('; '); + const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3); + return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`; +} + +function buildSkippedSpawnCompactDetail( + skippedSpawnDetails: readonly SkippedSpawnDetail[] +): string | null { + if (skippedSpawnDetails.length === 0) { + return null; + } + if (skippedSpawnDetails.length === 1) { + return `${skippedSpawnDetails[0].name} skipped`; + } + return `${skippedSpawnDetails.length} teammates skipped`; +} + export interface TeamProvisioningPresentation { progress: TeamProvisioningProgress; isActive: boolean; @@ -393,6 +493,7 @@ export interface TeamProvisioningPresentation { processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; + skippedSpawnCount: number; allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; remainingJoinCount: number; @@ -454,6 +555,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, } = getLaunchJoinMilestonesFromMembers({ members, memberSpawnStatuses, @@ -470,6 +572,13 @@ export function buildTeamProvisioningPresentation({ failedSpawnCount, expectedTeammateCount ); + const skippedSpawnDetails = getSkippedSpawnDetails({ + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + }); + const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails); + const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails); const permissionBlockedCount = countPermissionBlockedMembers({ memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, @@ -483,6 +592,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }); const progressStepIndex = getDisplayStepIndex({ @@ -492,6 +602,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, }); if (isFailed) { @@ -507,6 +618,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, @@ -542,31 +654,43 @@ export function buildTeamProvisioningPresentation({ failedSpawnCount > 0 ? (failedSpawnCompactDetail ?? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) - : hasMembersStillJoining - ? pendingDetailPhrase - : expectedTeammateCount === 0 - ? 'Lead online' - : `All ${expectedTeammateCount} teammates joined`; + : skippedSpawnCount > 0 + ? (skippedSpawnCompactDetail ?? + `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`) + : hasMembersStillJoining + ? pendingDetailPhrase + : expectedTeammateCount === 0 + ? 'Lead online' + : `All ${expectedTeammateCount} teammates joined`; const readyDetailMessage = failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message) - : expectedTeammateCount === 0 - ? 'Team provisioned - lead online' - : allTeammatesConfirmedAlive - ? `Team provisioned - all ${expectedTeammateCount} teammates joined` - : hasMembersStillJoining - ? pendingDetailPhrase - : 'Team provisioned - teammates are still joining'; + : skippedSpawnCount > 0 + ? (skippedSpawnPanelMessage ?? + `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`) + : expectedTeammateCount === 0 + ? 'Team provisioned - lead online' + : allTeammatesConfirmedAlive + ? `Team provisioned - all ${expectedTeammateCount} teammates joined` + : hasMembersStillJoining + ? pendingDetailPhrase + : 'Team provisioned - teammates are still joining'; const readyDetailSeverity = - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined; + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'info' + : undefined; const readyMessage = failedSpawnCount > 0 ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start` - : expectedTeammateCount === 0 - ? 'Team launched - lead online' - : allTeammatesConfirmedAlive - ? `Team launched - all ${expectedTeammateCount} teammates joined` - : 'Finishing launch'; + : skippedSpawnCount > 0 + ? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped` + : expectedTeammateCount === 0 + ? 'Team launched - lead online' + : allTeammatesConfirmedAlive + ? `Team launched - all ${expectedTeammateCount} teammates joined` + : 'Finishing launch'; return { progress, @@ -579,27 +703,45 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, panelTitle: 'Launch details', - panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null, + panelMessage: + failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining + ? readyDetailMessage + : null, panelMessageSeverity: readyDetailSeverity, successMessage: readyMessage, successMessageSeverity: - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success', + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'info' + : 'success', defaultLiveOutputOpen: false, compactTitle: failedSpawnCount > 0 ? 'Launch finished with errors' - : hasMembersStillJoining - ? 'Finishing launch' - : 'Team launched', + : skippedSpawnCount > 0 + ? 'Launch continued with skipped teammates' + : hasMembersStillJoining + ? 'Finishing launch' + : 'Team launched', compactDetail: readyCompactDetail, compactTone: - failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success', + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 'warning' + : hasMembersStillJoining + ? 'default' + : 'success', currentStepIndex: - failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX, + failedSpawnCount > 0 || skippedSpawnCount > 0 + ? 2 + : hasMembersStillJoining + ? 2 + : DISPLAY_COMPLETE_STEP_INDEX, }; } @@ -633,6 +775,7 @@ export function buildTeamProvisioningPresentation({ processOnlyAliveCount, pendingSpawnCount, failedSpawnCount, + skippedSpawnCount, allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, @@ -640,26 +783,33 @@ export function buildTeamProvisioningPresentation({ panelMessage: failedSpawnCount > 0 ? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message) - : hasMembersStillJoining && - permissionBlockedCount > 0 && - permissionBlockedCount === remainingJoinCount - ? activePendingDetailPhrase - : progress.message, - panelMessageSeverity: failedSpawnCount > 0 ? 'warning' : progress.messageSeverity, + : skippedSpawnCount > 0 + ? (skippedSpawnPanelMessage ?? + `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`) + : hasMembersStillJoining && + permissionBlockedCount > 0 && + permissionBlockedCount === remainingJoinCount + ? activePendingDetailPhrase + : progress.message, + panelMessageSeverity: + failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity, defaultLiveOutputOpen: false, compactTitle: 'Launching team', compactDetail: failedSpawnCount > 0 ? (failedSpawnCompactDetail ?? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`) - : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0 - ? permissionBlockedCount === remainingJoinCount - ? buildAwaitingPermissionPhrase(permissionBlockedCount) - : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : expectedTeammateCount > 0 && progressStepIndex >= 2 - ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` - : progress.message, - compactTone: failedSpawnCount > 0 ? 'warning' : 'default', + : skippedSpawnCount > 0 + ? (skippedSpawnCompactDetail ?? + `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`) + : hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0 + ? permissionBlockedCount === remainingJoinCount + ? buildAwaitingPermissionPhrase(permissionBlockedCount) + : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : expectedTeammateCount > 0 && progressStepIndex >= 2 + ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed` + : progress.message, + compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default', }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ff84e0c1..9cbc679e 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -542,6 +542,7 @@ export interface TeamsAPI { getMemberSpawnStatuses: (teamName: string) => Promise; getTeamAgentRuntime: (teamName: string) => Promise; restartMember: (teamName: string, memberName: string) => Promise; + skipMemberForLaunch: (teamName: string, memberName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 66b4cde3..d5a7fbfa 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -73,6 +73,8 @@ export interface TeamSummary { confirmedMemberCount?: number; /** Missing teammate names from the last partial launch marker. */ missingMembers?: string[]; + /** Teammates intentionally skipped for the last launch. */ + skippedMembers?: string[]; /** Durable aggregate launch state derived from persisted launch-state evidence. */ teamLaunchState?: TeamLaunchAggregateState; /** ISO timestamp of the last durable launch-state evaluation. */ @@ -81,6 +83,7 @@ export interface TeamSummary { confirmedCount?: number; pendingCount?: number; failedCount?: number; + skippedCount?: number; runtimeAlivePendingCount?: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; @@ -683,14 +686,19 @@ export interface AddTaskCommentRequest { export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; -export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; +export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped'; export type MemberLaunchState = | 'starting' | 'runtime_pending_bootstrap' | 'runtime_pending_permission' | 'confirmed_alive' - | 'failed_to_start'; -export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure'; + | 'failed_to_start' + | 'skipped_for_launch'; +export type TeamLaunchAggregateState = + | 'clean_success' + | 'partial_pending' + | 'partial_failure' + | 'partial_skipped'; export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled'; export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; @@ -939,6 +947,9 @@ export interface PersistedTeamLaunchMemberState { laneOwnerProviderId?: TeamProviderId; launchIdentity?: ProviderModelLaunchIdentity; launchState: MemberLaunchState; + skippedForLaunch?: boolean; + skipReason?: string; + skippedAt?: string; agentToolAccepted: boolean; runtimeAlive: boolean; bootstrapConfirmed: boolean; @@ -964,6 +975,7 @@ export interface PersistedTeamLaunchSummary { confirmedCount: number; pendingCount: number; failedCount: number; + skippedCount?: number; runtimeAlivePendingCount: number; shellOnlyPendingCount?: number; runtimeProcessPendingCount?: number; @@ -1092,6 +1104,10 @@ export interface MemberSpawnStatusEntry { error?: string; /** Hard failure reason for failed_to_start. */ hardFailureReason?: string; + /** True when the user intentionally skipped this teammate for the current launch only. */ + skippedForLaunch?: boolean; + skipReason?: string; + skippedAt?: string; /** * Optional provenance for `online`. * - heartbeat: teammate sent a real inbox/native message after bootstrap diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 4f2ed047..08cbbc9f 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'events'; import type * as FsType from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { Readable } from 'stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@shared/utils/logger', () => ({ @@ -500,6 +501,69 @@ describe('FileWatcher', () => { watcher.stop(); }); + + it('retires catch-up files after repeated stat timeouts', async () => { + vi.useRealTimers(); + vi.mocked(errorDetector.detectErrors).mockClear(); + + const fsProvider = { + type: 'local' as const, + exists: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(''), + stat: vi.fn().mockRejectedValue(new Error('stat timeout')), + readdir: vi.fn().mockResolvedValue([]), + createReadStream: vi.fn(() => Readable.from([])), + dispose: vi.fn(), + }; + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher( + dataCache, + '/watch-root/projects', + '/watch-root/todos', + fsProvider + ); + watcher.setNotificationManager(notificationManager); + + const filePath = '/watch-root/projects/test-project/session-timeout.jsonl'; + const watcherAny = watcher as unknown as { + isWatching: boolean; + activeSessionFiles: Map< + string, + { projectId: string; sessionId: string; lastObservedAt: number } + >; + catchUpStatFailures: Map; + lastProcessedSize: Map; + lastProcessedLineCount: Map; + runCatchUpScan: () => Promise; + }; + watcherAny.isWatching = true; + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'session-timeout', + lastObservedAt: Date.now(), + }); + watcherAny.lastProcessedSize.set(filePath, 100); + watcherAny.lastProcessedLineCount.set(filePath, 5); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); + expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(1); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(true); + expect(watcherAny.catchUpStatFailures.get(filePath)).toBe(2); + + await watcherAny.runCatchUpScan(); + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); + expect(watcherAny.catchUpStatFailures.has(filePath)).toBe(false); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(100); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(5); + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + + watcher.stop(); + }); }); // =========================================================================== diff --git a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts b/test/main/services/team/OpenCodeLaunchModeEnv.test.ts deleted file mode 100644 index 14b3b4a4..00000000 --- a/test/main/services/team/OpenCodeLaunchModeEnv.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveOpenCodeTeamLaunchModeFromEnv } from '../../../../src/main/services/team/opencode/config/OpenCodeLaunchModeEnv'; - -describe('resolveOpenCodeTeamLaunchModeFromEnv', () => { - it('defaults to production so OpenCode is visible while strict readiness remains authoritative', () => { - expect(resolveOpenCodeTeamLaunchModeFromEnv({})).toBe('production'); - }); - - it('preserves explicit launch mode overrides', () => { - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'disabled' }) - ).toBe('disabled'); - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'dogfood' }) - ).toBe('dogfood'); - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'production' }) - ).toBe('production'); - }); - - it('keeps the legacy dogfood flag as an explicit opt-in', () => { - expect(resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_DOGFOOD: '1' })).toBe( - 'dogfood' - ); - }); - - it('falls back to production for invalid launch mode values', () => { - expect( - resolveOpenCodeTeamLaunchModeFromEnv({ CLAUDE_TEAM_OPENCODE_LAUNCH_MODE: 'enabled' }) - ).toBe('production'); - }); -}); diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index 6328ddc0..f46a227b 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -15,9 +15,7 @@ import { } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { - getTeamBootstrapStatePath, -} from '../../../../src/main/services/team/TeamBootstrapStateReader'; +import { getTeamBootstrapStatePath } from '../../../../src/main/services/team/TeamBootstrapStateReader'; import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; @@ -68,112 +66,106 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it( - 'recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', - async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); + it('recovers active mixed OpenCode side lanes from live runtime reconcile instead of marking them never spawned', async () => { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); - const svc = new TeamProvisioningService(); - svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); - const teamName = `mixed-opencode-recovery-${Date.now()}`; - const launchedLanes: TeamRuntimeLaunchInput[] = []; + const teamName = `mixed-opencode-recovery-${Date.now()}`; + const launchedLanes: TeamRuntimeLaunchInput[] = []; - await writeMixedRecoveryFixtures({ + await writeMixedRecoveryFixtures({ + teamName, + projectPath: PROJECT_PATH, + secondaryMembers: ['bob'], + }); + + try { + const launchInput = createSecondaryLaneLaunchInput({ teamName, - projectPath: PROJECT_PATH, - secondaryMembers: ['bob'], + laneId: 'secondary:opencode:bob', + memberName: 'bob', + selectedModel, + }); + launchedLanes.push(launchInput); + const launchResult = await adapter.launch(launchInput); + expect(launchResult.teamLaunchState).toBe('clean_success'); + expect(launchResult.members.bob).toMatchObject({ + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, }); - try { - const launchInput = createSecondaryLaneLaunchInput({ - teamName, - laneId: 'secondary:opencode:bob', - memberName: 'bob', - selectedModel, - }); - launchedLanes.push(launchInput); - const launchResult = await adapter.launch(launchInput); - expect(launchResult.teamLaunchState).toBe('clean_success'); - expect(launchResult.members.bob).toMatchObject({ - launchState: 'confirmed_alive', - runtimeAlive: true, - bootstrapConfirmed: true, - }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: launchInput.laneId ?? 'secondary:opencode:bob', + state: 'active', + }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), - teamName, - laneId: launchInput.laneId ?? 'secondary:opencode:bob', - state: 'active', - }); + const result = await svc.getMemberSpawnStatuses(teamName); - const result = await svc.getMemberSpawnStatuses(teamName); - - expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob'])); - expect(result.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - }); - expect(result.statuses.bob.error).toBeUndefined(); - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: { - [launchInput.laneId ?? 'secondary:opencode:bob']: { - state: 'active', - }, - }, - } - ); - } finally { - for (const launchInput of launchedLanes) { - await adapter - .stop({ - runId: launchInput.runId, - laneId: launchInput.laneId, - teamName, - cwd: PROJECT_PATH, - providerId: 'opencode', - reason: 'cleanup', - previousLaunchState: null, - force: true, - } satisfies TeamRuntimeStopInput) - .catch(() => undefined); - } + expect(result.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob'])); + expect(result.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(result.statuses.bob.error).toBeUndefined(); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: { + [launchInput.laneId ?? 'secondary:opencode:bob']: { + state: 'active', + }, + }, + }); + } finally { + for (const launchInput of launchedLanes) { + await adapter + .stop({ + runId: launchInput.runId, + laneId: launchInput.laneId, + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + reason: 'cleanup', + previousLaunchState: null, + force: true, + } satisfies TeamRuntimeStopInput) + .catch(() => undefined); } - }, - 240_000 - ); + } + }, 240_000); liveMultiLaneIt( 'recovers multiple active mixed OpenCode side lanes from live runtime reconcile', @@ -207,9 +199,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { reconcileTimeoutMs: 90_000, stopTimeoutMs: 90_000, }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); const svc = new TeamProvisioningService(); svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); @@ -259,16 +249,16 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { }); expect(result.statuses[memberName]?.error).toBeUndefined(); } - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: Object.fromEntries( - sideMembers.map((memberName) => [ - `secondary:opencode:${memberName}`, - { state: 'active' }, - ]) - ), - } - ); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: Object.fromEntries( + sideMembers.map((memberName) => [ + `secondary:opencode:${memberName}`, + { state: 'active' }, + ]) + ), + }); } finally { for (const launchInput of launchedLanes) { await adapter @@ -360,10 +350,7 @@ async function writeMixedRecoveryFixtures(input: { name: input.teamName, projectPath: input.projectPath, leadSessionId: 'lead-session', - members: [ - { name: 'team-lead', agentType: 'team-lead' }, - { name: 'alice' }, - ], + members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }], }, null, 2 diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts deleted file mode 100644 index d8c6159c..00000000 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { promises as fs } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - validateOpenCodeProductionE2EEvidence, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; -import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, -} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; - -describe('OpenCodeProductionE2EEvidence', () => { - let tempDir: string; - const now = new Date('2026-04-21T12:00:00.000Z'); - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-e2e-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('accepts production evidence when runtime identity, project context and required MCP tools match', () => { - const evidence = passingEvidence(); - - expect(validateOpenCodeProductionE2EEvidence(evidence)).toEqual(evidence); - expect( - assertOpenCodeProductionE2EArtifactGate({ - evidence, - artifactPath: '/tmp/opencode-e2e', - now, - expected: { - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredMcpTools: ['agent-teams_runtime_deliver_message'], - }, - }) - ).toEqual({ - ok: true, - diagnostics: [], - }); - }); - - it('fails closed for stale, mismatched or incomplete evidence', () => { - const expired = passingEvidence({ - expiresAt: '2026-04-21T11:59:59.000Z', - selectedModel: 'openrouter/anthropic/claude-sonnet-4.5', - requiredSignals: requiredSignals({ stale_run_rejected: false }), - mcpTools: { - requiredTools: ['agent-teams_runtime_deliver_message'], - observedTools: [], - }, - }); - - expect( - assertOpenCodeProductionE2EArtifactGate({ - evidence: expired, - artifactPath: '/tmp/opencode-e2e', - now, - expected: { - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredMcpTools: ['agent-teams_runtime_deliver_message'], - }, - }) - ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence is expired', - 'OpenCode production E2E evidence is missing signals: stale_run_rejected', - 'OpenCode production E2E evidence is missing observed MCP tools: agent-teams_runtime_deliver_message', - ]), - }); - }); - - it('reads missing evidence as a production-blocking diagnostic and quarantines corrupt artifacts', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await expect(store.read()).resolves.toMatchObject({ - ok: true, - evidence: null, - artifactPath: filePath, - diagnostics: ['OpenCode production E2E evidence artifact has not been written yet'], - }); - - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, '{broken', 'utf8'); - const corrupt = await store.read(); - expect(corrupt).toMatchObject({ - ok: false, - evidence: null, - artifactPath: filePath, - }); - expect(corrupt.diagnostics[0]).toContain( - 'OpenCode production E2E evidence store is unreadable' - ); - }); - - it('writes evidence with the store path as artifactPath when the input omits it', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write({ - ...passingEvidence(), - artifactPath: null, - }); - - await expect(store.read()).resolves.toMatchObject({ - ok: true, - evidence: { - artifactPath: filePath, - evidenceId: 'e2e-1', - }, - diagnostics: [], - }); - }); - - it('stores production evidence for multiple raw model ids and reads exact model matches when no project context is provided', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write(passingEvidence({ selectedModel: 'opencode/big-pickle' })); - await store.write( - passingEvidence({ - evidenceId: 'e2e-2', - selectedModel: 'opencode/minimax-m2.5-free', - }) - ); - - await expect( - store.read({ selectedModel: 'opencode/minimax-m2.5-free' }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-2', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - - await expect(store.read({ selectedModel: 'openai/gpt-5.4-mini' })).resolves.toMatchObject({ - ok: true, - evidence: null, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for selected model openai/gpt-5.4-mini', - ], - }); - }); - - it('reuses the current project production proof even when the requested OpenCode model differs', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-a', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/nemotron-3-super-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - }); - - it('prefers a runtime-compatible project proof over a newer stale one from the same cwd', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'stale-newer', - createdAt: '2026-04-21T12:05:00.000Z', - selectedModel: 'opencode/big-pickle', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - capabilitySnapshotId: 'cap-stale', - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'matching-older', - createdAt: '2026-04-21T12:00:00.000Z', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - capabilitySnapshotId: 'cap-current', - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/nemotron-3-super-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - opencodeVersion: '1.14.19', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-current', - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'matching-older', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - }); - - it('stores production evidence for the same raw model across multiple project contexts', async () => { - const filePath = path.join(tempDir, 'production-e2e-evidence.json'); - const store = new OpenCodeProductionE2EEvidenceStore({ - filePath, - clock: () => now, - }); - - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-a', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-a'), - }) - ); - await store.write( - passingEvidence({ - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ); - - await expect( - store.read({ - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-b'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: { - evidenceId: 'e2e-project-b', - selectedModel: 'opencode/minimax-m2.5-free', - }, - diagnostics: [], - }); - - await expect( - store.read({ - selectedModel: 'opencode/minimax-m2.5-free', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo-c'), - }) - ).resolves.toMatchObject({ - ok: true, - evidence: null, - diagnostics: ['OpenCode production E2E evidence artifact has no entry for the current working directory'], - }); - }); -}); - -function passingEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = '2026-04-21T12:00:00.000Z'; - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ); - - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: '2026-04-21T12:10:00.000Z', - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: requiredSignals(), - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} - -function requiredSignals( - overrides: Partial< - Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean> - > = {} -) { - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals']; -} diff --git a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts deleted file mode 100644 index 999335b9..00000000 --- a/test/main/services/team/OpenCodeProductionE2EEvidencePath.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as path from 'path'; - -import { describe, expect, it } from 'vitest'; - -import { - OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE, - OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV, - resolveOpenCodeProductionE2EEvidencePath, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath'; - -describe('OpenCodeProductionE2EEvidencePath', () => { - it('defaults to the app-owned bridge control directory', () => { - expect( - resolveOpenCodeProductionE2EEvidencePath({ - bridgeControlDir: '/app/user-data/opencode-bridge', - env: {}, - }) - ).toBe(path.join('/app/user-data/opencode-bridge', OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE)); - }); - - it('allows release and local proof runs to point production at an explicit artifact', () => { - const relativeOverride = 'tmp/opencode-production-evidence.json'; - - expect( - resolveOpenCodeProductionE2EEvidencePath({ - bridgeControlDir: '/app/user-data/opencode-bridge', - env: { - [OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]: ` ${relativeOverride} `, - }, - }) - ).toBe(path.resolve(relativeOverride)); - }); -}); diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts deleted file mode 100644 index 5a500817..00000000 --- a/test/main/services/team/OpenCodeProductionGate.live.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { constants as fsConstants, promises as fs } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; -import { - createOpenCodeBridgeCommandLeaseStore, - createOpenCodeBridgeCommandLedgerStore, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; -import { - createOpenCodeBridgeClientIdentity, - OpenCodeBridgeCommandHandshakePort, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; -import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; -import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import { - assertOpenCodeProductionE2EArtifactGate, - buildOpenCodeProjectPathFingerprint, - OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS, - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; -import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, -} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; -import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; -import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; - -import type { - OpenCodeBridgeRuntimeSnapshot, - OpenCodeLaunchTeamCommandData, - OpenCodeStopTeamCommandData, - RuntimeStoreManifestEvidence, -} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; -import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; -import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; - -const liveDescribe = process.env.OPENCODE_E2E === '1' ? describe : describe.skip; - -const DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH = path.join( - os.userInfo().homedir, - 'Library', - 'Application Support', - 'Agent Teams UI', - 'opencode-bridge', - 'production-e2e-evidence.json' -); -const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd(); -const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; -const DEFAULT_MODEL = 'opencode/big-pickle'; - -liveDescribe('OpenCode production gate live e2e', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-production-gate-e2e-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it('runs live launch/reconcile/transcript/stop and accepts production evidence with app MCP tool proof', async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); - - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - - const readiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: PROJECT_PATH, - selectedModel, - requireExecutionProbe: false, - }); - const initialRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); - if (!initialRuntime) { - throw new Error( - `OpenCode live readiness did not return runtime snapshot: ${[ - ...readiness.diagnostics, - ...readiness.missing, - ].join('; ')}` - ); - } - expect(initialRuntime?.version).toBe('1.14.19'); - expect(initialRuntime?.capabilitySnapshotId).toBeTruthy(); - - const runId = `opencode-e2e-${Date.now()}`; - const teamName = `opencode-e2e-team-${Date.now()}`; - const memberName = 'E2E'; - let launch: OpenCodeLaunchTeamCommandData | null = null; - let reconcile: OpenCodeLaunchTeamCommandData | null = null; - let stop: OpenCodeStopTeamCommandData | null = null; - let transcriptMessages = 0; - let staleRunRejected = false; - - try { - launch = await readinessBridge.launchOpenCodeTeam({ - mode: 'dogfood', - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - selectedModel, - members: [ - { - name: memberName, - role: 'e2e', - prompt: 'Reply with exactly: opencode-production-gate-e2e', - }, - ], - leadPrompt: 'Live OpenCode production gate e2e', - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - }); - - expect(launch.teamLaunchState).toBe('ready'); - expect(launch.members[memberName]?.launchState).toBe('confirmed_alive'); - - reconcile = await readinessBridge.reconcileOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - expectedMembers: [{ name: memberName, model: selectedModel }], - reason: 'production_gate_e2e', - }); - expect(reconcile.teamLaunchState).toBe('ready'); - - const transcript = await bridgeClient.execute< - { teamId: string; teamName: string; laneId: string; memberName: string }, - { logProjection?: { messages?: unknown[] }; messages?: unknown[] } - >( - 'opencode.getRuntimeTranscript', - { teamId: teamName, teamName, laneId: 'primary', memberName }, - { cwd: PROJECT_PATH, timeoutMs: 60_000 } - ); - expect(transcript.ok).toBe(true); - if (transcript.ok) { - transcriptMessages = - transcript.data.logProjection?.messages?.length ?? transcript.data.messages?.length ?? 0; - expect(transcriptMessages).toBeGreaterThan(0); - } - - staleRunRejected = await rejectsStaleCapability({ - stateChangingCommands, - teamName, - runId: `${runId}-stale`, - selectedModel, - }); - - stop = await readinessBridge.stopOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - reason: 'production_gate_e2e_cleanup', - force: true, - }); - expect(stop.stopped).toBe(true); - - const finalReadiness = await readinessBridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: PROJECT_PATH, - selectedModel, - requireExecutionProbe: true, - }); - const finalRuntime = readinessBridge.getLastOpenCodeRuntimeSnapshot(PROJECT_PATH); - if (!finalRuntime) { - throw new Error( - `OpenCode final readiness did not return runtime snapshot: ${[ - ...finalReadiness.diagnostics, - ...finalReadiness.missing, - ].join('; ')}` - ); - } - expect(finalRuntime.version).toBe('1.14.19'); - expect(finalRuntime.capabilitySnapshotId).toBeTruthy(); - - const candidate = buildCandidateEvidence({ - runId, - teamName, - memberName, - selectedModel, - runtime: finalRuntime, - readinessObservedTools: readiness.evidence.observedMcpTools, - launch, - reconcile, - stop, - transcriptMessages, - staleRunRejected, - appMcpToolsVisible: readiness.requiredToolsPresent, - }); - const gate = assertOpenCodeProductionE2EArtifactGate({ - evidence: candidate, - artifactPath: candidate.artifactPath, - expected: { - opencodeVersion: finalRuntime.version ?? null, - binaryFingerprint: finalRuntime.binaryFingerprint ?? null, - capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null, - selectedModel, - projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), - requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), - }, - }); - - expect(gate).toEqual({ - ok: true, - diagnostics: [], - }); - await writeProductionEvidenceIfRequested(candidate); - } finally { - if (!stop) { - await readinessBridge - .stopOpenCodeTeam({ - runId, - laneId: 'primary', - teamId: teamName, - teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: initialRuntime?.capabilitySnapshotId ?? null, - manifestHighWatermark: null, - reason: 'production_gate_e2e_finally_cleanup', - force: true, - }) - .catch(() => undefined); - } - } - }, 240_000); -}); - -async function writeProductionEvidenceIfRequested( - evidence: OpenCodeProductionE2EEvidence -): Promise { - const explicitPath = process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim(); - const writeAppEvidence = process.env.OPENCODE_E2E_WRITE_APP_EVIDENCE === '1'; - const filePath = - explicitPath || (writeAppEvidence ? DEFAULT_APP_PRODUCTION_E2E_EVIDENCE_PATH : ''); - if (!filePath) { - return; - } - - const store = new OpenCodeProductionE2EEvidenceStore({ filePath }); - await store.write({ - ...evidence, - artifactPath: filePath, - }); -} - -function createStateChangingCommands(input: { - bridge: OpenCodeBridgeCommandExecutor; - controlDir: string; -}): OpenCodeStateChangingBridgeCommandService { - const clientIdentity = createOpenCodeBridgeClientIdentity({ - appVersion: '1.3.0-e2e', - gitSha: null, - buildId: 'opencode-production-gate-e2e', - }); - - return new OpenCodeStateChangingBridgeCommandService({ - expectedClientIdentity: clientIdentity, - handshakePort: new OpenCodeBridgeCommandHandshakePort({ - bridge: input.bridge, - clientIdentity, - }), - leaseStore: createOpenCodeBridgeCommandLeaseStore({ - filePath: path.join(input.controlDir, 'leases.json'), - }), - ledger: createOpenCodeBridgeCommandLedgerStore({ - filePath: path.join(input.controlDir, 'ledger.json'), - }), - bridge: input.bridge, - manifestReader: new StaticManifestReader(), - }); -} - -class StaticManifestReader implements RuntimeStoreManifestReader { - async read(): Promise { - return { - highWatermark: 0, - activeRunId: null, - capabilitySnapshotId: null, - }; - } -} - -async function rejectsStaleCapability(input: { - stateChangingCommands: OpenCodeStateChangingBridgeCommandService; - teamName: string; - runId: string; - selectedModel: string; -}): Promise { - try { - await input.stateChangingCommands.execute({ - command: 'opencode.reconcileTeam', - teamName: input.teamName, - laneId: 'primary', - runId: input.runId, - capabilitySnapshotId: 'opencode:stale-capability', - behaviorFingerprint: null, - body: { - runId: input.runId, - laneId: 'primary', - teamId: input.teamName, - teamName: input.teamName, - projectPath: PROJECT_PATH, - expectedCapabilitySnapshotId: 'opencode:stale-capability', - manifestHighWatermark: null, - expectedMembers: [{ name: 'E2E', model: input.selectedModel }], - reason: 'production_gate_stale_run_probe', - }, - cwd: PROJECT_PATH, - timeoutMs: 30_000, - }); - return false; - } catch (error) { - return error instanceof Error && error.message.includes('capability snapshot mismatch'); - } -} - -function buildCandidateEvidence(input: { - runId: string; - teamName: string; - memberName: string; - selectedModel: string; - runtime: OpenCodeBridgeRuntimeSnapshot; - readinessObservedTools: string[]; - launch: OpenCodeLaunchTeamCommandData; - reconcile: OpenCodeLaunchTeamCommandData; - stop: OpenCodeStopTeamCommandData; - transcriptMessages: number; - staleRunRejected: boolean; - appMcpToolsVisible: boolean; -}): OpenCodeProductionE2EEvidence { - const now = new Date(); - const createdAt = now.toISOString(); - const sessionId = input.launch.members[input.memberName]?.sessionId ?? 'missing-session'; - const checkpointByName = new Map(); - for (const checkpoint of input.launch.durableCheckpoints ?? []) { - checkpointByName.set(checkpoint.name, { - name: checkpoint.name, - observedAt: checkpoint.observedAt, - }); - } - for (const evidence of input.launch.members[input.memberName]?.evidence ?? []) { - checkpointByName.set(evidence.kind, { - name: evidence.kind, - observedAt: evidence.observedAt, - }); - } - for (const name of OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS) { - checkpointByName.set(name, checkpointByName.get(name) ?? { name, observedAt: createdAt }); - } - - return { - schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION, - evidenceId: `live-${input.runId}`, - createdAt, - expiresAt: new Date(now.getTime() + OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS).toISOString(), - version: input.runtime.version ?? 'unknown', - passed: true, - artifactPath: path.join(os.tmpdir(), `opencode-production-e2e-${input.runId}.json`), - binaryFingerprint: input.runtime.binaryFingerprint ?? 'unknown', - capabilitySnapshotId: input.runtime.capabilitySnapshotId ?? 'unknown', - selectedModel: input.selectedModel, - projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), - requiredSignals: { - ...Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ), - app_mcp_tools_visible: input.appMcpToolsVisible, - stale_run_rejected: input.staleRunRejected, - } as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), - observedTools: input.readinessObservedTools, - }, - launch: { - runId: input.runId, - teamId: input.teamName, - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: input.memberName, - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: Array.from(checkpointByName.values()), - }, - reconcile: { - runId: input.reconcile.runId, - teamLaunchState: 'ready', - memberCount: Object.keys(input.reconcile.members).length, - }, - stop: { - runId: input.stop.runId, - stopped: true, - stoppedSessionIds: Object.values(input.stop.members) - .map((member) => member.sessionId) - .filter((value): value is string => Boolean(value)), - }, - logProjection: { - observed: true, - projectedMessageCount: input.transcriptMessages, - }, - }; -} - -async function assertExecutable(filePath: string): Promise { - await fs.access(filePath, fsConstants.X_OK); -} - -function withBunOnPath(pathValue: string): string { - const bunDir = '/Users/belief/.bun/bin'; - return pathValue.split(path.delimiter).includes(bunDir) - ? pathValue - : `${bunDir}${path.delimiter}${pathValue}`; -} - -function createStableBridgeEnv(): NodeJS.ProcessEnv { - const realHome = os.userInfo().homedir; - const env = applyOpenCodeAutoUpdatePolicy({ ...process.env }); - return { - ...env, - HOME: realHome, - USERPROFILE: realHome, - }; -} diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 5aa66bbe..5d141bb6 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -3,18 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { OpenCodeReadinessBridge, type OpenCodeReadinessBridgeCommandExecutor, - type OpenCodeProductionE2EEvidenceReadPort, } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - buildOpenCodeProjectPathFingerprint, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, -} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { @@ -93,114 +82,6 @@ describe('OpenCodeReadinessBridge', () => { expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull(); }); - it('blocks production readiness when strict production E2E evidence is missing', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore(null); - const bridge = new OpenCodeReadinessBridge(executor, { productionE2eEvidence: evidence }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: ['OpenCode production launch requires a current production E2E evidence artifact'], - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }); - expect(evidence.read).toHaveBeenCalledOnce(); - }); - - it('allows dogfood readiness while surfacing missing production E2E evidence diagnostics', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: fakeEvidenceStore(null), - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'dogfood', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'supported_e2e_pending', - diagnostics: [ - 'OpenCode production launch requires a current production E2E evidence artifact', - ], - }); - }); - - it('keeps production readiness open when evidence matches runtime identity and project context', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore(productionEvidence()); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: evidence, - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'openai/gpt-5.4-mini', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'production_supported', - diagnostics: [], - }); - expect(evidence.read).toHaveBeenCalledWith({ - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), - opencodeVersion: '1.14.19', - binaryFingerprint: 'bin-1', - capabilitySnapshotId: 'cap-1', - }); - }); - - it('accepts production evidence recorded with a different OpenCode model when runtime identity matches', async () => { - const executor = fakeExecutor( - bridgeSuccess(readiness({ state: 'ready', launchAllowed: true })) - ); - const evidence = fakeEvidenceStore( - productionEvidence({ selectedModel: 'opencode/minimax-m2.5-free' }) - ); - const bridge = new OpenCodeReadinessBridge(executor, { - productionE2eEvidence: evidence, - }); - - await expect( - bridge.checkOpenCodeTeamLaunchReadiness({ - projectPath: '/repo', - selectedModel: 'opencode/nemotron-3-super-free', - requireExecutionProbe: true, - launchMode: 'production', - }) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'production_supported', - diagnostics: [], - }); - }); - it('routes state-changing launch commands through the guarded command service when configured', async () => { const executor = fakeExecutor( bridgeFailure('internal_error', 'direct bridge must not run', []) @@ -231,7 +112,6 @@ describe('OpenCodeReadinessBridge', () => { await expect( bridge.launchOpenCodeTeam({ - mode: 'dogfood', runId: 'run-1', laneId: 'primary', teamId: 'team-a', @@ -271,19 +151,6 @@ function fakeExecutor( }; } -function fakeEvidenceStore( - evidence: OpenCodeProductionE2EEvidence | null -): OpenCodeProductionE2EEvidenceReadPort & { read: ReturnType } { - return { - read: vi.fn(async () => ({ - ok: true, - evidence, - artifactPath: '/tmp/opencode-production-e2e.json', - diagnostics: [], - })), - }; -} - function bridgeSuccess( data: OpenCodeTeamLaunchReadiness ): OpenCodeBridgeSuccess { @@ -379,65 +246,3 @@ function readiness( ...overrides, }; } - -function productionEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ); - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-production-e2e.json', - binaryFingerprint: 'bin-1', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: buildOpenCodeProjectPathFingerprint('/repo'), - requiredSignals: Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index c05b26d3..7c67dd1c 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -1,17 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { OpenCodeTeamLaunchReadinessService, type OpenCodeApiCapabilityPort, type OpenCodeModelExecutionProbePort, type OpenCodeMcpToolProofPort, - type OpenCodeProductionE2EEvidencePort, type OpenCodeRuntimeInventory, type OpenCodeRuntimeInventoryPort, type OpenCodeRuntimeStoreReadinessPort, @@ -23,7 +18,6 @@ import type { } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; import type { OpenCodeMcpToolProof } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; -import type { OpenCodeProductionE2EEvidence } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy'; describe('OpenCodeTeamLaunchReadinessService', () => { it('returns not_installed before probing deeper runtime dependencies', async () => { @@ -84,16 +78,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); - it('blocks capability-compatible versions until production E2E evidence exists', async () => { - const ports = createPorts({ - evidence: null, - }); + it('does not require project-specific E2E evidence before runtime readiness checks', async () => { + const ports = createPorts(); await expect(service(ports).check(readinessInput())).resolves.toMatchObject({ - state: 'e2e_missing', - launchAllowed: false, - supportLevel: 'supported_e2e_pending', - missing: ['OpenCode version is capability-compatible but production E2E evidence is missing'], + state: 'ready', + launchAllowed: true, + supportLevel: 'production_supported', }); }); @@ -159,46 +150,10 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); - it('fails closed behind adapter feature gate after all runtime evidence is healthy', async () => { + it('allows launch when inventory, capabilities, stores, MCP and model probe are healthy', async () => { const ports = createPorts(); - await expect( - service(ports, { adapterEnabled: false }).check(readinessInput()) - ).resolves.toMatchObject({ - state: 'adapter_disabled', - launchAllowed: false, - missing: ['OpenCode team launch adapter is disabled by feature gate'], - }); - expect(ports.inventory.probe).not.toHaveBeenCalled(); - }); - - it('allows dogfood launch to continue without production E2E evidence after runtime checks pass', async () => { - const ports = createPorts({ evidence: null }); - - await expect( - service(ports, { launchMode: 'dogfood' }).check( - readinessInput({ requireExecutionProbe: true }) - ) - ).resolves.toMatchObject({ - state: 'ready', - launchAllowed: true, - supportLevel: 'supported_e2e_pending', - requiredToolsPresent: true, - runtimeStoresReady: true, - diagnostics: [ - 'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.', - ], - }); - expect(ports.mcpTools.prove).toHaveBeenCalled(); - expect(ports.modelExecution.verify).toHaveBeenCalled(); - }); - - it('allows launch only when inventory, capabilities, E2E, stores, MCP and model probe are healthy', async () => { - const ports = createPorts(); - - await expect( - service(ports, { adapterEnabled: true }).check(readinessInput()) - ).resolves.toMatchObject({ + await expect(service(ports).check(readinessInput())).resolves.toMatchObject({ state: 'ready', launchAllowed: true, modelId: 'openai/gpt-5.4-mini', @@ -218,20 +173,13 @@ describe('OpenCodeTeamLaunchReadinessService', () => { }); }); -function service( - ports: ReturnType, - options: { adapterEnabled?: boolean; launchMode?: 'disabled' | 'dogfood' | 'production' } = {} -): OpenCodeTeamLaunchReadinessService { +function service(ports: ReturnType): OpenCodeTeamLaunchReadinessService { return new OpenCodeTeamLaunchReadinessService( ports.inventory, ports.capabilities, ports.mcpTools, ports.runtimeStores, - ports.modelExecution, - ports.e2eEvidence, - options.launchMode - ? { launchMode: options.launchMode } - : { adapterEnabled: options.adapterEnabled ?? true } + ports.modelExecution ); } @@ -261,7 +209,6 @@ function createPorts( reason: string | null; diagnostics: string[]; }; - evidence?: OpenCodeProductionE2EEvidence | null; } = {} ): { inventory: OpenCodeRuntimeInventoryPort & { probe: ReturnType }; @@ -269,7 +216,6 @@ function createPorts( mcpTools: OpenCodeMcpToolProofPort & { prove: ReturnType }; runtimeStores: OpenCodeRuntimeStoreReadinessPort & { check: ReturnType }; modelExecution: OpenCodeModelExecutionProbePort & { verify: ReturnType }; - e2eEvidence: OpenCodeProductionE2EEvidencePort & { read: ReturnType }; } { return { inventory: { @@ -287,9 +233,6 @@ function createPorts( modelExecution: { verify: vi.fn(async () => overrides.modelProbe ?? modelProbe()), }, - e2eEvidence: { - read: vi.fn(async () => (overrides.evidence === undefined ? evidence() : overrides.evidence)), - }, }; } @@ -373,60 +316,3 @@ function modelProbe() { diagnostics: [], }; } - -function evidence(): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`); - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals'], - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId, - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints: OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })), - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: [sessionId], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - }; -} diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 7ed8cbc1..f40e0ceb 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -56,126 +56,120 @@ liveDescribe('OpenCode team provisioning live e2e', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - it( - 'creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', - async () => { - const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; - const orchestratorCli = - process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; - await assertExecutable(orchestratorCli); + it('creates and stops a pure OpenCode team through TeamProvisioningService using the live runtime adapter', async () => { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); - const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); - const bridgeEnv = { - ...createStableBridgeEnv(), - PATH: withBunOnPath(process.env.PATH ?? ''), - XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, - CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', - }; - const bridgeClient = new OpenCodeBridgeCommandClient({ - binaryPath: orchestratorCli, - tempDirectory: path.join(tempDir, 'bridge-input'), - env: bridgeEnv, - }); - const stateChangingCommands = createStateChangingCommands({ - bridge: bridgeClient, - controlDir: path.join(tempDir, 'control'), - }); - const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { - stateChangingCommands, - timeoutMs: 180_000, - launchTimeoutMs: 180_000, - reconcileTimeoutMs: 90_000, - stopTimeoutMs: 90_000, - }); - const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { - launchMode: 'dogfood', - }); - const svc = new TeamProvisioningService(); - svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); - const teamName = `opencode-team-provisioning-${Date.now()}`; - const progressEvents: TeamProvisioningProgress[] = []; + const teamName = `opencode-team-provisioning-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; - try { - const { runId } = await svc.createTeam( - { - teamName, - cwd: PROJECT_PATH, - providerId: 'opencode', - model: selectedModel, - skipPermissions: true, - members: [ - { - name: 'alice', - role: 'Developer', - providerId: 'opencode', - model: selectedModel, - }, - { - name: 'bob', - role: 'Reviewer', - providerId: 'opencode', - model: selectedModel, - }, - ], - }, - (progress) => { - progressEvents.push(progress); - } - ); - - expect(runId).toBeTruthy(); - const progressDump = progressEvents - .map((progress) => - [ - progress.state, - progress.message, - progress.messageSeverity, - progress.error, - progress.cliLogsTail, - ] - .filter(Boolean) - .join(' | ') - ) - .join('\n'); - expect( - progressEvents.some((progress) => - progress.message.includes('OpenCode team launch is ready') - ), - progressDump - ).toBe(true); - - const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); - expect(runtimeSnapshot.members.alice).toMatchObject({ - alive: true, - runtimeModel: selectedModel, - }); - expect(runtimeSnapshot.members.bob).toMatchObject({ - alive: true, - runtimeModel: selectedModel, - }); - await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( - { - lanes: { - primary: { - state: 'active', - }, + try { + const { runId } = await svc.createTeam( + { + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + model: selectedModel, + skipPermissions: true, + members: [ + { + name: 'alice', + role: 'Developer', + providerId: 'opencode', + model: selectedModel, }, - } - ); + { + name: 'bob', + role: 'Reviewer', + providerId: 'opencode', + model: selectedModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); - svc.stopTeam(teamName); - await waitUntil(async () => { - const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); - return Object.keys(laneIndex.lanes).length === 0; - }, 90_000); - } finally { - svc.stopTeam(teamName); - } - }, - 300_000 - ); + expect(runId).toBeTruthy(); + const progressDump = progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.alice).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ + lanes: { + primary: { + state: 'active', + }, + }, + }); + + svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000); + } finally { + svc.stopTeam(teamName); + } + }, 300_000); }); function createStateChangingCommands(input: { diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 3b6c9b0b..a95e0033 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -20,7 +20,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { diagnostics: ['OpenCode missing canonical app MCP tool id'], }) ); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect(adapter.prepare(launchInput())).resolves.toEqual({ ok: false, @@ -34,13 +34,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { projectPath: '/repo', selectedModel: 'openai/gpt-5.4-mini', requireExecutionProbe: true, - launchMode: 'production', }); }); it('uses runtime-only readiness for model-less preflight checks', async () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null })); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect( adapter.prepare(launchInput({ model: undefined, runtimeOnly: true })) @@ -54,7 +53,6 @@ describe('OpenCodeTeamRuntimeAdapter', () => { projectPath: '/repo', selectedModel: null, requireExecutionProbe: false, - launchMode: undefined, }); }); @@ -69,7 +67,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { missing: ['OpenCode bridge command timed out'], }) ); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect(adapter.launch(launchInput())).resolves.toMatchObject({ teamLaunchState: 'partial_failure', @@ -87,25 +85,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); - it('fails closed when launch mode is disabled', async () => { - const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true })); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge); - - await expect(adapter.prepare(launchInput())).resolves.toMatchObject({ - ok: false, - providerId: 'opencode', - reason: 'opencode_team_launch_disabled', - retryable: false, - }); - expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); - }); - it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => { const launchOpenCodeTeam = vi.fn(); const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, }); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); const result = await adapter.launch( launchInput({ @@ -138,7 +123,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, }); - const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); const result = await adapter.launch(launchInput({ expectedMembers: [] })); @@ -187,8 +172,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); await expect(adapter.launch(launchInput())).resolves.toMatchObject({ @@ -257,8 +241,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch({ @@ -393,8 +376,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { reconcileOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.reconcile({ @@ -488,8 +470,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch(launchInput()); @@ -539,8 +520,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch(launchInput()); @@ -576,8 +556,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch(launchInput()); @@ -588,7 +567,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => { runtimeAlive: false, livenessKind: 'runtime_process_candidate', runtimePid: 123, - runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification', + runtimeDiagnostic: + 'OpenCode runtime pid reported by bridge without local process verification', }); }); @@ -613,8 +593,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch(launchInput()); @@ -663,8 +642,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { capabilitySnapshotId: 'cap-1', })), launchOpenCodeTeam, - }), - { launchMode: 'dogfood' } + }) ); const result = await adapter.launch( diff --git a/test/main/services/team/OpenCodeVersionPolicy.test.ts b/test/main/services/team/OpenCodeVersionPolicy.test.ts index 991cacfc..cdf95be1 100644 --- a/test/main/services/team/OpenCodeVersionPolicy.test.ts +++ b/test/main/services/team/OpenCodeVersionPolicy.test.ts @@ -10,7 +10,6 @@ import { type OpenCodeApiEndpointKey, } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; import { - assertOpenCodeProductionE2EGate, buildOpenCodeBinaryFingerprint, evaluateOpenCodeSupport, parseOpenCodeSemver, @@ -19,12 +18,6 @@ import { type OpenCodeCompatibilitySnapshot, type OpenCodeRouteCompatibilityCache, } from '../../../../src/main/services/team/opencode/version/OpenCodeVersionPolicy'; -import { - OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS, - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS, - type OpenCodeProductionE2EEvidence, -} from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; -import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeVersionPolicy', () => { let tempDir: string; @@ -58,7 +51,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.4.0', capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -70,7 +62,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.14.19-beta.1', capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -79,12 +70,11 @@ describe('OpenCodeVersionPolicy', () => { }); }); - it('requires capabilities and production E2E evidence before production support', () => { + it('requires capabilities before support', () => { expect( evaluateOpenCodeSupport({ version: '1.14.19', capabilities: missingCapabilities(['POST permission reply route']), - evidence: passingEvidence(), }) ).toMatchObject({ supported: false, @@ -96,23 +86,6 @@ describe('OpenCodeVersionPolicy', () => { evaluateOpenCodeSupport({ version: '1.14.19', capabilities: readyCapabilities(), - evidence: null, - }) - ).toMatchObject({ - supported: false, - supportLevel: 'supported_e2e_pending', - diagnostics: [ - 'OpenCode version is capability-compatible but production E2E evidence is missing', - ], - }); - }); - - it('accepts supported version only when capabilities and E2E evidence pass', () => { - expect( - evaluateOpenCodeSupport({ - version: '1.14.19', - capabilities: readyCapabilities(), - evidence: passingEvidence(), }) ).toMatchObject({ supported: true, @@ -121,31 +94,16 @@ describe('OpenCodeVersionPolicy', () => { }); }); - it('rejects stale or incomplete production E2E evidence', () => { + it('accepts supported version when capabilities pass', () => { expect( - assertOpenCodeProductionE2EGate({ - evidence: passingEvidence({ version: '1.14.18' }), - testedVersion: '1.14.19', + evaluateOpenCodeSupport({ + version: '1.14.19', + capabilities: readyCapabilities(), }) ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence version 1.14.18 does not match tested version 1.14.19', - ]), - }); - - expect( - assertOpenCodeProductionE2EGate({ - evidence: passingEvidence({ - requiredSignals: requiredSignals({ canonical_log_projection_observed: false }), - }), - testedVersion: '1.14.19', - }) - ).toMatchObject({ - ok: false, - diagnostics: expect.arrayContaining([ - 'OpenCode production E2E evidence is missing signals: canonical_log_projection_observed', - ]), + supported: true, + supportLevel: 'production_supported', + diagnostics: [], }); }); @@ -260,76 +218,6 @@ function missingCapabilities(missing: string[]) { }; } -function passingEvidence( - overrides: Partial = {} -): OpenCodeProductionE2EEvidence { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + 60_000).toISOString(); - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => `agent_teams_${tool}`); - const durableCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.map((name) => ({ - name, - observedAt: createdAt, - })); - - return { - schemaVersion: 1, - evidenceId: 'e2e-1', - createdAt, - expiresAt, - version: '1.14.19', - passed: true, - artifactPath: '/tmp/opencode-e2e', - binaryFingerprint: 'version:1.14.19', - capabilitySnapshotId: 'cap-1', - selectedModel: 'openai/gpt-5.4-mini', - projectPathFingerprint: 'project-a', - requiredSignals: requiredSignals(), - mcpTools: { - requiredTools: requiredToolIds, - observedTools: requiredToolIds, - }, - launch: { - runId: 'run-1', - teamId: 'team-a', - teamLaunchState: 'ready', - memberCount: 1, - sessions: [ - { - memberName: 'Dev', - sessionId: 'ses-1', - launchState: 'confirmed_alive', - }, - ], - durableCheckpoints, - }, - reconcile: { - runId: 'run-1', - teamLaunchState: 'ready', - memberCount: 1, - }, - stop: { - runId: 'run-1', - stopped: true, - stoppedSessionIds: ['ses-1'], - }, - logProjection: { - observed: true, - projectedMessageCount: 1, - }, - ...overrides, - }; -} - -function requiredSignals( - overrides: Partial< - Record<(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number], boolean> - > = {} -) { - return Object.fromEntries( - OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [signal, overrides[signal] ?? true]) - ) as OpenCodeProductionE2EEvidence['requiredSignals']; -} - function compatibilitySnapshot( overrides: Partial ): OpenCodeCompatibilitySnapshot { @@ -349,7 +237,6 @@ function compatibilitySnapshot( supported: true, supportLevel: 'production_supported', apiCapabilities: readyCapabilities(), - testedEvidencePath: '/tmp/opencode-e2e', diagnostics: [], ...overrides, }; diff --git a/test/main/services/team/TeamLaunchStateEvaluator.test.ts b/test/main/services/team/TeamLaunchStateEvaluator.test.ts index dff9760f..ddfb4168 100644 --- a/test/main/services/team/TeamLaunchStateEvaluator.test.ts +++ b/test/main/services/team/TeamLaunchStateEvaluator.test.ts @@ -77,6 +77,7 @@ describe('TeamLaunchStateEvaluator', () => { confirmedCount: 0, pendingCount: 2, failedCount: 0, + skippedCount: 0, runtimeAlivePendingCount: 0, shellOnlyPendingCount: 0, runtimeProcessPendingCount: 0, @@ -86,6 +87,72 @@ describe('TeamLaunchStateEvaluator', () => { }); }); + it('keeps skipped members terminal and out of pending counts', () => { + const summary = summarizePersistedLaunchMembers(['alice', 'bob'], { + alice: { + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }, + bob: { + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }, + } as any); + + expect(summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + skippedCount: 1, + }); + }); + + it('does not preserve runtimeAlive for skipped persisted members', () => { + const snapshot = normalizePersistedLaunchSnapshot('demo', { + version: 2, + teamName: 'demo', + updatedAt: '2026-04-23T00:00:00.000Z', + launchPhase: 'finished', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'runtime_process', + lastEvaluatedAt: '2026-04-23T00:00:00.000Z', + }, + }, + }); + + expect(snapshot?.members.alice).toMatchObject({ + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + agentToolAccepted: false, + skippedForLaunch: true, + }); + + const statuses = snapshotToMemberSpawnStatuses(snapshot!); + expect(statuses.alice).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + agentToolAccepted: false, + skippedForLaunch: true, + }); + }); + it('counts registered-only persisted liveness as no-runtime pending', () => { const summary = summarizePersistedLaunchMembers(['alice'], { alice: { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 554cc524..9584ca92 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -1550,6 +1550,191 @@ describe('TeamProvisioningService', () => { expect(restartMessage).toContain('Their workflow: Use the updated checklist'); }); + it('retries a failed teammate without live runtime by resetting spawn status to spawning', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate "bob" failed to start: spawn failed', + error: 'Teammate "bob" failed to start: spawn failed', + agentToolAccepted: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => []); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.restartMember('codex-team', 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'spawning', + launchState: 'starting', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + agentToolAccepted: false, + }); + expect(run.pendingMemberRestarts.has('bob')).toBe(true); + expect(sendMessageToRun).toHaveBeenCalledTimes(1); + expect(sendMessageToRun).toHaveBeenCalledWith( + run, + expect.stringContaining('Teammate "bob" with role "Developer" was restarted from the UI.') + ); + }); + + it('skips a failed teammate for the current launch without marking it alive', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate "bob" failed to start: spawn failed', + error: 'Teammate "bob" failed to start: spawn failed', + agentToolAccepted: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.isLaunch = true; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.skipMemberForLaunch('codex-team', 'bob'); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + agentToolAccepted: false, + }); + expect(run.pendingMemberRestarts.has('bob')).toBe(false); + expect(sendMessageToRun).toHaveBeenCalledWith( + run, + expect.stringContaining('Teammate "bob" was skipped for this launch') + ); + }); + + it('rejects skipping a failed teammate while a retry is already in progress', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'spawn failed', + error: 'spawn failed', + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.pendingMemberRestarts.set('bob', { + requestedAt: new Date().toISOString(), + desired: { name: 'bob', role: 'Developer' }, + }); + + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Codex Team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', role: 'Developer' }, + ], + })), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).aliveRunByTeam.set('codex-team', run.runId); + (svc as any).runs.set(run.runId, run); + + await expect(svc.skipMemberForLaunch('codex-team', 'bob')).rejects.toThrow( + 'already in progress' + ); + }); + it('does not let removed base-member metadata override a suffixed teammate during restart', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -7106,7 +7291,8 @@ describe('TeamProvisioningService', () => { { alive: false, livenessKind: 'runtime_process_candidate', - runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnostic: + 'OpenCode runtime pid is alive, but process identity is unverified', runtimeDiagnosticSeverity: 'warning', }, ], @@ -7337,6 +7523,117 @@ describe('TeamProvisioningService', () => { }); }); + it('does not resurrect a skipped teammate when live runtime metadata is strong', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: true, + livenessKind: 'runtime_process', + pid: 123, + providerId: 'codex', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('codex-team', { + bob: createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skipReason: 'Skipped by user after launch failure: spawn failed', + }), + }); + + expect(result.bob).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + error: undefined, + livenessSource: undefined, + }); + }); + + it('does not resurrect a skipped teammate during spawn status audit', async () => { + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + const svc = new TeamProvisioningService(); + (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set(['bob'])); + (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['bob'])); + + await (svc as any).auditMemberSpawnStatuses(run); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }); + }); + + it('does not convert a skipped teammate to failed during final missing-member reconciliation', async () => { + const run = createMemberSpawnRun({ + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + const svc = new TeamProvisioningService(); + (svc as any).getRegisteredTeamMemberNames = vi.fn(async () => new Set()); + + await (svc as any).finalizeMissingRegisteredMembersAsFailed(run); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }); + }); + it('does not downgrade an already-online teammate when waiting is reported later', () => { const run = createMemberSpawnRun({ memberSpawnStatuses: new Map([ diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 112abc53..5f9895c8 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -473,11 +473,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => { return { ok: false as const, providerId: 'opencode' as const, - reason: 'e2e_missing', + reason: 'model_unavailable', retryable: false, - diagnostics: [ - 'OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', - ], + diagnostics: ['Selected model opencode/nemotron-3-super-free is not available'], warnings: [], }; } @@ -529,7 +527,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { 'Selected model opencode/minimax-m2.5-free verified for launch.' ); expect(result.message).toBe( - 'Selected model opencode/nemotron-3-super-free is unavailable. OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free' + 'Selected model opencode/nemotron-3-super-free is unavailable. Selected model opencode/nemotron-3-super-free is not available' ); }); @@ -687,56 +685,6 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); - it('reports missing OpenCode project evidence as a provider note instead of model failures', async () => { - const projectEvidenceDiagnostic = - 'OpenCode production E2E evidence artifact has no entry for the current working directory'; - const projectEvidenceNote = - 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.'; - const prepare = vi.fn(async () => ({ - ok: false as const, - providerId: 'opencode' as const, - reason: 'e2e_missing', - retryable: false, - diagnostics: [projectEvidenceDiagnostic], - warnings: [], - })); - const registry = new TeamRuntimeAdapterRegistry([ - { - providerId: 'opencode', - prepare, - launch: vi.fn(), - reconcile: vi.fn(), - stop: vi.fn(), - } as any, - ]); - const svc = new TeamProvisioningService(); - svc.setRuntimeAdapterRegistry(registry); - - const result = await svc.prepareForProvisioning(tempRoot, { - providerId: 'opencode', - forceFresh: true, - modelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'], - modelVerificationMode: 'compatibility', - }); - - expect(result.ready).toBe(true); - expect(result.message).toBe('CLI is ready to launch (see notes)'); - expect(result.details).toEqual([ - 'Selected model opencode/minimax-m2.5-free verified for launch.', - 'Selected model opencode/ling-2.6-flash-free verified for launch.', - ]); - expect(result.warnings).toEqual([projectEvidenceNote]); - expect(result.message).not.toContain('unavailable'); - expect(prepare).toHaveBeenCalledTimes(1); - expect(prepare).toHaveBeenCalledWith( - expect.objectContaining({ - providerId: 'opencode', - model: undefined, - runtimeOnly: true, - }) - ); - }); - it('treats retryable OpenCode compatibility failures as blocking selected-model diagnostics', async () => { const prepare = vi.fn(async () => ({ ok: false as const, diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 66fe3c1e..20607fb2 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -613,6 +613,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain( 'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.' ); + expect(prompt).toContain('retry tool search at most once'); + expect(prompt).toContain('Do NOT keep searching for member_briefing'); }); it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => { diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 14648d1e..d7e4cb84 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -99,7 +99,6 @@ describe('electron userData migration', () => { ['mcp-server/1.3.0/package.json', '{"type":"module"}'], ['opencode-bridge/command-ledger.json', '{"commands":[]}'], ['opencode-bridge/command-leases.json', '{"leases":[]}'], - ['opencode-bridge/production-e2e-evidence.json', '{"ok":true}'], ['logs/claude-cli-auth-diag.ndjson', '{"event":"auth"}\n'], ['Local Storage/leveldb/000003.log', 'renderer localStorage bytes'], ['future-feature/state.json', '{"kept":true}'], diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 3bd94ee2..50929675 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -746,7 +746,7 @@ describe('TeamModelSelector disabled Codex models', () => { supported: true, authenticated: true, statusMessage: 'OpenCode team launch is gated', - detailMessage: 'OpenCode production E2E evidence is missing', + detailMessage: 'OpenCode runtime store needs recovery', capabilities: { teamLaunch: false }, models: [], }, @@ -773,7 +773,7 @@ describe('TeamModelSelector disabled Codex models', () => { ); expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.getAttribute('title')).toContain( - 'OpenCode production E2E evidence is missing' + 'OpenCode runtime store needs recovery' ); expect(openCodeButton?.textContent).toContain('Gate'); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index f3fc17d3..a9af3989 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -167,7 +167,10 @@ describe('TeamProvisioningBanner launch-step alignment', () => { cliLogsTail: '', assistantOutput: '', }; - storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record< + string, + unknown + >; storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'waiting', launchState: 'starting' }, bob: { status: 'waiting', launchState: 'starting' }, @@ -281,6 +284,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 3, + runtimeProcessPendingCount: 3, }, source: 'merged', }; diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 6fe64e72..5f82f2cd 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -327,16 +327,13 @@ describe('ProvisioningProviderStatusList', () => { { providerId: 'opencode', status: 'failed', - details: [ - 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', - ], + details: ['nemotron-3-super-free - unavailable - selected model is not available'], }, ], }) ).toEqual({ state: 'failed', - message: - 'nemotron-3-super-free - unavailable - OpenCode production E2E evidence artifact has no entry for selected model opencode/nemotron-3-super-free', + message: 'nemotron-3-super-free - unavailable - selected model is not available', }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index bdfa9007..46a183da 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -256,68 +256,6 @@ describe('runProviderPrepareDiagnostics', () => { ); }); - it('shows missing OpenCode project evidence as a provider note instead of model failures', async () => { - const projectEvidenceNote = - 'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.'; - const prepareProvisioning = vi - .fn< - ( - cwd?: string, - providerId?: TeamProviderId, - providerIds?: TeamProviderId[], - selectedModels?: string[], - limitContext?: boolean, - modelVerificationMode?: 'compatibility' | 'deep' - ) => Promise - >() - .mockResolvedValue({ - ready: true, - message: 'CLI is ready to launch (see notes)', - details: [ - 'Selected model opencode/minimax-m2.5-free verified for launch.', - 'Selected model opencode/ling-2.6-flash-free verified for launch.', - ], - warnings: [projectEvidenceNote], - }); - - const result = await runProviderPrepareDiagnostics({ - cwd: '/tmp/project', - providerId: 'opencode', - selectedModelIds: ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'], - prepareProvisioning, - }); - - expect(result.status).toBe('notes'); - expect(result.details).toEqual([ - projectEvidenceNote, - 'minimax-m2.5-free - verified', - 'ling-2.6-flash-free - verified', - ]); - expect(result.details.filter((detail) => detail === projectEvidenceNote)).toHaveLength(1); - expect(result.warnings).toEqual([projectEvidenceNote]); - expect(result.modelResultsById).toEqual({ - 'opencode/minimax-m2.5-free': { - status: 'ready', - line: 'minimax-m2.5-free - verified', - warningLine: null, - }, - 'opencode/ling-2.6-flash-free': { - status: 'ready', - line: 'ling-2.6-flash-free - verified', - warningLine: null, - }, - }); - expect(prepareProvisioning).toHaveBeenCalledTimes(1); - expect(prepareProvisioning).toHaveBeenCalledWith( - '/tmp/project', - 'opencode', - ['opencode'], - ['opencode/minimax-m2.5-free', 'opencode/ling-2.6-flash-free'], - undefined, - 'compatibility' - ); - }); - it('normalizes raw Codex API error envelopes into a clean model reason', async () => { const prepareProvisioning = vi.fn< ( diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index b97dc859..4e964b65 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ @@ -56,6 +56,33 @@ const currentTask: TeamTaskWithKanban = { status: 'in_progress', } as unknown as TeamTaskWithKanban; +const failedSpawnEntry: MemberSpawnStatusEntry = { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'spawn failed', + agentToolAccepted: false, + livenessKind: 'not_found', + runtimeDiagnostic: 'spawn failed', + runtimeDiagnosticSeverity: 'error', + updatedAt: '2026-04-24T12:00:00.000Z', +}; + +const skippedSpawnEntry: MemberSpawnStatusEntry = { + status: 'skipped', + launchState: 'skipped_for_launch', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: spawn failed', + skippedAt: '2026-04-24T12:01:00.000Z', + updatedAt: '2026-04-24T12:01:00.000Z', +}; + describe('MemberCard starting-state visuals', () => { afterEach(() => { document.body.innerHTML = ''; @@ -582,4 +609,309 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('renders retry for failed teammate launches', 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(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onRestartMember: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders skip for failed teammate launches', 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(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onSkipMemberForLaunch: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('retries failed teammate launches without opening the member row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + let resolveRetry!: () => void; + const retryPromise = new Promise((resolve) => { + resolveRetry = resolve; + }); + const onRestartMember = vi.fn(() => retryPromise); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onClick, + onRestartMember, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(onRestartMember).toHaveBeenCalledWith('alice'); + expect(onClick).not.toHaveBeenCalled(); + expect(host.querySelector('[aria-label="Retrying teammate"]')).not.toBeNull(); + + await act(async () => { + resolveRetry(); + await retryPromise; + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('skips failed teammate launches without opening the member row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + let resolveSkip!: () => void; + const skipPromise = new Promise((resolve) => { + resolveSkip = resolve; + }); + const onSkipMemberForLaunch = vi.fn(() => skipPromise); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onClick, + onSkipMemberForLaunch, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + }); + + expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice'); + expect(onClick).not.toHaveBeenCalled(); + expect(host.querySelector('[aria-label="Skipping teammate"]')).not.toBeNull(); + + await act(async () => { + resolveSkip(); + await skipPromise; + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps retry available and exposes retry errors after rejection', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onRestartMember = vi.fn(async () => { + throw new Error('restart failed'); + }); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onRestartMember, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onRestartMember).toHaveBeenCalledWith('alice'); + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + expect(host.textContent).toContain('restart failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps skip available and exposes skip errors after rejection', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onSkipMemberForLaunch = vi.fn(async () => { + throw new Error('skip failed'); + }); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnRuntimeAlive: false, + spawnError: 'spawn failed', + spawnEntry: failedSpawnEntry, + onSkipMemberForLaunch, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice'); + expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull(); + expect(host.textContent).toContain('skip failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows skipped teammates as skipped and keeps retry available', 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(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'skipped', + spawnLaunchState: 'skipped_for_launch', + spawnRuntimeAlive: false, + spawnEntry: skippedSpawnEntry, + onRestartMember: vi.fn(), + onSkipMemberForLaunch: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('skipped'); + expect(host.textContent).toContain('Skipped by user after launch failure'); + expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Skip for this launch"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index 36ba0fdb..02516601 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -8,10 +8,45 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ MemberCard: ({ member, spawnError, + spawnStatus, + spawnLaunchState, + onRestartMember, + onSkipMemberForLaunch, }: { member: ResolvedTeamMember; spawnError?: string; - }) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''), + spawnStatus?: string; + spawnLaunchState?: string; + onRestartMember?: (memberName: string) => void; + onSkipMemberForLaunch?: (memberName: string) => void; + }) => + React.createElement( + 'div', + { 'data-testid': `member-${member.name}` }, + spawnError ?? '', + onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') + ? React.createElement( + 'button', + { + 'data-testid': `retry-${member.name}`, + type: 'button', + onClick: () => onRestartMember(member.name), + }, + 'retry' + ) + : null, + onSkipMemberForLaunch && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') + ? React.createElement( + 'button', + { + 'data-testid': `skip-${member.name}`, + type: 'button', + onClick: () => onSkipMemberForLaunch(member.name), + }, + 'skip' + ) + : null + ), })); import { MemberList } from '@renderer/components/team/members/MemberList'; @@ -98,4 +133,126 @@ describe('MemberList spawn-status memoization', () => { await Promise.resolve(); }); }); + + it('passes retry callbacks to failed member cards and rerenders when the callback changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members = [member]; + const firstRestart = vi.fn(); + const secondRestart = vi.fn(); + const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onRestartMember: firstRestart, + }) + ); + await Promise.resolve(); + }); + + const firstRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement; + expect(firstRetry).not.toBeNull(); + + await act(async () => { + firstRetry.click(); + await Promise.resolve(); + }); + + expect(firstRestart).toHaveBeenCalledWith('bob'); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onRestartMember: secondRestart, + }) + ); + await Promise.resolve(); + }); + + const secondRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement; + expect(secondRetry).not.toBeNull(); + + await act(async () => { + secondRetry.click(); + await Promise.resolve(); + }); + + expect(secondRestart).toHaveBeenCalledWith('bob'); + expect(firstRestart).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members = [member]; + const firstSkip = vi.fn(); + const secondSkip = vi.fn(); + const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onSkipMemberForLaunch: firstSkip, + }) + ); + await Promise.resolve(); + }); + + const firstButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement; + expect(firstButton).not.toBeNull(); + + await act(async () => { + firstButton.click(); + await Promise.resolve(); + }); + + expect(firstSkip).toHaveBeenCalledWith('bob'); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: spawnStatuses, + onSkipMemberForLaunch: secondSkip, + }) + ); + await Promise.resolve(); + }); + + const secondButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement; + expect(secondButton).not.toBeNull(); + + await act(async () => { + secondButton.click(); + await Promise.resolve(); + }); + + expect(secondSkip).toHaveBeenCalledWith('bob'); + expect(firstSkip).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/provisioningSteps.test.ts b/test/renderer/components/team/provisioningSteps.test.ts index 69689fb8..eae7686f 100644 --- a/test/renderer/components/team/provisioningSteps.test.ts +++ b/test/renderer/components/team/provisioningSteps.test.ts @@ -104,4 +104,25 @@ describe('getLaunchJoinMilestonesFromMembers', () => { expect(milestones.processOnlyAliveCount).toBe(0); expect(milestones.pendingSpawnCount).toBe(4); }); + + it('counts skipped teammates separately from pending and failed launch members', () => { + const milestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses: { + alice: { + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }, + }); + + expect(milestones.skippedSpawnCount).toBe(1); + expect(milestones.failedSpawnCount).toBe(0); + expect(milestones.pendingSpawnCount).toBe(3); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 04313f64..6ca20000 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -29,6 +29,7 @@ const hoisted = vi.hoisted(() => ({ permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), restartMember: vi.fn(), + skipMemberForLaunch: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), invalidateTaskChangeSummaries: vi.fn(), @@ -53,6 +54,7 @@ vi.mock('@renderer/api', () => ({ permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, restartMember: hoisted.restartMember, + skipMemberForLaunch: hoisted.skipMemberForLaunch, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, onProvisioningProgress: hoisted.onProvisioningProgress, @@ -233,6 +235,7 @@ describe('teamSlice actions', () => { hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); hoisted.restartMember.mockResolvedValue(undefined); + hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); it('maps inbox verify failure to user-friendly text', async () => { @@ -2537,6 +2540,57 @@ describe('teamSlice actions', () => { expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); }); + it('skipMemberForLaunch refreshes spawn statuses, runtime snapshot, and team list', async () => { + const store = createSliceStore(); + const refreshTeams = vi.fn(async () => undefined); + store.setState({ fetchTeams: refreshTeams }); + hoisted.getMemberSpawnStatuses.mockResolvedValue({ + statuses: { + alice: createMemberSpawnStatus({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + }), + }, + runId: 'runtime-run', + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot()); + + await store.getState().skipMemberForLaunch('my-team', 'alice'); + + expect(hoisted.skipMemberForLaunch).toHaveBeenCalledWith('my-team', 'alice'); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({ + alice: expect.objectContaining({ + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + }), + }); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot()); + expect(refreshTeams).toHaveBeenCalled(); + }); + + it('skipMemberForLaunch refreshes launch data even when skip fails', async () => { + const store = createSliceStore(); + const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined); + const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined); + const refreshTeams = vi.fn(async () => undefined); + store.setState({ + fetchMemberSpawnStatuses: refreshSpawnStatuses, + fetchTeamAgentRuntime: refreshRuntimeSnapshot, + fetchTeams: refreshTeams, + }); + hoisted.skipMemberForLaunch.mockRejectedValueOnce(new Error('skip failed')); + + await expect(store.getState().skipMemberForLaunch('my-team', 'alice')).rejects.toThrow( + 'skip failed' + ); + + expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); + expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); + expect(refreshTeams).toHaveBeenCalled(); + }); + it('clears stale runtime snapshots on delete', async () => { const store = createSliceStore(); store.setState({ diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index dcdd7901..0bd0c2f8 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -293,6 +293,75 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.compactTone).toBe('warning'); }); + it('shows skipped teammates as a continued launch instead of still joining', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3d', + teamName: 'mixed-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + configReady: true, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + bob: { + status: 'skipped', + launchState: 'skipped_for_launch', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + skippedForLaunch: true, + skipReason: 'Skipped by user after launch failure: OpenCode lane failed', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['bob'], + summary: { + confirmedCount: 0, + pendingCount: 0, + failedCount: 0, + skippedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Launch continued - 1/1 teammates skipped'); + expect(presentation?.panelMessage).toContain('bob skipped for this launch'); + expect(presentation?.compactTitle).toBe('Launch continued with skipped teammates'); + expect(presentation?.compactDetail).toBe('bob skipped'); + expect(presentation?.compactTone).toBe('warning'); + expect(presentation?.currentStepIndex).toBe(2); + expect(presentation?.hasMembersStillJoining).toBe(false); + }); + it('prefers live member spawn statuses over a stale persisted launch summary', () => { const presentation = buildTeamProvisioningPresentation({ progress: {